v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
- OAuth2 Gitea (login/callback/logout) with fallback admin mode
- Session management with signed cookies (itsdangerous, 7 days)
- CSRF protection middleware on all POST/PUT/PATCH/DELETE
- Rate limiting in-memory (60 req/min per IP)
- Issue creation from FlowDeck (POST /api/issues/{owner}/{repo})
- Inline issue editing (PATCH /api/issues/{owner}/{repo}/{issue_id})
- Card detail modal (double-click): description, labels, comments
- Checklists with toggleable items (CRUD endpoints + UI)
- Priority + due date editable on cards
- Webhook receiver (POST /api/webhook) with HMAC-SHA256
- Auto-register/status webhooks for repos
- GiteaClient: create_issue, update_issue, get_comments, webhooks, collaborators
- New DB tables: users, user_tokens, checklists, checklist_items
- CSS: modal, forms, checklists, comments, PR section
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["webhooks"], prefix="/api/webhook")
|
||||
|
||||
|
||||
async def verify_signature(request: Request) -> bool:
|
||||
"""Verify Gitea webhook HMAC signature."""
|
||||
if not settings.gitea_webhook_secret:
|
||||
return True # No secret configured, skip verification
|
||||
|
||||
sig = request.headers.get("X-Gitea-Signature", "")
|
||||
if not sig:
|
||||
return False
|
||||
|
||||
body = await request.body()
|
||||
expected = hmac.new(
|
||||
settings.gitea_webhook_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def receive_webhook(request: Request):
|
||||
"""Receive and process Gitea webhook events."""
|
||||
if not await verify_signature(request):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
event_type = request.headers.get("X-Gitea-Event", "")
|
||||
body = await request.json()
|
||||
|
||||
logger.info("Webhook received: %s", event_type)
|
||||
|
||||
if event_type == "issues":
|
||||
await _handle_issue_event(body)
|
||||
elif event_type == "pull_request":
|
||||
await _handle_pr_event(body)
|
||||
elif event_type == "repository":
|
||||
await _handle_repo_event(body)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _handle_issue_event(payload: dict):
|
||||
"""Handle issue webhook events."""
|
||||
action = payload.get("action", "")
|
||||
issue = payload.get("issue", {})
|
||||
repo = payload.get("repository", {})
|
||||
|
||||
owner = repo.get("owner", {}).get("login", "")
|
||||
repo_name = repo.get("name", "")
|
||||
issue_id = issue.get("number", 0)
|
||||
|
||||
if not all([owner, repo_name, issue_id]):
|
||||
return
|
||||
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo_name),
|
||||
).fetchone()
|
||||
|
||||
if not board:
|
||||
return
|
||||
board_id = board["id"]
|
||||
|
||||
if action in ("opened", "reopened"):
|
||||
# Add card to default column
|
||||
column = "Backlog"
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO cards (board_id, gitea_issue_id, column_name, position)
|
||||
VALUES (?, ?, ?, 0)""",
|
||||
(board_id, issue_id, column),
|
||||
)
|
||||
|
||||
elif action == "closed":
|
||||
# Move to Terminé column
|
||||
conn.execute(
|
||||
"UPDATE cards SET column_name='Terminé', updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
|
||||
(board_id, issue_id),
|
||||
)
|
||||
|
||||
elif action == "label_updated" or action == "labeled":
|
||||
# Check if any new label maps to a column
|
||||
labels = issue.get("labels", [])
|
||||
for lbl in labels:
|
||||
mapping = conn.execute(
|
||||
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||
(board_id, lbl.get("name", "")),
|
||||
).fetchone()
|
||||
if mapping:
|
||||
conn.execute(
|
||||
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
|
||||
(mapping["column_name"], board_id, issue_id),
|
||||
)
|
||||
break
|
||||
|
||||
elif action == "deleted":
|
||||
conn.execute(
|
||||
"DELETE FROM cards WHERE board_id=? AND gitea_issue_id=?",
|
||||
(board_id, issue_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
logger.debug("Issue webhook processed: %s/%s #%d action=%s", owner, repo_name, issue_id, action)
|
||||
|
||||
|
||||
async def _handle_pr_event(payload: dict):
|
||||
"""Handle pull request webhook events."""
|
||||
# For now, just invalidate cache so board refreshes
|
||||
from app.services.gitea_client import gitea
|
||||
repo = payload.get("repository", {})
|
||||
owner = repo.get("owner", {}).get("login", "")
|
||||
repo_name = repo.get("name", "")
|
||||
if owner and repo_name:
|
||||
gitea._invalidate_issue_cache(owner, repo_name)
|
||||
|
||||
|
||||
async def _handle_repo_event(payload: dict):
|
||||
"""Handle repository events (create, delete, etc.)."""
|
||||
# Invalidate projects cache
|
||||
from app.services.gitea_client import gitea
|
||||
gitea._cache.clear()
|
||||
|
||||
|
||||
@router.post("/register/{owner}/{repo}")
|
||||
async def register_webhook(owner: str, repo: str, request: Request):
|
||||
"""Register a webhook for a specific Gitea repository."""
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
webhook_url = settings.webhook_base_url.rstrip("/") + "/api/webhook"
|
||||
if not webhook_url or not settings.gitea_webhook_secret:
|
||||
raise HTTPException(status_code=400, detail="Webhook URL or secret not configured")
|
||||
|
||||
try:
|
||||
webhooks = await gitea.list_webhooks(owner, repo)
|
||||
|
||||
# Check if already registered
|
||||
for wh in webhooks:
|
||||
if wh.get("url") == webhook_url:
|
||||
return {"status": "already_registered", "webhook": wh}
|
||||
|
||||
# Create webhook
|
||||
result = await gitea.create_webhook(owner, repo, webhook_url, settings.gitea_webhook_secret)
|
||||
return {"status": "ok", "webhook": result}
|
||||
except Exception as e:
|
||||
logger.error("Failed to register webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status/{owner}/{repo}")
|
||||
async def webhook_status(owner: str, repo: str):
|
||||
"""Check webhook registration status."""
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
try:
|
||||
webhook_url = settings.webhook_base_url.rstrip("/") + "/api/webhook"
|
||||
webhooks = await gitea.list_webhooks(owner, repo)
|
||||
for wh in webhooks:
|
||||
if wh.get("url") == webhook_url:
|
||||
return {"registered": True, "webhook": wh}
|
||||
return {"registered": False}
|
||||
except Exception as e:
|
||||
return {"registered": False, "error": str(e)}
|
||||
Reference in New Issue
Block a user