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:
FabriceandClaude Opus 5.5 committed 2026-10-09 14:49:47 +00:00
1 parent 71779f6f53
commit 4366db94ed
22 files changed
+229 -86

No files matched your search

+1
View File
@@ -150,6 +150,7 @@ def generer(racine=".", cfg=None, mettre_a_jour_mymaps=False):
ecrire("index.html", env.get_template("accueil.html").render(
titre_site=TITRE_SITE, rubriques=rubriques, nb_recents=NB_RECENTS,
administration=cfg["administration"]["adresse"],
carte_globale=CARTE_GLOBALE if any(r['titre'] == TITRE_SECTION_RACINE for r in rubriques) else None,
sous_titre=f"{total} voyage{'s' if total > 1 else ''}" if total else ""))
for r in rubriques:
+5 -2
View File
@@ -54,6 +54,9 @@ def main(argv=None):
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)")
i.add_argument("--proxy", action="store_true",
help="derrière le serveur web du site (mot de passe obligatoire)")
i.add_argument("--prefixe", default="", help="chemin de l'interface sur le site, par exemple /admin")
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")
@@ -105,8 +108,8 @@ def main(argv=None):
from rtg.interface.serveur import lancer
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)
lancer(racine, args.port, ouvrir=not (args.sans_navigateur or args.proxy), distant=args.distant,
certificat=args.certificat, cle=args.cle, proxy=args.proxy, prefixe=args.prefixe)
except KeyboardInterrupt:
print("\nInterrompu.")
sys.exit(130)
+3
View File
@@ -32,6 +32,9 @@ DEFAUTS = {
"photos": {"grand_cote": 1600, "qualite": 80, "positions_deduites": True},
"journees": {"fuseau": "Europe/Paris", "coupure_heure": 4},
"carte_globale": {"mymaps": []},
# Adresse de l'interface (rtg interface --proxy) : bouton « Connexion »
# sur l'accueil du site. Vide = pas de bouton.
"administration": {"adresse": ""},
# Propres à un voyage
"rubrique": "",
"moto": "",
+5 -5
View File
@@ -16,7 +16,7 @@
<tr>
<td><code>{{ nom }}</code> <span class="discret">{{ d.km|round|int }} km</span></td>
<td>
<form class="en-ligne" hx-post="/voyage/date" hx-target="#analyse">
<form class="en-ligne" hx-post="{{ base }}/voyage/date" hx-target="#analyse">
<input type="hidden" name="v" value="{{ v.chemin }}"><input type="hidden" name="fichier" value="{{ nom }}">
<input type="date" name="jour" required><button>OK</button>
</form>
@@ -24,7 +24,7 @@
<td>
{% set s = suggestions.get(nom) %}
{% if s and s[0] %}
<button class="petit" hx-post="/voyage/date" hx-target="#analyse"
<button class="petit" hx-post="{{ base }}/voyage/date" hx-target="#analyse"
hx-vals='{{ {"v": v.chemin, "fichier": nom, "jour": s[0].isoformat()}|tojson }}'
title="{{ s[1] }} photo(s) ou vidéo(s) de ce jour prises le long de la trace">Le {{ s[0]|date_longue }} ?</button>
{% else %}<span class="discret">pas de suggestion</span>{% endif %}
@@ -34,7 +34,7 @@
</tbody>
</table>
{% if non_datees|length > 1 %}
<form class="en-ligne" hx-post="/voyage/dates-suite" hx-target="#analyse">
<form class="en-ligne" hx-post="{{ base }}/voyage/dates-suite" hx-target="#analyse">
<input type="hidden" name="v" value="{{ v.chemin }}">
Ou bien, dans l'ordre des noms, un jour par trace à partir du <input type="date" name="debut" required>
<button>Répartir</button>
@@ -66,13 +66,13 @@
<td><code title="{{ d.fichier }}">{{ d.nom }}</code>{% if d.erreur %} <span class="pastille attention" title="{{ d.erreur }}">illisible</span>{% endif %}</td>
<td>{% for j in d.jours %}{{ j|date_courte }}{{ ", " if not loop.last }}{% else %}<span class="discret">—</span>{% endfor %}</td>
<td>
<form class="en-ligne" hx-post="/voyage/date" hx-target="#analyse">
<form class="en-ligne" hx-post="{{ base }}/voyage/date" hx-target="#analyse">
<input type="hidden" name="v" value="{{ v.chemin }}"><input type="hidden" name="fichier" value="{{ d.nom }}">
<input type="date" name="jour" value="{{ d.impose.isoformat() if d.impose else '' }}">
<button class="petit">{{ "Changer" if d.impose else "Imposer" }}</button>
</form>
{% if d.impose %}
<button class="petit" hx-post="/voyage/date" hx-target="#analyse"
<button class="petit" hx-post="{{ base }}/voyage/date" hx-target="#analyse"
hx-vals='{{ {"v": v.chemin, "fichier": d.nom, "jour": ""}|tojson }}'>Annuler</button>
{% endif %}
</td>
+2 -2
View File
@@ -1,5 +1,5 @@
{% if tache and tache.en_cours %}
<a class="bandeau en-cours" href="/tache">⏳ {{ tache.libelle }} · {{ tache.etape }}{% if tache.etape_voyage %} ({{ tache.etape_voyage }}){% endif %}{% if tache.video and tache.video.rang %} · vidéo {{ tache.video.rang[0] }}/{{ tache.video.rang[1] }} ({{ tache.video.pct|int }} %){% endif %}</a>
<a class="bandeau en-cours" href="{{ base }}/tache">⏳ {{ tache.libelle }} · {{ tache.etape }}{% if tache.etape_voyage %} ({{ tache.etape_voyage }}){% endif %}{% if tache.video and tache.video.rang %} · vidéo {{ tache.video.rang[0] }}/{{ tache.video.rang[1] }} ({{ tache.video.pct|int }} %){% endif %}</a>
{% elif tache %}
<a class="bandeau {{ 'ok' if tache.ok else 'echec' }}" href="/tache">{{ "✓" if tache.ok else "⚠️" }} {{ tache.libelle }} : {{ tache.etape|lower }}</a>
<a class="bandeau {{ 'ok' if tache.ok else 'echec' }}" href="{{ base }}/tache">{{ "✓" if tache.ok else "⚠️" }} {{ tache.libelle }} : {{ tache.etape|lower }}</a>
{% endif %}
+4 -4
View File
@@ -3,14 +3,14 @@
<h3>Choisir un dossier de {{ libelle }}</h3>
<div class="raccourcis">
{% for nom, c in raccourcis %}
<button class="petit" hx-get="/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ c|url }}" hx-target="#fenetre">{{ nom }}</button>
<button class="petit" hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ c|url }}" hx-target="#fenetre">{{ nom }}</button>
{% endfor %}
</div>
<p class="chemin"><code>{{ chemin }}</code></p>
<ul class="liste-dossiers">
{% if parent %}<li><a href="#" hx-get="/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ parent|url }}" hx-target="#fenetre">⬆︎ Dossier parent</a></li>{% endif %}
{% if parent %}<li><a href="#" hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ parent|url }}" hx-target="#fenetre">⬆︎ Dossier parent</a></li>{% endif %}
{% for d in sous %}
<li><a href="#" hx-get="/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ (chemin.rstrip(sep) ~ sep ~ d)|url }}" hx-target="#fenetre">📁 {{ d }}</a></li>
<li><a href="#" hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v|url }}&chemin={{ (chemin.rstrip(sep) ~ sep ~ d)|url }}" hx-target="#fenetre">📁 {{ d }}</a></li>
{% else %}
<li class="vide">Pas de sous-dossier</li>
{% endfor %}
@@ -19,7 +19,7 @@
<span class="{{ 'ok' if nb else 'discret' }}">{{ nb }} fichier{{ 's' if nb > 1 }} de {{ libelle }} ici</span>
<span>
<button data-fermer>Annuler</button>
<button class="principal" data-fermer hx-post="/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
<button class="principal" data-fermer hx-post="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
hx-vals='{{ {"v": v, "cle": cle, "action": "ajouter", "chemin": chemin}|tojson }}'>Choisir ce dossier</button>
</span>
</div>
+1 -1
View File
@@ -8,5 +8,5 @@
<div><span class="etiquette">Encodage</span>
{{ noms_qualites.get(cfg.video.qualite, cfg.video.qualite) }} · {{ "matériel" if i.encodage.encodeur == "vt" else "processeur" }}
<span class="discret">(HEVC CRF {{ i.encodage.crf_hevc }} / H.264 CRF {{ i.encodage.crf_h264 }})</span>
<a href="/reglages#video">changer</a></div>
<a href="{{ base }}/reglages#video">changer</a></div>
</div>
+3 -3
View File
@@ -9,7 +9,7 @@
{% if l.existe %}<span class="pastille {{ 'ok' if l.nb else 'neutre' }}">{{ l.nb }} fichier{{ 's' if l.nb > 1 }}</span>
{% elif l.lien_casse %}<span class="pastille attention">disque débranché ?</span>
{% else %}<span class="pastille attention">introuvable</span>{% endif %}
<button class="petit" hx-post="/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
<button class="petit" hx-post="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML"
hx-vals='{{ {"v": v.chemin, "cle": cle, "action": "retirer", "chemin": l.source}|tojson }}'>Retirer</button>
</li>
{% else %}
@@ -17,8 +17,8 @@
{% endfor %}
</ul>
<div class="ajout">
<button hx-get="/dossiers?cle={{ cle }}&v={{ v.chemin|url }}" hx-target="#fenetre">Choisir un dossier…</button>
<form hx-post="/voyage/sources" hx-target="#sources" hx-swap="outerHTML" class="en-ligne">
<button hx-get="{{ base }}/dossiers?cle={{ cle }}&v={{ v.chemin|url }}" hx-target="#fenetre">Choisir un dossier…</button>
<form hx-post="{{ base }}/voyage/sources" hx-target="#sources" hx-swap="outerHTML" class="en-ligne">
<input type="hidden" name="v" value="{{ v.chemin }}">
<input type="hidden" name="cle" value="{{ cle }}">
<input type="hidden" name="action" value="ajouter">
+2 -2
View File
@@ -1,13 +1,13 @@
{% if message %}<div class="erreur">{{ message }}</div>{% endif %}
{% if tache %}
<div class="tache {{ 'en-cours' if tache.en_cours else ('ok' if tache.ok else 'echec') }}"
{% if tache.en_cours %}hx-get="/tache/fragment{{ '?complet=1' if complet }}" hx-trigger="every 1s" hx-swap="outerHTML"{% endif %}>
{% if tache.en_cours %}hx-get="{{ base }}/tache/fragment{{ '?complet=1' if complet }}" hx-trigger="every 1s" hx-swap="outerHTML"{% endif %}>
<div class="tache-tete">
<strong>{{ tache.libelle }}</strong>
<span class="etape">{{ "⏳ " if tache.en_cours }}{{ tache.etape }}{% if tache.etape_voyage %} — {{ tache.etape_voyage }}{% endif %}</span>
<span class="discret">{{ tache.duree }}</span>
{% if tache.en_cours %}
<button class="danger petit" hx-post="/tache/arreter" hx-target="closest .tache" hx-swap="outerHTML">Arrêter</button>
<button class="danger petit" hx-post="{{ base }}/tache/arreter" hx-swap="none">Arrêter</button>
{% endif %}
</div>
{% if tache.video and tache.video.rang %}
+11 -11
View File
@@ -4,21 +4,21 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titre %}{% endblock %} · RTG</title>
<link rel="stylesheet" href="/static/rtg.css">
<link rel="stylesheet" href="{{ base }}/static/rtg.css">
{% block tete %}{% endblock %}
<script src="/static/htmx.min.js"></script>
<script src="{{ base }}/static/htmx.min.js"></script>
</head>
<body>
<body data-base="{{ base }}">
<header class="barre">
<a class="logo" href="/">RTG</a>
<a class="logo" href="{{ base }}/">RTG</a>
<nav>
<a href="/">Mes voyages</a>
<a href="/reglages">Réglages</a>
<a href="/tache">Traitement</a>
<a href="/site/" target="_blank">Voir le site ↗</a>
<a href="{{ base }}/">Mes voyages</a>
<a href="{{ base }}/reglages">Réglages</a>
<a href="{{ base }}/tache">Traitement</a>
<a href="{{ lien_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>
<div id="bandeau" hx-get="{{ base }}/tache/bandeau" hx-trigger="load, every 2s"></div>
<button class="petit discret-bouton" hx-post="{{ base }}/deconnexion" title="Fermer la session de ce navigateur">Se déconnecter</button>
</header>
<main>
{% block contenu %}{% endblock %}
@@ -26,7 +26,7 @@
<div id="messages" aria-live="polite"></div>
<div id="fenetre"></div>
<footer>RoadTripsGenerator {{ version }} · <code>{{ racine }}</code></footer>
<script src="/static/rtg.js"></script>
<script src="{{ base }}/static/rtg.js"></script>
{% block fin %}{% endblock %}
</body>
</html>
+5 -4
View File
@@ -4,15 +4,15 @@
<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>
<link rel="stylesheet" href="{{ base }}/static/rtg.css">
<script src="{{ base }}/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>
<p><a class="bouton principal" href="{{ base }}/">Ouvrir l'interface</a></p>
{% else %}
<h1>Accès à l'interface</h1>
{% if not distant %}
@@ -20,7 +20,7 @@
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">
<form hx-post="{{ base }}/connexion" hx-target="#retour-connexion">
<label>Mot de passe
<input type="password" name="mot_de_passe" autocomplete="current-password" required autofocus>
</label>
@@ -32,6 +32,7 @@
<code>rtg mot-de-passe</code>.</p>
{% endif %}
{% endif %}
{% if site %}<p class="aide"><a href="{{ site }}">← Retour au site</a></p>{% endif %}
</main>
</body>
</html>
+2 -2
View File
@@ -2,7 +2,7 @@
{% block titre %}Nouveau voyage{% endblock %}
{% block contenu %}
<h1>Nouveau voyage</h1>
<form id="formulaire" class="formulaire" hx-post="/nouveau" hx-target="#formulaire" hx-select="#formulaire" hx-swap="outerHTML">
<form id="formulaire" class="formulaire" hx-post="{{ base }}/nouveau" hx-target="#formulaire" hx-select="#formulaire" hx-swap="outerHTML">
{% if erreur %}<div class="erreur">{{ erreur }}</div>{% endif %}
<label>Titre
<input name="titre" value="{{ form.titre[0] if form and form.titre else '' }}" placeholder="Été 2027 en Écosse" autofocus>
@@ -22,7 +22,7 @@
{% endfor %}
<label class="choix">Nouvelle rubrique : <input name="groupe_nouveau" placeholder="Balades"></label>
</fieldset>
<p><button class="principal">Créer le voyage</button> <a href="/">Annuler</a></p>
<p><button class="principal">Créer le voyage</button> <a href="{{ base }}/">Annuler</a></p>
<p class="aide">Le dossier est créé avec un <code>voyage.toml</code>. Vous indiquerez ensuite où sont les traces, les vidéos et les photos : elles restent où elles sont.</p>
</form>
{% endblock %}
+8 -2
View File
@@ -3,7 +3,7 @@
{% block contenu %}
<h1>Réglages du site</h1>
<p class="aide">Enregistrés dans <code>site.toml</code>. Ils valent pour tous les voyages.</p>
<form class="formulaire" hx-post="/reglages" hx-target="#retour-reglages">
<form class="formulaire" hx-post="{{ base }}/reglages" hx-target="#retour-reglages">
<fieldset>
<legend>Site</legend>
<label>Titre <input name="titre" value="{{ cfg.titre }}"></label>
@@ -60,9 +60,15 @@
<textarea name="mymaps" rows="3">{{ cfg.carte_globale.mymaps|join("\n") }}</textarea>
<span class="aide">Leurs tracés sont récupérés une fois et ajoutés à la carte de l'accueil, sur fond OpenStreetMap.</span></label>
</fieldset>
<fieldset>
<legend>Administration depuis le site</legend>
<label>Adresse de l'interface <input name="administration" value="{{ cfg.administration.adresse }}" placeholder="/admin/">
<span class="aide">Si elle est remplie, l'accueil du site affiche un bouton « Connexion » vers cette adresse.
L'interface doit alors tourner sur le serveur du site (<code>rtg interface --proxy --prefixe /admin</code>), derrière le serveur web en HTTPS : voir le README.</span></label>
</fieldset>
<p><button class="principal">Enregistrer</button> <span id="retour-reglages"></span></p>
</form>
<h2>Système</h2>
<div hx-get="/systeme" hx-trigger="load"><p class="chargement">Vérification des outils…</p></div>
<div hx-get="{{ base }}/systeme" hx-trigger="load"><p class="chargement">Vérification des outils…</p></div>
{% endblock %}
+1 -1
View File
@@ -3,7 +3,7 @@
{% block contenu %}
<h1>Traitement</h1>
{% if tache %}
{% if tache.voyage %}<p><a href="/voyage?v={{ tache.voyage|url }}">← {{ tache.voyage }}</a></p>{% endif %}
{% if tache.voyage %}<p><a href="{{ base }}/voyage?v={{ tache.voyage|url }}">← {{ tache.voyage }}</a></p>{% endif %}
{% with complet = True %}{% include "_tache.html" %}{% endwith %}
{% else %}
<p class="vide">Aucun traitement lancé depuis l'ouverture de l'interface.</p>
+9 -9
View File
@@ -1,16 +1,16 @@
{% 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 tete %}<link rel="stylesheet" href="{{ base }}/static/leaflet.css"><script src="{{ base }}/static/leaflet.js"></script>{% endblock %}
{% block fin %}<script src="{{ base }}/static/editeur.js"></script>{% endblock %}
{% block contenu %}
<div class="entete-page">
<div>
<p class="fil"><a href="/">Mes voyages</a> › {{ v.rubrique }}</p>
<p class="fil"><a href="{{ base }}/">Mes voyages</a> › {{ v.rubrique }}</p>
<h1>{{ v.titre }}</h1>
<p class="discret">{{ [periode(v.debut, v.fin), v.moto]|select|join(" · ") }} <code>{{ v.chemin }}</code></p>
</div>
<div class="actions">
{% if v.genere %}<a class="bouton" href="/site/{{ v.chemin }}/index.html" target="_blank">Voir la page ↗</a>{% endif %}
{% if v.genere %}<a class="bouton" href="{{ base }}/site/{{ v.chemin }}/index.html" target="_blank">Voir la page ↗</a>{% endif %}
</div>
</div>
@@ -23,7 +23,7 @@
</nav>
<section class="onglet" id="onglet-journees">
<div id="analyse" hx-get="/voyage/analyse?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body">
<div id="analyse" hx-get="{{ base }}/voyage/analyse?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body">
<p class="chargement">Lecture des traces…</p>
</div>
</section>
@@ -71,14 +71,14 @@
</section>
<section class="onglet" id="onglet-infos">
<form class="formulaire" hx-post="/voyage/infos" hx-target="find .retour">
<form class="formulaire" hx-post="{{ base }}/voyage/infos" hx-target="find .retour">
<input type="hidden" name="v" value="{{ v.chemin }}">
<label>Titre <input name="titre" value="{{ cfg.titre }}"></label>
<label>Moto <input name="moto" value="{{ cfg.moto }}" placeholder="facultatif : affichée dans la légende de la carte globale"></label>
<p class="aide">Rubrique : <strong>{{ v.rubrique }}</strong> (elle vient du dossier où est rangé le voyage).</p>
<p><button class="principal">Enregistrer</button> <span class="retour"></span></p>
</form>
<form class="formulaire large" hx-post="/voyage/recit" hx-target="find .retour">
<form class="formulaire large" hx-post="{{ base }}/voyage/recit" hx-target="find .retour">
<input type="hidden" name="v" value="{{ v.chemin }}">
<label>Récit (<code>voyage.md</code>, en Markdown)
<textarea name="recit" rows="18" spellcheck="true">{{ recit }}</textarea>
@@ -90,8 +90,8 @@
</section>
<section class="onglet" id="onglet-generer">
<div hx-get="/voyage/infos-utiles?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body"><p class="chargement">Calcul des tailles…</p></div>
<form class="formulaire" hx-post="/tache/lancer" hx-target="#tache-zone">
<div hx-get="{{ base }}/voyage/infos-utiles?v={{ v.chemin|url }}" hx-trigger="load, sources-modifiees from:body, tache-finie from:body"><p class="chargement">Calcul des tailles…</p></div>
<form class="formulaire" hx-post="{{ base }}/tache/lancer" hx-target="#tache-zone">
<input type="hidden" name="v" value="{{ v.chemin }}">
<label class="choix"><input type="checkbox" name="sans_compression"> Sans encoder les vidéos <span class="aide">(photos, positions et pages seulement)</span></label>
<label class="choix"><input type="checkbox" name="force"> Tout refaire, même ce qui est déjà à jour</label>
+5 -5
View File
@@ -4,7 +4,7 @@
{% if not site_existe %}
<div class="encart">
<p><strong>Ce dossier n'est pas encore un site RTG.</strong> Il n'y a pas de <code>site.toml</code> dans <code>{{ racine }}</code>.</p>
<p><button class="principal" hx-post="/site/creer">Créer le site dans ce dossier</button></p>
<p><button class="principal" hx-post="{{ base }}/site/creer">Créer le site dans ce dossier</button></p>
<p class="aide">Pour un autre dossier, relancez avec <code>rtg interface --racine CHEMIN</code>.</p>
</div>
{% endif %}
@@ -12,8 +12,8 @@
<div class="entete-page">
<h1>Mes voyages <span class="compte">{{ nb }}</span></h1>
<div class="actions">
<a class="bouton principal" href="/nouveau">+ Nouveau voyage</a>
<form hx-post="/tache/lancer" hx-target="#tache-zone">
<a class="bouton principal" href="{{ base }}/nouveau">+ Nouveau voyage</a>
<form hx-post="{{ base }}/tache/lancer" hx-target="#tache-zone">
<input type="hidden" name="quoi" value="accueil">
<button title="index.html, pages des rubriques et carte globale">Refaire l'accueil</button>
</form>
@@ -29,7 +29,7 @@
<tbody>
{% for v in liste %}
<tr>
<td><a href="/voyage?v={{ v.chemin|url }}"><strong>{{ v.titre }}</strong></a>
<td><a href="{{ base }}/voyage?v={{ v.chemin|url }}"><strong>{{ v.titre }}</strong></a>
{% if v.moto %}<div class="discret">{{ v.moto }}</div>{% endif %}</td>
<td>{{ periode(v.debut, v.fin) }}</td>
<td class="num">{{ v.jours or "—" }}</td>
@@ -51,7 +51,7 @@
{% else %}<span class="pastille attention">à refaire</span>{% endif %}
{% if v.a_verifier %}<span class="pastille attention" title="{{ v.a_verifier|join('\n') }}">⚠️ {{ v.a_verifier|length }}</span>{% endif %}
</td>
<td class="droite">{% if v.genere %}<a href="/site/{{ v.chemin }}/index.html" target="_blank">Voir ↗</a>{% endif %}</td>
<td class="droite">{% if v.genere %}<a href="{{ base }}/site/{{ v.chemin }}/index.html" target="_blank">Voir ↗</a>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
+6 -5
View File
@@ -24,10 +24,11 @@ ESSAIS_LIBRES = 5
class Gardien:
def __init__(self, port, distant=False, https=False):
def __init__(self, port, distant=False, https=False, proxy=False, prefixe=""):
self.jeton = secrets.token_urlsafe(24)
self.cookie = f"rtg_{port}"
self.distant, self.https = distant, https
self.distant, self.https, self.proxy = distant, https, proxy
self.chemin_cookie = prefixe + "/" if prefixe else "/"
self._sessions = {}
self._echecs = {} # ip → (nombre d'échecs, heure du dernier)
self._verrou = threading.Lock()
@@ -55,11 +56,11 @@ class Gardien:
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"
def entete_cookie(self, sid, https=None, effacer=False):
valeur = f"{self.cookie}={'' if effacer else sid}; Path={self.chemin_cookie}; HttpOnly; SameSite=Strict"
if effacer:
valeur += "; Max-Age=0"
if self.https:
if self.https if https is None else https:
valeur += "; Secure"
return valeur
+67 -26
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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:
+3 -2
View File
@@ -7,6 +7,7 @@
const racine = document.getElementById("editeur");
if (!racine || !window.L) return;
const voyage = racine.dataset.voyage;
const base = document.body.dataset.base || ""; // « /admin » derrière le serveur web
const $ = id => document.getElementById(id);
let carte = null, donnees = null, coucheTraces, coucheMedias, coucheZones;
let brouillon = null, modeZone = false, aPlacer = null, cadree = false;
@@ -29,7 +30,7 @@
}
function poster(url, params) {
return fetch(url, {
return fetch(base + url, {
method: "POST",
headers: { "HX-Request": "true", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(Object.assign({ v: voyage }, params))
@@ -41,7 +42,7 @@
}
function charger() {
return fetch("/voyage/carte.json?v=" + encodeURIComponent(voyage), { headers: { "HX-Request": "true" } })
return fetch(base + "/voyage/carte.json?v=" + encodeURIComponent(voyage), { headers: { "HX-Request": "true" } })
.then(lireReponse)
.then(d => { donnees = d; remplirJours(); dessinerTout(); });
}
+6
View File
@@ -54,6 +54,10 @@
display: block; width: 100%; height: min(600px, 75vh); border: none;
border-radius: 15px; box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
/* Bouton discret vers l'interface d'administration (site.toml, [administration]). */
a.connexion { position: absolute; top: 16px; right: 20px; font-size: 0.85rem; color: #777;
text-decoration: none; padding: 5px 12px; border: 1px solid #ccc; border-radius: 99px; }
a.connexion:hover { color: #3498db; border-color: #3498db; }
.vide { text-align: center; color: #777; margin-top: 60px; line-height: 1.7; }
code { background: #e8e8ef; padding: 2px 6px; border-radius: 4px; }
@media (prefers-color-scheme: dark) {
@@ -62,10 +66,12 @@
.sous-titre, .vide, .periode { color: #9a9aa2; }
.rubrique-titre { border-color: #333; }
code { background: #2e2e36; }
a.connexion { color: #9a9aa2; border-color: #444; }
}
</style>
</head>
<body>
{% if administration %}<a class="connexion" href="{{ administration }}" rel="nofollow">Connexion</a>{% endif %}
<header>
<h1>{% block entete %}🌍 {{ titre_site }}{% endblock %}</h1>
<div class="sous-titre">{% block sous_titre %}{% endblock %}</div>