Remplace les inputs type=time par des champs texte HH:MM pour supprimer
l'icône d'horloge et le sélecteur natif qui rognaient le texte.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Le tableau débordait horizontalement à cause de la colonne Cible. On force la
largeur à 100%, on réduit les paddings et on laisse le contenu de la cible se
replier sur deux lignes.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Passe la grille à 4 colonnes et réduit la taille des chiffres pour que le
quota quotidien apparaisse à côté des autres totaux sans repli.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Rend visible la valeur quotidienne dérivée de la config globale à côté du
total travaillé et du dû hebdomadaire.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Le quota quotidien est désormais dérivé d'heures_semaine / jours_par_semaine
au lieu d'être saisi directement. Rétrocompatible avec les anciennes configs
qui utilisent encore heures_jour.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Les pages /aide, /settings et /logs référeçaient encore Tasker (payant) pour
la détection de présence. Mise à jour de la procédure (Wi-Fi ou géofence +
action Trigger URL POST), des libellés et messages, et du test qui vérifiait
la présence du mot « Tasker ».
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Les routes d'authentification n'avaient aucune limite de débit :
- POST /login permettait le mail-bombing SMTP vers n'importe quelle adresse
du domaine autorisé (CSRF + flood).
- POST /login/password permettait le brute-force d'un mot de passe sans
verrou (bcrypt est lent mais pas assez pour un attaquant patient).
Ajout d'un compteur stateful en mémoire (_LOGIN_ATTEMPTS), fenêtre
glissante de 60 s, configurable via config.login_rate_limit_per_min
(défaut 10, <= 0 désactive). Clés :
- "login:<ip>" pour POST /login (limite globale par IP)
- "pw:<ip>:<email>" pour POST /login/password (limite par compte)
Au-delà du quota, la route renvoie la page login avec le message
"Trop de tentatives. Réessaie dans une minute." plutôt que d'exécuter
le workflow. La limite par (ip, email) rend le brute-force d'un compte
impossible sans rotation d'IP.
Tests : déclenchement aux limites, indépendance par email, désactivation
via config, et helper main._LOGIN_ATTEMPTS.clear() pour isoler les tests.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Le cookie de session était émis sans Secure : en cas de downgrade HTTP ou
de mixed content, il aurait transité en clair. Aucun en-tête de sécurité
n'était positionné (pas de CSP, pas de X-Frame-Options, pas de HSTS), ce
qui laissait le champ libre au clickjacking, aux sniffing MIME et aux
fuites via Referer.
Changements :
1. _set_auth_cookie accepte désormais la request : le drapeau Secure est
posé automatiquement si scheme == 'https', ou si la nouvelle clé
config.force_secure_cookies est vraie. En HTTP local (dev/test), le
cookie reste sans Secure pour ne pas casser les navigateurs.
2. Le middleware CSRF injecte maintenant les en-têtes de sécurité sur
toutes les réponses :
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- Referrer-Policy: same-origin
- Content-Security-Policy (default-src 'self', frame-ancestors 'none',
base-uri 'self', avec autorisation pour Google Fonts et unsafe-inline
pour les JS/CSS inline des templates semaine/stats ; à durcir plus tard)
- Strict-Transport-Security (HTTPS uniquement)
Les nouveaux tests vérifient la présence des headers, le HSTS en HTTPS
uniquement, le Secure en HTTPS, l'absence de Secure en HTTP, et l'override
par force_secure_cookies.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
GET /presence/{token}/arrivee et /depart modifiaient l'état (save_presence,
append_log) : c'est anti-pattern REST, CSRF-trivial via <img src="..."> si
le token fuite, et le token se retrouvait dans les logs reverse-proxy,
l'historique navigateur et les referrers.
Les deux routes passent en POST. C'est un breaking change pour les clients
Tasker actuels, mais Tasker supporte HTTP POST nativement et la doc
docs/notifications.md est mise à jour (Tasker : HTTP Request méthode POST ;
script NetworkManager : curl -X POST).
Les tests existants qui utilisaient client.get(...) sur ces routes passent
en client.post(...). Un nouveau test vérifie que le GET renvoie désormais
405 Method Not Allowed et que le POST continue de fonctionner.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Les routes POST/PUT/DELETE/PATCH n'avaient aucune protection CSRF explicite,
et reposaient uniquement sur le cookie SameSite=Lax (insuffisant pour les
navigateurs anciens). L'endpoint /login était particulièrement exposé car
déclenchable depuis n'importe quel site → mail-bombing SMTP.
Ajout d'un middleware qui, pour toute requête mutative, valide le header
Origin (ou Referer en fallback) quand il est présent : son hostname doit
correspondre au host attendu (config.base_url si définie, sinon le Host de
la requête). Une correspondance absente renvoie 403.
L'absence d'Origin est tolérée pour ne pas casser les clients non-navigateur
(Tasker, curl) qui ne l'envoient pas ; SameSite=Lax reste la protection
primaire pour ces cas.
Les nouveaux tests couvrent le rejet cross-site (Origin et Referer), le
cas légitime (Origin == Host), l'absence d'Origin (acceptée), et
l'immunité des GET.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
POST /login construisait le lien set-password envoyé par email avec
request.base_url, qui reflète directement le header Host contrôlable par le
client. Un attaquant pouvait envoyer POST /login avec Host: phishing.attacker
et déclencher l'envoi d'un email légitime contenant un lien vers son site,
avec un token réellement valide : phishing direct du mot de passe.
Ajout de main._safe_base_url(request) qui :
- utilise config.base_url si définie (nouvelle clé, schéma http/https requis) ;
- sinon accepte le Host seulement s'il correspond à allowed_email_domain
(suffixe strict avec un point) ou à localhost/127.0.0.1/::1 (dev local) ;
- sinon retourne None.
POST /login refuse désormais l'envoi du mail si le Host n'est pas validé
(message "Domaine non autorisé"). Les routes /settings (affichage des URLs
Tasker à l'utilisateur lui-même) utilisent le helper avec fallback sur
request.base_url, car le HTML n'est pas envoyé par email.
data/config.example.json documente la nouvelle clé base_url. Les tests
mockent send_mail et vérifient : rejet d'un Host arbitraire, acceptation
d'un host suffixe du domaine autorisé, acceptation de localhost, primauté
de config.base_url sur le Host, et cas unitaires du helper.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
POST /pointage acceptait n'importe quelle valeur pour les 4 champs
matin_entree/matin_sortie/aprem_entree/aprem_sortie : la fonction clean ne
faisait qu'un strip(). Comme ces valeurs sont réinjectées telles quelles
dans des f-strings HTML hors templates Jinja (qui seuls bénéficient de
l'autoescape), un payload type `"><svg/onload=...>` déclenchait une
exécution JS immédiate dans la réponse HTMX, sans CSP pour limiter l'impact.
Double défense appliquée :
1. Validation stricte en entrée : clean() refuse désormais tout ce qui ne
matche pas _HHMM_RE (^([01]\d|2[0-3]):[0-5]\d$). Une valeur non conforme
est traitée comme vide plutôt que stockée.
2. Échappement HTML systématique en sortie : un alias `_e = html.escape`
est appliqué à toutes les valeurs dynamiques (date, heures, cibles)
dans _oob_calc, _oob_week_extras, _oob_time_cells et
_htmx_conge_and_soldes. Les ids, data-date, value, hx-post et contenus
textuels sont protégés.
Les nouveaux tests test_security couvrent plusieurs payloads XSS, vérifient
que la valeur n'est ni persistée ni reflétée, et qu'un pointage HHMM
valide continue à fonctionner.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Le champ ntfy_server du formulaire /settings était utilisé tel quel dans
urllib.request.urlopen sans aucune validation. Un attaquant pouvait pointer
le serveur vers 169.254.169.254 (metadata AWS/GCP), le réseau interne, ou un
service protégé, et comme la reminder_loop relance l'URL toutes les 5 min le
SSRF devenait persistant. Le header Authorization étant aussi contrôlable,
des requêtes authentifiées vers des services internes étaient possibles.
Ajout de notifications.is_safe_ntfy_server() qui valide : schéma http(s),
absence de credentials embarqués, refus de localhost et de toutes les IP
littérales privées (loopback, link-local, 10/8, 172.16/12, 192.168/16,
fc00::/7, ::1, multicast, réservé). send_ntfy et POST /settings appellent
ce validateur avant toute ouverture de connexion ou persistance ; un serveur
refusé déclenche une redirection /settings?ssrf_error=1 côté UI.
Les tests simulant un échec réseau (127.0.0.1:1) passent sur un host public
injoignable (.invalid), et un nouveau bloc test_security couvre les cas
loopback/private/bad-scheme/credentials et la non-ouverture de socket vers
une IP privée.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Le cookie user_id était lu tel quel depuis la requête, ce qui permettait à
n'importe qui de forger `user_id=victime` dans DevTools/curl et d'accéder à
toutes les routes "authentifiées" (lecture/écriture des pointages, export,
régénération des tokens Tasker, etc.). Le mot de passe bcrypt ne servait
donc à rien.
Le cookie est maintenant au format `<user_id>.<hmac_sha256_hex>` signé avec
une clé persistée dans /data/secret.key (auto-générée, stable entre
redémarrages). La vérification utilise hmac.compare_digest pour éviter les
fuites par timing. Toutes les routes appellent _get_user qui valide la
signature à chaque requête.
Les tests de test_logs.py récupèrent désormais le cookie réellement signé
par le serveur, et un nouveau module test_security.py couvre le rejet des
cookies forgés/tamponnés/rejoués.
Le DATA_DIR temporaire des tests est centralisé dans conftest.py pour éviter
les collisions entre modules de test.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Toute modification des paramètres (topic/serveur/jeton ntfy, plages
horaires, régénération du token de présence Tasker) est désormais
enregistrée dans /logs sous un nouveau type "Réglages", avec un onglet
dédié. Chaque entrée résume ce qui a changé (valeurs des plages, plage
des rappels, topic, jeton défini/vide, URLs invalidées), pour aider à
diagnostiquer a posteriori un problème de configuration.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Le bouton "Régénérer le token" de la carte Tasker crée un nouveau token
secret et invalide immédiatement les anciennes URLs, au cas où elles
auraient fuité. La fonction regenerate_presence_token préserve les
autres réglages notif (topic, serveur, jeton d'accès, plage). Au passage
le message de retour du bouton de test ntfy occupe désormais toute la
largeur de la carte au lieu d'être limité à 420px.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Certains serveurs ntfy auto-hébergés (ex: ntfy.arkel.fr) renvoient
403 Forbidden sans authentification. Un nouveau champ "Jeton d'accès"
(optionnel, type password) sur /settings permet de renseigner un token
qui sera envoyé comme "Authorization: Bearer ..." à chaque publication,
pour les rappels réels comme pour le bouton de test. La page /aide
documente le cas 403 et la marche à suivre.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Permet de vérifier en un clic que le serveur et le topic ntfy
renseignés fonctionnent, avant d'attendre un vrai rappel. Le bouton
envoie une notification de test via la même chaîne que les rappels
réels (donc tracée dans /logs), et affiche le résultat inline.
La fonction send_ntfy retourne désormais un booléen de succès pour
permettre ce retour utilisateur.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
La nouvelle route /aide regroupe dans l'app les étapes d'installation
de l'app ntfy (Play Store/F-Droid, abonnement au topic, piège
optimisation batterie, option auto-hébergé) et de Tasker (permissions
localisation, profils Enter/Exit avec HTTP Request), plus une section
vérification via /logs et un dépannage. Elle est accessible depuis la
nav et depuis les deux cartes ntfy/Tasker de /settings. Les 11 tests
de test_logs.py couvrent en plus les routes /logs et /aide, le logging
présence, l'isolation par user et la rotation.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Chaque utilisateur a désormais son propre fichier logs.json dans son
dossier users/<id>/, alimenté automatiquement quand un rappel ntfy est
envoyé (ou échoue) et quand Tasker signale une arrivée ou un départ.
Une nouvelle page /logs, accessible depuis la nav, affiche ces entrées
avec filtres (Tout / Notifications / Présence), et le journal tourne à
500 entrées pour rester compact.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
La colonne Cible affichait un horaire seulement pour les demi-journées
avec du rattrapage à faire ; les demi-journées au rythme normal
n'affichaient rien. Désormais chaque demi-journée restante (matin et
après-midi) propose sa plage entrée → sortie, qu'il y ait du retard ou
non, pour donner un planning complet à l'utilisateur.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
La colonne "Cible" (anciennement "Fin cible") montre désormais une plage
complète entrée → sortie pour chaque demi-journée à rattraper, au lieu
d'une heure de départ seule. Moins ambigu pour l'utilisateur qui sait
aussi quand arriver.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
La cible matin ne dépasse plus jamais matin_fin, garantissant la pause de
midi ; le nominal qui déborde bascule sur l'après-midi du même jour.
Ajoute deux réglages optionnels par utilisateur (arrivée visée le matin,
départ visé le soir) qui orientent la répartition du rattrapage en deux
passes : d'abord dans le cadre visé, puis au-delà si nécessaire.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trois corrections sur les préconisations :
- Une demi-journée en cours (entrée pointée sans sortie, aujourd'hui) était
comptée deux fois — temps écoulé dans le travaillé ET nominal complet comme
capacité restante — ce qui faisait disparaître le rattrapage pendant la
journée et produisait des cibles en manque du temps déjà écoulé. Le temps
écoulé est réintégré au besoin planifié.
- La cible matin est plafonnée à la reprise de l'après-midi ; un créneau
saturé reporte son surplus sur les créneaux encore ouverts (waterfill)
au lieu d'afficher une sortie matin absurde.
- Une semaine entièrement pointée mais en déficit affiche désormais un
bandeau "Semaine terminée avec -X de manque" au lieu de rien.
Une demi-journée passée non renseignée était exclue à tort du calcul de
rattrapage (l'appli permet de saisir ses heures après coup, un jour passé
vide n'est pas une perte définitive) : une semaine entièrement vide affichait
"reste à faire ... sur 2 demi-journées" au lieu de 10. Corrige aussi le
chevauchement de l'icône d'horloge sur les champs <input type="time"> de la
page Réglages (largeur trop juste pour le texte + l'icône native).
Chaque compte a désormais son propre data/users/<user>/plages.json, réglable
depuis une nouvelle carte "Plages horaires" sur /settings (avec validation
HH:MM et ordre matin < aprem). Le calcul, les stats de conformité et les
avertissements de saisie lisent la config de l'utilisateur courant au lieu
du plages global. La valeur globale de data/config.json reste lue comme
valeur de repli tant qu'un utilisateur n'a rien personnalisé.
Au lieu de reporter tout le manque horaire sur la sortie du jour courant, le calcul
distribue désormais le retard à parts égales sur chaque demi-journée non traitée
(aujourd'hui et jours à venir), avec un plafond de sécurité si le rattrapage dépasse
minuit. La table affiche une cible par demi-journée (matin/après-midi) et le bandeau
de soldes indique le reste à faire et le nombre de demi-journées restantes.
classement, ajoute une suite de tests pytest
- calcul.py: matin_debut/fin, aprem_debut/fin et pause_dejeuner_fin sont
paramétrables via config.json > plages (moteur de calcul de l'heure de
sortie optimale inclus, pas seulement l'affichage). Défauts identiques
à l'ancien comportement, validé par la suite de tests.
- stats.py: les seuils de conformité utilisent la même config.
- main.py/templates: warn de saisie et suffixes de cible dynamiques.
- Rappels ntfy: serveur, topic et plage horaire (rappel_debut_h/fin_h)
configurables par utilisateur depuis /settings, plus en dur dans le code.
- Page classement supprimée (pas de compétition entre utilisateurs).
- Nouvelle suite pytest (app/tests/), à lancer via
`docker compose run --rm pointeuse pytest -v`.
réduit le score spam des emails, et rend le niveau de log configurable
- Les styles .login-* étaient définis uniquement dans login.html, donc
set_password.html s'affichait sans aucun style. Déplacé dans base.html.
- La nav affichait les liens même sans session active.
- mail.py ajoute Message-ID/Date et encode en quoted-printable (au lieu
de base64) pour réduire le score spam (MISSING_MID, MISSING_DATE,
MIME_BASE64_TEXT).
- log_level devient une directive de config.json plutôt qu'une variable
d'environnement.
- README: section démarrage rapide (copie de config.example.json,
compose.override.yml pour les surcharges locales).
Niveau configurable via LOG_LEVEL (défaut DEBUG). Les erreurs de config
JSON invalide remontent maintenant un message clair (fichier + position)
au lieu d'un JSONDecodeError brut.
Ajoute le bloc "smtp" (host, port, identifiants, TLS) dans config.json,
plus fiable et plus simple à opérer qu'une livraison directe au MX du
destinataire. data/config.json n'est plus versionné (secrets), remplacé
par data/config.example.json.
Calcule les heures travaillées et cibles d'entrée/sortie même quand une
demi-journée est en cours (pas encore de pointage de sortie), au lieu
d'attendre la fin de journée. Ajoute un endpoint /refresh pour
rafraîchir l'affichage sans repointer. Désambiguïse les libellés de
semaine sur plusieurs années et force TZ=Europe/Paris dans le conteneur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Auth cookie simple (SSO-ready) : login/logout, redirection si non authentifié
- Données isolées par utilisateur dans /data/users/{user_id}/
- Migration automatique des données legacy au premier login
- Page classement avec podium et tableau comparatif (score, précision zéro, conformité)
- Nav mise à jour : Classement, affichage utilisateur courant, déconnexion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Nouvelle page /stats avec histogrammes de distribution par créneau
horaire (entrée/sortie matin et après-midi)
- Camemberts animés de conformité aux directives (≤09:00, ≥12:00,
≤14:00, ≥17:00) globale, par slot et par période (mois, sem. N-1)
- Onglets Tout / 1 an / 3 mois / 1 mois : filtrage côté client avec
recalcul μ, σ, n et conformité en temps réel
- Graphique des soldes hebdomadaires (barres animées, toutes semaines
sur l'axe X en rotation -45°, semaines partielles distinctes)
- Clic sur un graphique → modal plein écran avec axe X détaillé (15 min)
- Légende σ / n / directive / moyenne sur chaque histogramme
- Journées incomplètes exclues des stats ; heures dues retirées du solde
- Lien "Statistiques" dans la nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Nouvelles colonnes : Δ sem. (solde cumulé semaine) et Fin cible (heure de
sortie aprem optimale pour 0h suppl. en fin de semaine)
- Δ sem. n'est affiché que quand la journée est complète et la chaîne de jours
ininterrompue (pas de saut de jour non saisi)
- Fin cible disparaît quand la journée est validée (passée/complète)
- Delta = "—" pour les jours non travaillés (futur ou congé jour)
- Indicateur warn (fond rouge) sur matin/aprem si horaires < minimum
(matin 09:00→12:00, aprem 14:00→17:00) ; mise à jour live via JS
- Suppression du solde global (3 cartes au lieu de 4)
- Tableau sans largeurs fixes + container 1100 px pour éviter scroll
horizontal sur écrans normaux ; touch-scroll conservé sur mobile
- Base reste 39h/semaine (7h48/jour)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
App web de suivi des heures de travail (FastAPI + HTMX + JSON plats).
- Saisie inline par semaine avec bouton ✓ par ligne
- Congés MA/AM/J avec coloration cellule et mise à jour live via OOB HTMX
- Import fichier ODS badgeuse
- Solde semaine et solde global recalculés à la volée
- Design "ledger" écru, DM Mono, zéro border-radius
- Déploiement Docker sur port 8000
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>