Kanbante
+Pilotage de projets, adossé à votre Gitea
+⚠️ {{ error }}
+ {% endif %} + +Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.
+diff --git a/README.md b/README.md index b1d036a..be6a0db 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,10 @@ sudo systemctl enable --now docker | `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea | | `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement | | `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) | -| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis | +| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts | | `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée | +| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) | +| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé | | `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » | | `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) | | `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet | diff --git a/app/config.py b/app/config.py index 7a66bbb..bb69739 100644 --- a/app/config.py +++ b/app/config.py @@ -8,9 +8,15 @@ Environment variables: GITEA_URL Base URL of the Gitea instance. GITEA_TOKEN Gitea API token (read/write issues). KANBANTE_DB_PATH Path of the local SQLite database. - KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth). - KANBANTE_AUTH_PASSWORD Basic-auth password. + KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth). + KANBANTE_AUTH_PASSWORD Login password. KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured. + KANBANTE_SESSION_SECRET HMAC key for the login session cookie + (default: derived from the credentials — + rotating the password invalidates sessions). + KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot + refreshes (chantier B, default 120; + 0 disables the refresher). KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many days counts as "active" (default 365). KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30). @@ -89,7 +95,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.21.0" +APP_VERSION = "0.22.0" def _env_bool(name: str, default: bool) -> bool: @@ -158,6 +164,8 @@ class Settings: briefing_include_standup: bool = False rules_raw: str = "" zombie_days: int = 30 + session_secret: str = "" + snapshot_interval: int = 120 @property def auth_enabled(self) -> bool: @@ -203,4 +211,6 @@ def get_settings() -> Settings: briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False), rules_raw=os.getenv("KANBANTE_RULES", ""), zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30), + session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""), + snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120), ) diff --git a/app/main.py b/app/main.py index 9b2c799..fead3e8 100644 --- a/app/main.py +++ b/app/main.py @@ -22,13 +22,21 @@ import json import logging import re import secrets +import time from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from pathlib import Path +from urllib.parse import quote import httpx from fastapi import FastAPI, Form, Request -from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse +from fastapi.responses import ( + HTMLResponse, + JSONResponse, + RedirectResponse, + Response, + StreamingResponse, +) from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape from starlette.middleware.base import BaseHTTPMiddleware @@ -37,6 +45,7 @@ from app.config import APP_VERSION, get_settings from app.gitea_client import GiteaClient from app import code_links as code_links_service from app import digest as digest_service +from app import snapshots as snapshots_service from app import flow as flow_service from app import forecast as forecast_service from app import rules as rules_service @@ -155,6 +164,13 @@ async def lifespan(app: FastAPI): logger.warning( "GITEA_TOKEN is not set: only public Gitea content will be visible." ) + # Chantier B: hydrate the client cache from persisted + # snapshots BEFORE serving, then keep them warm in the + # background — pages never wait on Gitea when data exists. + gitea.hydrate_from_snapshots() + snapshot_task = asyncio.create_task( + snapshots_service.snapshot_loop(gitea, classify_repos) + ) scheduler = asyncio.create_task(_briefing_scheduler()) digest_task = asyncio.create_task(_digest_scheduler()) rules_task = asyncio.create_task(_rules_scheduler()) @@ -179,6 +195,7 @@ async def lifespan(app: FastAPI): settings.capture_ntfy_topic, ) yield + snapshot_task.cancel() scheduler.cancel() digest_task.cancel() rules_task.cancel() @@ -192,20 +209,81 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") gitea = GiteaClient() -# ── Auth middleware ──────────────────────────────────────── +# ── Auth: login form + signed session cookie (0.22.0) ────── +# Before 0.22.0 the browser got HTTP Basic's native prompt. +# Browsers now use a real login page; the session rides in an +# HMAC-signed cookie. An Authorization: Basic header is still +# honoured for scripts and API clients. /api/health stays +# public for Docker's HEALTHCHECK; /webhooks/gitea is +# authenticated by its HMAC signature (Gitea cannot log in) and +# /api/capture by its dedicated token. + +SESSION_COOKIE = "kanbante_session" +SESSION_DAYS = 7 +_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login") + + +def _session_secret() -> bytes: + settings = get_settings() + if settings.session_secret: + return settings.session_secret.encode("utf-8") + # Derived from the credentials: rotating the password + # invalidates every outstanding session (documented trade-off). + material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}" + return hashlib.sha256(material.encode("utf-8")).digest() + + +def _make_session(user: str) -> str: + exp = int(time.time()) + SESSION_DAYS * 86400 + payload = f"{user}.{exp}" + sig = hmac.new( + _session_secret(), payload.encode("utf-8"), hashlib.sha256 + ).hexdigest() + return f"{payload}.{sig}" + + +def _session_user(request: Request) -> str | None: + raw = request.cookies.get(SESSION_COOKIE, "") + user, _, rest = raw.partition(".") + exp, _, sig = rest.partition(".") + if not user or not exp or not sig: + return None + expected = hmac.new( + _session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256 + ).hexdigest() + if not secrets.compare_digest(sig, expected): + return None + try: + if int(exp) < int(time.time()): + return None + except ValueError: + return None + if not secrets.compare_digest(user, get_settings().auth_user): + return None + return user + + +def _basic_ok(request: Request) -> bool: + settings = get_settings() + header = request.headers.get("authorization", "") + if not header.lower().startswith("basic "): + return False + try: + decoded = base64.b64decode(header[6:]).decode("utf-8") + except Exception: + return False + user, _, password = decoded.partition(":") + return secrets.compare_digest( + user, settings.auth_user + ) and secrets.compare_digest(password, settings.auth_password) + class AuthMiddleware(BaseHTTPMiddleware): - """HTTP Basic auth, enabled only when credentials are configured. - - /api/health stays public so Docker's HEALTHCHECK (liveness) works. - When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every - other route answers 503: failing closed beats failing open. - """ + """Session cookie (browser) or Basic header (scripts).""" async def dispatch(self, request: Request, call_next): - # /api/health: liveness for Docker. /webhooks/gitea: authenticated - # by its HMAC signature, not by Basic auth (Gitea cannot log in). - if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"): + path = request.url.path + if path in _PUBLIC_PATHS or path.startswith("/static/"): return await call_next(request) settings = get_settings() if not settings.auth_enabled: @@ -220,30 +298,82 @@ class AuthMiddleware(BaseHTTPMiddleware): status_code=503, ) return await call_next(request) - - ok = False - header = request.headers.get("authorization", "") - if header.lower().startswith("basic "): - try: - decoded = base64.b64decode(header[6:]).decode("utf-8") - except Exception: - decoded = "" - user, _, password = decoded.partition(":") - ok = secrets.compare_digest( - user, settings.auth_user - ) and secrets.compare_digest(password, settings.auth_password) - if not ok: - return Response( - "Authentication required", + if _session_user(request) is not None or _basic_ok(request): + return await call_next(request) + # API consumers get a plain 401 (no browser prompt, no + # redirect they cannot follow); browsers get the login page. + if path.startswith("/api/") or "text/html" not in request.headers.get( + "accept", "" + ): + return JSONResponse( + {"ok": False, "error": "Authentication required"}, status_code=401, - headers={"WWW-Authenticate": 'Basic realm="Kanbante"'}, ) - return await call_next(request) + target = path + if request.url.query: + target = f"{path}?{request.url.query}" + return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303) app.add_middleware(AuthMiddleware) +def _safe_next(raw: str) -> str: + """Only local paths may be a post-login destination.""" + if raw.startswith("/") and not raw.startswith("//") and "://" not in raw: + return raw + return "/" + + +@app.get("/login") +async def login_page(request: Request, next: str = "/"): + if not get_settings().auth_enabled or _session_user(request) is not None: + return RedirectResponse(_safe_next(next), status_code=303) + return render(request, "login.html", next=_safe_next(next), error="") + + +@app.post("/login") +async def login_submit( + request: Request, + username: str = Form(""), + password: str = Form(""), + next: str = Form("/"), + csrf_token: str = Form(""), +): + settings = get_settings() + if not settings.auth_enabled: + return RedirectResponse("/", status_code=303) + if not _csrf_valid(request, csrf_token): + return render( + request, "login.html", status_code=403, + next=_safe_next(next), error="Session expirée — réessaie.", + ) + ok = secrets.compare_digest( + username, settings.auth_user + ) and secrets.compare_digest(password, settings.auth_password) + if not ok: + return render( + request, "login.html", status_code=401, + next=_safe_next(next), error="Identifiants incorrects.", + ) + response = RedirectResponse(_safe_next(next), status_code=303) + response.set_cookie( + SESSION_COOKIE, + _make_session(username), + max_age=SESSION_DAYS * 86400, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/logout") +async def logout(): + response = RedirectResponse("/login", status_code=303) + response.delete_cookie(SESSION_COOKIE) + return response + + # ── Live updates (SSE hub) ───────────────────────────────── class LiveHub: @@ -330,6 +460,8 @@ def render( context.setdefault("csrf_token", token) context.setdefault("version", APP_VERSION) context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, "")) + context.setdefault("auth_enabled", get_settings().auth_enabled) + context.setdefault("data_freshness", snapshots_service.freshness_label()) html = _tpl_env.get_template(name).render(**context) response = HTMLResponse(html, status_code=status_code) if generated: @@ -2303,6 +2435,13 @@ async def gitea_webhook(request: Request): handled = "pr-merged:" + (",".join(results) if results else "none") gitea.invalidate_cache() + # Chantier B: a webhook means this repo just changed — refresh + # its snapshots right away instead of waiting for the interval. + if full_name and "/" in full_name: + _owner, _, _repo = full_name.partition("/") + asyncio.create_task( + snapshots_service.refresh_repo_snapshots(gitea, _owner, _repo) + ) await hub.broadcast( {"repo": full_name, "event": event, "action": action, "issue": number} ) diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..fc7945a --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,36 @@ + + +
+ + + +Pilotage de projets, adossé à votre Gitea
+⚠️ {{ error }}
+ {% endif %} + +Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.
+