Administration depuis le site : bouton Connexion sur l'accueil
site.toml [administration] adresse : l'accueil affiche un bouton « Connexion » vers l'interface. Celle-ci peut tourner derrière le serveur web du site (rtg interface --proxy --prefixe /admin) : adresses préfixées, mot de passe obligatoire, visiteur identifié par X-Forwarded-For, cookie Secure limité au préfixe quand le site est en HTTPS. Exemples nginx et Apache dans le README. Le bouton Arrêter ne remplace plus le bloc de la tâche en même temps que la relève. 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
71779f6f53
commit
4366db94ed
22 files changed
+229
-86
No files matched your search
+67
-26
@@ -99,8 +99,12 @@ def json_rep(donnees):
|
||||
|
||||
# --- Application ---------------------------------------------------------------
|
||||
class Application:
|
||||
def __init__(self, racine):
|
||||
def __init__(self, racine, prefixe=""):
|
||||
self.racine = os.path.abspath(racine)
|
||||
# Préfixe d'adresse quand l'interface est servie sous le site
|
||||
# (https://…/admin/ derrière le serveur web) : « /admin ».
|
||||
self.prefixe = ("/" + prefixe.strip("/")) if prefixe.strip("/") else ""
|
||||
ENV.globals["base"] = self.prefixe
|
||||
self._cache = {} # voyage → (empreinte des sources, analyse)
|
||||
self.routes = []
|
||||
for methode, motif, fn in [
|
||||
@@ -171,7 +175,7 @@ class Application:
|
||||
if not os.path.exists(chemin):
|
||||
modifier_toml(chemin, {"titre": "Nos Road-Trips"},
|
||||
entete="Réglages du site RoadTripsGenerator (rtg).")
|
||||
return Reponse("", entetes={"HX-Redirect": "/"})
|
||||
return Reponse("", entetes={"HX-Redirect": self.prefixe + "/"})
|
||||
|
||||
def groupes_existants(self):
|
||||
res = []
|
||||
@@ -197,7 +201,7 @@ class Application:
|
||||
return page("nouveau.html", **self.contexte(groupes=self.groupes_existants(),
|
||||
erreur=str(e), form=r.form))
|
||||
rel = os.path.relpath(chemin, self.racine).replace(os.sep, "/")
|
||||
return Reponse("", entetes={"HX-Redirect": f"/voyage?v={quote(rel)}#sources"})
|
||||
return Reponse("", entetes={"HX-Redirect": f"{self.prefixe}/voyage?v={quote(rel)}#sources"})
|
||||
|
||||
# --- Un voyage
|
||||
def voyage(self, r):
|
||||
@@ -359,11 +363,11 @@ class Application:
|
||||
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)}"
|
||||
vignette = f"{self.prefixe}/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
|
||||
vignette = f"{self.prefixe}/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)
|
||||
|
||||
@@ -501,6 +505,7 @@ class Application:
|
||||
"journees": {"fuseau": r.f("fuseau").strip() or "Europe/Paris",
|
||||
"coupure_heure": entier("coupure_heure", 4)},
|
||||
"carte_globale": {"mymaps": liens},
|
||||
"administration": {"adresse": r.f("administration").strip()},
|
||||
}, entete="Réglages du site RoadTripsGenerator (rtg).")
|
||||
return Reponse('<span class="ok">✓ Réglages enregistrés.</span>')
|
||||
|
||||
@@ -553,8 +558,10 @@ class Application:
|
||||
return page("_tache.html", tache=taches.COURANTE, message=message)
|
||||
|
||||
def tache_arreter(self, r):
|
||||
# La relève suivante (chaque seconde) affichera l'arrêt : remplacer le
|
||||
# bloc ici en même temps qu'elle ferait échouer l'une des deux.
|
||||
taches.arreter()
|
||||
return page("_tache.html", tache=taches.COURANTE)
|
||||
return Reponse("")
|
||||
|
||||
def tache(self, r):
|
||||
return page("tache.html", **self.contexte())
|
||||
@@ -619,8 +626,11 @@ PUBLIQUES = {"/connexion"}
|
||||
|
||||
|
||||
def creer_gestionnaire(app, port, gardien=None):
|
||||
gardien = gardien or Gardien(port)
|
||||
gardien = gardien or Gardien(port, prefixe=app.prefixe)
|
||||
hotes = {f"127.0.0.1:{port}", f"localhost:{port}"}
|
||||
P = app.prefixe
|
||||
# « Voir le site » : derrière le serveur web, le vrai site ; sinon le site servi par RTG.
|
||||
ENV.globals["lien_site"] = (P.rsplit("/", 1)[0] + "/") if gardien.proxy else P + "/site/"
|
||||
|
||||
class Gestionnaire(BaseHTTPRequestHandler):
|
||||
server_version = f"rtg/{__version__}"
|
||||
@@ -653,12 +663,26 @@ def creer_gestionnaire(app, port, gardien=None):
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def ip(self):
|
||||
"""Adresse du visiteur ; derrière le serveur web, celle qu'il transmet."""
|
||||
if gardien.proxy:
|
||||
transmise = (self.headers.get("X-Forwarded-For") or "").split(",")[-1].strip()
|
||||
if transmise:
|
||||
return transmise
|
||||
return self.client_address[0]
|
||||
|
||||
def traiter(self, methode, tete=False):
|
||||
try:
|
||||
if not gardien.distant and self.headers.get("Host") not in hotes:
|
||||
if not (gardien.distant or gardien.proxy) and self.headers.get("Host") not in hotes:
|
||||
raise Erreur(403, "Hôte refusé")
|
||||
url = urlsplit(self.path)
|
||||
chemin = unquote(url.path)
|
||||
if P:
|
||||
if chemin == P:
|
||||
return self.rediriger(P + "/")
|
||||
if not chemin.startswith(P + "/"):
|
||||
raise Erreur(404, "Page introuvable")
|
||||
chemin = chemin[len(P):]
|
||||
query = parse_qs(url.query, keep_blank_values=True)
|
||||
if methode == "GET" and chemin.startswith("/static/"):
|
||||
return self.fichier(STATIC, chemin[len("/static/"):], tete)
|
||||
@@ -667,8 +691,8 @@ def creer_gestionnaire(app, port, gardien=None):
|
||||
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")
|
||||
return self.rediriger(P + chemin, gardien.entete_cookie(sid, self.https()))
|
||||
return self.rediriger(P + "/connexion")
|
||||
connecte = gardien.session_valide(self.session())
|
||||
form = {}
|
||||
if methode == "POST":
|
||||
@@ -682,12 +706,13 @@ def creer_gestionnaire(app, port, gardien=None):
|
||||
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")
|
||||
return self.envoyer(Reponse("", entetes={"HX-Redirect": P + "/connexion"}), tete)
|
||||
return self.rediriger(P + "/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)
|
||||
"HX-Redirect": P + "/connexion",
|
||||
"Set-Cookie": gardien.entete_cookie("", self.https(), effacer=True)}), tete)
|
||||
if methode == "GET" and chemin.startswith("/site/"):
|
||||
return self.fichier(app.racine, chemin[len("/site/"):], tete)
|
||||
req = Requete(query, form)
|
||||
@@ -713,16 +738,25 @@ def creer_gestionnaire(app, port, gardien=None):
|
||||
rep.entetes = {"HX-Retarget": "#messages", "HX-Reswap": "innerHTML"}
|
||||
self.envoyer(rep, tete)
|
||||
|
||||
def https(self):
|
||||
return gardien.https or (gardien.proxy and self.headers.get("X-Forwarded-Proto") == "https")
|
||||
|
||||
def connexion(self, methode, form, connecte):
|
||||
ip = self.client_address[0]
|
||||
ip = self.ip()
|
||||
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("", entetes={"HX-Redirect": P + "/",
|
||||
"Set-Cookie": gardien.entete_cookie(sid, self.https())})
|
||||
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))
|
||||
distant=gardien.distant or gardien.proxy, attente=gardien.attente(ip),
|
||||
site=self.adresse_site())
|
||||
|
||||
def adresse_site(self):
|
||||
"""Lien « Retour au site » de la page de connexion, derrière le serveur web."""
|
||||
return (P.rsplit("/", 1)[0] or "") + "/" if gardien.proxy else ""
|
||||
|
||||
def envoyer(self, rep, tete=False):
|
||||
self.send_response(rep.code)
|
||||
@@ -802,21 +836,26 @@ def _echapper(s):
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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 ».")
|
||||
def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None,
|
||||
proxy=False, prefixe=""):
|
||||
"""Lance l'interface.
|
||||
distant : écoute sur toutes les interfaces réseau, mot de passe obligatoire ;
|
||||
certificat, cle : HTTPS ;
|
||||
proxy : derrière le serveur web du site (qui transmet https://…/admin/ à
|
||||
127.0.0.1:port), mot de passe obligatoire ; prefixe : « /admin »."""
|
||||
if (distant or proxy) and not acces.defini():
|
||||
raise SystemExit("❌ L'accès depuis le réseau 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)
|
||||
app = Application(racine, prefixe)
|
||||
serveur = None
|
||||
for p in range(port, port + 20):
|
||||
# Derrière le serveur web, le port doit être exactement celui de sa configuration.
|
||||
for p in ([port] if proxy else range(port, port + 20)):
|
||||
try:
|
||||
gardien = Gardien(p, distant=distant, https=bool(contexte_tls))
|
||||
gardien = Gardien(p, distant=distant, https=bool(contexte_tls), proxy=proxy, prefixe=app.prefixe)
|
||||
serveur = ThreadingHTTPServer(("0.0.0.0" if distant else "127.0.0.1", p),
|
||||
creer_gestionnaire(app, p, gardien))
|
||||
port = p
|
||||
@@ -824,14 +863,16 @@ def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=N
|
||||
except OSError:
|
||||
continue
|
||||
if serveur is None:
|
||||
raise SystemExit(f"❌ Aucun port libre entre {port} et {port + 19}.")
|
||||
raise SystemExit(f"❌ Port {port} occupé." if proxy else 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
|
||||
schema = "https" if contexte_tls else "http"
|
||||
url = f"{schema}://127.0.0.1:{port}/?jeton={gardien.jeton}"
|
||||
url = f"{schema}://127.0.0.1:{port}{app.prefixe}/?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 proxy:
|
||||
print(f" Derrière le serveur web : https://<votre site>{app.prefixe}/ (mot de passe)")
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user