Ajoute le support d'un jeton d'accès pour les serveurs ntfy protégés
Certains serveurs ntfy auto-hébergés (ex: ntfy.arkel.fr) renvoient
403 Forbidden sans authentification. Un nouveau champ "Jeton d'accès"
(optionnel, type password) sur /settings permet de renseigner un token
qui sera envoyé comme "Authorization: Bearer ..." à chaque publication,
pour les rappels réels comme pour le bouton de test. La page /aide
documente le cas 403 et la marche à suivre.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@ -563,6 +563,7 @@ def settings_page(request: Request):
|
||||
"current_user": user_id,
|
||||
"ntfy_topic": notif.get("ntfy_topic") or "",
|
||||
"ntfy_server": notif.get("ntfy_server") or "https://ntfy.sh",
|
||||
"ntfy_token": notif.get("ntfy_token") or "",
|
||||
"rappel_debut_h": notif.get("rappel_debut_h", 7),
|
||||
"rappel_fin_h": notif.get("rappel_fin_h", 20),
|
||||
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
|
||||
@ -578,6 +579,7 @@ def settings_page(request: Request):
|
||||
def settings_save(
|
||||
request: Request, ntfy_topic: str = Form(""), ntfy_server: str = Form(""),
|
||||
rappel_debut_h: int = Form(7), rappel_fin_h: int = Form(20),
|
||||
ntfy_token: str = Form(""),
|
||||
):
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
@ -587,10 +589,11 @@ def settings_save(
|
||||
rappel_fin_h = max(rappel_debut_h + 1, min(24, rappel_fin_h))
|
||||
|
||||
logger.info(
|
||||
"réglages notif mis à jour: user_id=%r ntfy_topic=%r ntfy_server=%r plage=%sh-%sh",
|
||||
"réglages notif mis à jour: user_id=%r ntfy_topic=%r ntfy_server=%r plage=%sh-%sh token=%s",
|
||||
user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h,
|
||||
"(défini)" if ntfy_token.strip() else "(vide)",
|
||||
)
|
||||
save_notif_config(user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h)
|
||||
save_notif_config(user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h, ntfy_token)
|
||||
return RedirectResponse("/settings?saved=1", status_code=303)
|
||||
|
||||
|
||||
@ -616,7 +619,7 @@ def settings_test_ntfy(request: Request):
|
||||
ok = send_ntfy(
|
||||
server, topic,
|
||||
"Ceci est une notification de test — ta configuration ntfy fonctionne.",
|
||||
"Test pointeuse", user_id=user_id,
|
||||
"Test pointeuse", user_id=user_id, token=notif.get("ntfy_token", ""),
|
||||
)
|
||||
cls = "settings-saved" if ok else "settings-error"
|
||||
msg = ("Notification envoyée. Vérifie ton téléphone, elle doit arriver en quelques secondes."
|
||||
|
||||
@ -156,11 +156,12 @@ DEFAULT_RAPPEL_FIN_H = 20
|
||||
|
||||
|
||||
def load_notif_config(user_id: str) -> dict:
|
||||
"""Return {"ntfy_topic", "ntfy_server", "rappel_debut_h", "rappel_fin_h", "token"}."""
|
||||
"""Return {ntfy_topic, ntfy_server, ntfy_token, rappel_debut_h, rappel_fin_h, token}."""
|
||||
f = _notif_file(user_id)
|
||||
if f.exists():
|
||||
cfg = json.loads(f.read_text())
|
||||
cfg.setdefault("ntfy_server", DEFAULT_NTFY_SERVER)
|
||||
cfg.setdefault("ntfy_token", "")
|
||||
cfg.setdefault("rappel_debut_h", DEFAULT_RAPPEL_DEBUT_H)
|
||||
cfg.setdefault("rappel_fin_h", DEFAULT_RAPPEL_FIN_H)
|
||||
if cfg.get("token"):
|
||||
@ -168,6 +169,7 @@ def load_notif_config(user_id: str) -> dict:
|
||||
else:
|
||||
cfg = {
|
||||
"ntfy_topic": None, "ntfy_server": DEFAULT_NTFY_SERVER,
|
||||
"ntfy_token": "",
|
||||
"rappel_debut_h": DEFAULT_RAPPEL_DEBUT_H, "rappel_fin_h": DEFAULT_RAPPEL_FIN_H,
|
||||
}
|
||||
cfg["token"] = secrets.token_hex(16)
|
||||
@ -179,10 +181,12 @@ def load_notif_config(user_id: str) -> dict:
|
||||
def save_notif_config(
|
||||
user_id: str, ntfy_topic: str, ntfy_server: str = "",
|
||||
rappel_debut_h: int = DEFAULT_RAPPEL_DEBUT_H, rappel_fin_h: int = DEFAULT_RAPPEL_FIN_H,
|
||||
ntfy_token: str = "",
|
||||
):
|
||||
cfg = load_notif_config(user_id)
|
||||
cfg["ntfy_topic"] = ntfy_topic.strip() or None
|
||||
cfg["ntfy_server"] = ntfy_server.strip().rstrip("/") or DEFAULT_NTFY_SERVER
|
||||
cfg["ntfy_token"] = ntfy_token.strip()
|
||||
cfg["rappel_debut_h"] = rappel_debut_h
|
||||
cfg["rappel_fin_h"] = rappel_fin_h
|
||||
_ensure_user_dirs(user_id)
|
||||
|
||||
@ -11,11 +11,14 @@ logger = logging.getLogger("pointeuse.notifications")
|
||||
CHECK_INTERVAL_SECONDS = 300
|
||||
|
||||
|
||||
def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None) -> bool:
|
||||
def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None, token: str = "") -> bool:
|
||||
headers = {"Title": title.encode("utf-8"), "Priority": "high"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(
|
||||
f"{server}/{topic}",
|
||||
data=message.encode("utf-8"),
|
||||
headers={"Title": title.encode("utf-8"), "Priority": "high"},
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
@ -43,6 +46,7 @@ async def check_user(user_id: str, now: datetime):
|
||||
notif = load_notif_config(user_id)
|
||||
topic = notif.get("ntfy_topic")
|
||||
server = notif.get("ntfy_server", "https://ntfy.sh")
|
||||
token = notif.get("ntfy_token", "")
|
||||
debut_h = notif.get("rappel_debut_h", 7)
|
||||
fin_h = notif.get("rappel_fin_h", 20)
|
||||
if not topic:
|
||||
@ -64,14 +68,14 @@ async def check_user(user_id: str, now: datetime):
|
||||
await asyncio.to_thread(
|
||||
send_ntfy, server, topic,
|
||||
"Tu es dans les locaux mais pas encore pointé ce matin.",
|
||||
"⏰ Pense à pointer", user_id,
|
||||
"⏰ Pense à pointer", user_id, token,
|
||||
)
|
||||
elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"):
|
||||
logger.info("rappel 'pense à dépointer' pour %r", user_id)
|
||||
await asyncio.to_thread(
|
||||
send_ntfy, server, topic,
|
||||
"Tu as quitté les locaux sans dépointer la sortie.",
|
||||
"⏰ Pense à dépointer", user_id,
|
||||
"⏰ Pense à dépointer", user_id, token,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -57,6 +57,13 @@
|
||||
(Réglages → Applications → ntfy → Batterie → "Aucune restriction"). Sinon Android
|
||||
peut couper les notifications en arrière-plan.
|
||||
</li>
|
||||
<li>
|
||||
<b>Serveur protégé par jeton</b> : si ton instance ntfy renvoie <code>403 Forbidden</code>,
|
||||
elle exige un jeton d'accès. Crée-le sur le serveur (panneau d'admin ntfy, onglet
|
||||
"Access tokens"), puis colle-le dans le champ <b>Jeton d'accès</b> sur
|
||||
<a href="/settings">/settings</a>. Il est envoyé comme
|
||||
<code>Authorization: Bearer ...</code> à chaque publication.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="settings-hint">
|
||||
Astuce : si tu veux ton propre serveur ntfy (auto-hébergé, pour éviter le topic public),
|
||||
|
||||
@ -84,6 +84,16 @@
|
||||
placeholder="ex: antoine-pointeuse-x7f2"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="login-label" for="ntfy_token">Jeton d'accès (optionnel, serveur protégé)</label>
|
||||
<input
|
||||
id="ntfy_token"
|
||||
class="login-input"
|
||||
type="password"
|
||||
name="ntfy_token"
|
||||
value="{{ ntfy_token }}"
|
||||
placeholder="ex: tk_xxxxxxxxxxxxxxxx"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="login-label" for="rappel_debut_h">Plage horaire des rappels</label>
|
||||
<div class="settings-range">
|
||||
<input
|
||||
|
||||
@ -185,3 +185,68 @@ def test_test_ntfy_logs_to_journal():
|
||||
logs = models.load_logs("alice")
|
||||
# L'entrée la plus récente doit être l'échec du test (message contient "Test pointeuse")
|
||||
assert any(l["event"] == "échec" and "Test pointeuse" in l["message"] for l in logs)
|
||||
|
||||
|
||||
# ── Jeton d'accès ntfy (serveur protégé) ─────────────────────────────────────
|
||||
|
||||
def test_send_ntfy_passes_authorization_header(monkeypatch):
|
||||
"""Quand un token est fourni, send_ntfy doit envoyer un header Authorization: Bearer."""
|
||||
import notifications
|
||||
captured = {}
|
||||
|
||||
class _DummyReq:
|
||||
def __init__(self, url, data, headers, method):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["method"] = method
|
||||
|
||||
class _DummyResp:
|
||||
def close(self): pass
|
||||
|
||||
def _fake_urlopen(req, timeout):
|
||||
captured["timeout"] = timeout
|
||||
return _DummyResp()
|
||||
|
||||
monkeypatch.setattr(notifications.urllib.request, "Request", _DummyReq)
|
||||
monkeypatch.setattr(notifications.urllib.request, "urlopen", _fake_urlopen)
|
||||
|
||||
ok = notifications.send_ntfy(
|
||||
"https://ntfy.example", "topic-x",
|
||||
"msg", "titre", user_id="alice", token="tk_abc123",
|
||||
)
|
||||
assert ok is True
|
||||
assert captured["headers"]["Authorization"] == "Bearer tk_abc123"
|
||||
|
||||
|
||||
def test_send_ntfy_omits_authorization_when_no_token(monkeypatch):
|
||||
"""Sans token, pas de header Authorization."""
|
||||
import notifications
|
||||
captured = {}
|
||||
|
||||
class _DummyReq:
|
||||
def __init__(self, url, data, headers, method):
|
||||
captured["headers"] = headers
|
||||
|
||||
class _DummyResp:
|
||||
def close(self): pass
|
||||
|
||||
monkeypatch.setattr(notifications.urllib.request, "Request", _DummyReq)
|
||||
monkeypatch.setattr(notifications.urllib.request, "urlopen", lambda req, timeout: _DummyResp())
|
||||
|
||||
notifications.send_ntfy("https://x", "t", "m", "ti", user_id=None, token="")
|
||||
assert "Authorization" not in captured["headers"]
|
||||
|
||||
|
||||
def test_settings_save_persists_ntfy_token():
|
||||
"""Le token saisi dans /settings doit être persisté."""
|
||||
models.save_notif_config("alice", ntfy_topic="t", ntfy_server="https://ntfy.sh")
|
||||
client.post("/settings", cookies=COOKIES, data={
|
||||
"ntfy_topic": "topic-persisted",
|
||||
"ntfy_server": "https://ntfy.arkel.fr",
|
||||
"ntfy_token": "tk_persisted_123",
|
||||
"rappel_debut_h": "8",
|
||||
"rappel_fin_h": "19",
|
||||
})
|
||||
cfg = models.load_notif_config("alice")
|
||||
assert cfg["ntfy_token"] == "tk_persisted_123"
|
||||
assert cfg["ntfy_topic"] == "topic-persisted"
|
||||
|
||||
Reference in New Issue
Block a user