Files
FabriceandClaude Opus 5.5 ada3d76f50 Administration native sur le Mac, réencodage sélectif et aide intégrée
- admin.sh : RTG lancé directement sur le Mac (plus de conteneur), nginx
  joint l'interface par host.docker.internal:8765 ; variante conteneur
  conservée (admin-docker.sh, docker-compose-conteneur.yml)
- Compression : réglages lus dans les fichiers (CRF, codec), refaire
  seulement le 1080p ou le 720p, seulement si les réglages ont changé ;
  réglages vidéo propres à chaque voyage ; « Compression actuelle » affichée
- Plus d'arrêt automatique après 2 h d'inactivité (--arret-inactif, 0 par défaut) ;
  arrêt propre qui attend la fin des encodages en cours
- Aide contextuelle dans l'interface (rtg/interface/aide) ; README centré
  sur l'installation et la mise en place
- Cœurs attribués au conteneur détectés (cgroup) et signalés

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mf2kATMtqVABHSYdxJMnim
2026-10-19 05:32:05 +00:00

241 lines
12 KiB
Python

"""Interface : jours imposés aux traces sans heures, suggestion d'après les
photos, et quelques échanges avec le serveur local."""
import os
import threading
import urllib.parse
import urllib.request
from datetime import date
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)
def _etape(chemin, lat0, lon0):
pts = "".join(f'<trkpt lat="{lat0 + i * 0.01:.5f}" lon="{lon0 + i * 0.012:.5f}"></trkpt>' for i in range(50))
with open(chemin, "w") as f:
f.write(f'<?xml version="1.0"?><gpx version="1.1" creator="68degres.fr" '
f'xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>{pts}</trkseg></trk></gpx>')
@pytest.fixture
def voyage_sans_heures(tmp_path):
racine = tmp_path / "site"
voyage = racine / "Ecosse-2027"
gpx, photos = tmp_path / "gpx", tmp_path / "photos"
for d in (voyage, gpx, photos):
d.mkdir(parents=True)
_etape(gpx / "Etape 1.gpx", 55.9, 3.2)
_etape(gpx / "Etape 2.gpx", 56.7, 2.24)
_photo(str(photos / "P1.jpg"), "2027:06:11 10:00:00", gps=(56.75, 2.30))
config.ecrire_toml(str(racine / "site.toml"), {"titre": "Test"})
config.ecrire_toml(str(voyage / "voyage.toml"), {
"titre": "Écosse", "sources": {"traces": [str(gpx)], "videos": [], "photos": [str(photos)]}})
return racine, voyage
def _cfg(racine, voyage):
return config.reglages_voyage(str(voyage), config.reglages_site(str(racine)))
def test_jour_impose_et_suggestion(voyage_sans_heures):
racine, voyage = voyage_sans_heures
a = sources.analyser(str(voyage), _cfg(racine, voyage))
assert not a.journees and len(a.non_datees) == 2
medias = sources.positions_medias(str(voyage), a)
par_nom = {os.path.basename(f): p for f, p in a.non_datees.items()}
assert sources.suggerer_jour(par_nom["Etape 2.gpx"], medias) == (date(2027, 6, 11), 1)
assert sources.suggerer_jour(par_nom["Etape 1.gpx"], medias) == (None, 0)
config.modifier_toml(str(voyage / "voyage.toml"), {"dates_traces": {"Etape 1.gpx": "2027-06-10"}})
a = sources.analyser(str(voyage), _cfg(racine, voyage))
assert sorted(a.journees) == [date(2027, 6, 10)]
assert [os.path.basename(f) for f in a.non_datees] == ["Etape 2.gpx"]
# Le titre et les sources ont survécu à la réécriture du fichier
assert config.lire_toml(str(voyage / "voyage.toml"))["titre"] == "Écosse"
@pytest.fixture
def client(voyage_sans_heures):
racine, voyage = voyage_sans_heures
app = serveur.Application(str(racine))
srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
port = srv.server_address[1]
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, 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()
def test_serveur(client):
appel, racine, voyage = client
code, html = appel("/")
assert code == 200 and "Écosse" in html
code, html = appel("/voyage/analyse?v=Ecosse-2027")
assert "2 traces à rattacher" in html and "11 juin 2027" in html
# Répartir : un jour par trace dans l'ordre des noms
code, html = appel("/voyage/dates-suite", {"v": "Ecosse-2027", "debut": "2027-06-10"})
assert "2 journées" in html
assert config.lire_toml(str(voyage / "voyage.toml"))["dates_traces"] == {
"Etape 1.gpx": "2027-06-10", "Etape 2.gpx": "2027-06-11"}
# Requête de modification sans l'en-tête htmx : refusée
code, _ = appel("/voyage/date", {"v": "Ecosse-2027", "fichier": "Etape 1.gpx", "jour": ""}, htmx=False)
assert code == 403
# 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)
def test_derriere_le_serveur_web(voyage_sans_heures, tmp_path, monkeypatch):
"""--proxy --prefixe /admin : adresses préfixées, mot de passe, cookie Secure."""
racine, voyage = voyage_sans_heures
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("APPDATA", str(tmp_path / "config"))
acces.definir("un mot de passe solide")
app = serveur.Application(str(racine), "/admin")
srv = ThreadingHTTPServer(("127.0.0.1", 0), None)
port = srv.server_address[1]
gardien = Gardien(port, proxy=True, prefixe=app.prefixe)
srv.RequestHandlerClass = serveur.creer_gestionnaire(app, port, gardien)
threading.Thread(target=srv.serve_forever, daemon=True).start()
ouvreur = urllib.request.build_opener(_SansRedirection)
entetes = {"Host": "roadtrip.example", "X-Forwarded-For": "203.0.113.7", "X-Forwarded-Proto": "https"}
def requete(chemin, donnees=None, extra=None):
req = urllib.request.Request(f"http://127.0.0.1:{port}{chemin}", headers=dict(entetes, **(extra or {})),
data=urllib.parse.urlencode(donnees).encode() if donnees else None)
try:
return ouvreur.open(req)
except urllib.error.HTTPError as e:
return e
try:
r = requete("/admin/")
assert r.status == 303 and r.headers["Location"] == "/admin/connexion"
assert requete("/").status == 404 # hors du préfixe
assert requete("/admin/connexion").status == 200
r = requete("/admin/connexion", {"mot_de_passe": "un mot de passe solide"}, {"HX-Request": "true"})
cookie = r.headers["Set-Cookie"]
assert r.headers["HX-Redirect"] == "/admin/" and "Path=/admin/" in cookie and "Secure" in cookie
sid = cookie.split(";")[0]
r = requete("/admin/", extra={"Cookie": sid})
html = r.read().decode()
assert r.status == 200 and 'href="/admin/nouveau"' in html and 'href="/"' in html # Voir le site
# Les échecs sont comptés par visiteur (X-Forwarded-For), pas pour le serveur web
for _ in range(5):
requete("/admin/connexion", {"mot_de_passe": "faux"}, {"HX-Request": "true"})
assert gardien.attente("203.0.113.7") > 0 and gardien.attente("127.0.0.1") == 0
finally:
srv.shutdown()
serveur.ENV.globals["base"] = ""
def test_aide(client):
appel, racine, voyage = client
code, html = appel("/aide")
assert code == 200 and "Prise en main" in html and 'href="/aide/generer"' in html
code, html = appel("/aide/generer?panneau=1")
assert "<h1>Générer</h1>" in html and "CRF" in html
code, html = appel("/aide/index?panneau=1")
assert 'hx-get="/aide/sources?panneau=1"' in html # liens internes au panneau
code, html = appel("/aide/nexistepas")
assert "introuvable" in html
# Chaque lien entre pages d'aide mène à une page qui existe
import glob, re as _re
dossier = os.path.join(os.path.dirname(serveur.__file__), "aide")
sujets = {os.path.basename(f)[:-3] for f in glob.glob(os.path.join(dossier, "*.md"))}
for f in glob.glob(os.path.join(dossier, "*.md")):
for cible in _re.findall(r"\]\(([a-z]+)\.md\)", open(f, encoding="utf-8").read()):
assert cible in sujets, (f, cible)