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:
+174
-1
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user