feat(capture): capture éclair — API à jeton dédié + écoute ntfy (issue #12)

POST /api/capture (X-Capture-Token ou Bearer; 503 si non configuré;
exempté Basic Auth/CSRF car machine-à-machine), JSON ou texte libre
(1re ligne = titre), dépôt par défaut KANBANTE_CAPTURE_REPO.
Module app/capture.py: parsing partagé + abonné ntfy en tâche de
fond (reconnexion, curseur kv_store anti-rejeu, tag 'kanban'...
'kanbante' ignoré). _do_capture commun: issue créée, carte placée,
événement 'created' source 'capture'.
This commit is contained in:
Spark
2026-10-11 12:40:31 -04:00
parent 05b8c506e0
commit 395ab6e14c
3 changed files with 313 additions and 2 deletions
+174 -1
View File
@@ -35,6 +35,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.config import APP_VERSION, get_settings
from app.gitea_client import GiteaClient
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
from app.capture import ntfy_capture_loop, parse_capture_text
from app.models import (
KANBAN_COLUMNS,
column_entry_times,
@@ -139,8 +140,30 @@ async def lifespan(app: FastAPI):
"GITEA_TOKEN is not set: only public Gitea content will be visible."
)
scheduler = asyncio.create_task(_briefing_scheduler())
capture_task = None
if settings.capture_ntfy_topic and settings.ntfy_url:
async def _capture_create(title: str, body: str) -> None:
repo = get_settings().capture_repo
if not repo:
logger.warning("ntfy capture: no KANBANTE_CAPTURE_REPO, skipped")
return
await _do_capture(title, body, repo, "backlog")
capture_task = asyncio.create_task(
ntfy_capture_loop(
_capture_create,
lambda: get_kv("capture_ntfy_last_id"),
lambda value: set_kv("capture_ntfy_last_id", value),
)
)
logger.info(
"ntfy capture listener started (topic %s)",
settings.capture_ntfy_topic,
)
yield
scheduler.cancel()
if capture_task is not None:
capture_task.cancel()
await gitea.aclose()
@@ -162,7 +185,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
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"):
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
return await call_next(request)
settings = get_settings()
if not settings.auth_enabled:
@@ -1003,6 +1026,156 @@ async def sse_event_stream(request: Request):
hub.unsubscribe(queue)
async def _do_capture(
title: str, body: str, repo_full_name: str, column: str = "backlog"
) -> dict:
"""Create a Gitea issue from a capture and place its card.
Shared by POST /api/capture, the /capture page and the ntfy
listener. Raises ValueError for bad input (callers map to 400).
"""
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
if column not in KANBAN_COLUMNS:
raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
issue = await gitea.create_issue(owner, repo, title, body or "")
move_issue(repo_full_name, issue["number"], column)
log_event(
repo_full_name, issue["number"], "created",
to_column=column, source="capture",
)
return {
"number": issue["number"],
"title": issue.get("title", title),
"html_url": issue.get("html_url", ""),
"repo_full_name": repo_full_name,
"column": column,
}
@app.post("/api/capture")
async def api_capture(request: Request):
"""Quick capture endpoint (roadmap #12).
Auth: dedicated capture token (X-Capture-Token header or
Authorization: Bearer), distinct from the Gitea token. Exempt
from Basic Auth and CSRF — it is a machine-to-machine endpoint.
Disabled (503) until KANBANTE_CAPTURE_TOKEN is set.
"""
settings = get_settings()
if not settings.capture_token:
return JSONResponse(
{"ok": False, "error": "Capture is not configured "
"(set KANBANTE_CAPTURE_TOKEN)"},
status_code=503,
)
provided = request.headers.get("x-capture-token", "")
auth = request.headers.get("authorization", "")
if not provided and auth.lower().startswith("bearer "):
provided = auth[7:].strip()
if not provided or not secrets.compare_digest(provided, settings.capture_token):
return JSONResponse(
{"ok": False, "error": "Invalid capture token"}, status_code=401
)
content_type = request.headers.get("content-type", "")
title = body = text = repo = ""
column = "backlog"
if "application/json" in content_type:
try:
payload = await request.json()
except Exception:
payload = {}
if isinstance(payload, dict):
title = str(payload.get("title") or "")
body = str(payload.get("body") or "")
text = str(payload.get("text") or "")
repo = str(payload.get("repo") or "")
column = str(payload.get("column") or "backlog")
elif "application/x-www-form-urlencoded" in content_type or (
"multipart/form-data" in content_type
):
form = await request.form()
title = str(form.get("title") or "")
body = str(form.get("body") or "")
text = str(form.get("text") or "")
repo = str(form.get("repo") or "")
column = str(form.get("column") or "backlog")
else: # text/plain and friends: the whole body is the capture
text = (await request.body()).decode("utf-8", errors="replace")
if not title and text:
title, rest = parse_capture_text(text)
body = body or rest
repo = repo or settings.capture_repo
if not repo:
return JSONResponse(
{"ok": False, "error": "No repo given and KANBANTE_CAPTURE_REPO "
"is not set"},
status_code=400,
)
try:
created = await _do_capture(title, body, repo, column)
except ValueError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
return JSONResponse({"ok": True, "issue": created}, status_code=201)
@app.get("/capture", response_class=HTMLResponse)
async def capture_page(request: Request):
"""Quick-capture page: form, status and usage instructions."""
settings = get_settings()
return render(
request,
"capture.html",
capture_repo=settings.capture_repo,
api_enabled=bool(settings.capture_token),
ntfy_topic=settings.capture_ntfy_topic,
ntfy_url=settings.ntfy_url,
created=None,
error=None,
)
@app.post("/capture", response_class=HTMLResponse)
async def capture_submit(
request: Request,
repo_full_name: str = Form(""),
title: str = Form(""),
body: str = Form(""),
column: str = Form("backlog"),
csrf_token: str = Form(""),
):
"""Web form capture (session + CSRF, like every other form)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
repo_full_name = repo_full_name.strip() or settings.capture_repo
created, error = None, None
if not repo_full_name:
error = "Aucun dépôt : renseigne le dépôt ou définis KANBANTE_CAPTURE_REPO."
else:
try:
created = await _do_capture(title, body, repo_full_name, column)
except ValueError as exc:
error = str(exc)
return render(
request,
"capture.html",
capture_repo=settings.capture_repo,
api_enabled=bool(settings.capture_token),
ntfy_topic=settings.capture_ntfy_topic,
ntfy_url=settings.ntfy_url,
created=created,
error=error,
)
@app.get("/api/stream")
async def live_stream(request: Request):
"""Server-Sent Events: browsers reload softly on Gitea activity."""