"""Gitea webhook receiver per §4.1. Both the webhook receiver and the reconciler are §4.1 cache writers. On a meaningful event — meta-repo push or PR change — we re-read just what changed from Gitea and update the cache. The signature is verified against the configured shared secret so spurious POSTs cannot poison the cache. """ from __future__ import annotations import hashlib import hmac import json import logging import os from fastapi import APIRouter, Header, HTTPException, Request from . import cache, db from .config import Config from .gitea import Gitea log = logging.getLogger(__name__) EVENTS_OF_INTEREST = { "push", # meta-repo or RFC-repo commits "pull_request", # opened / closed / merged "create", # branch or repo created "delete", # branch deleted "repository", # repo created or deleted } def make_router(config: Config, gitea: Gitea) -> APIRouter: router = APIRouter() @router.post("/api/webhooks/gitea") async def receive( request: Request, x_gitea_event: str = Header(default=""), x_gitea_signature: str = Header(default=""), ): body = await request.body() # v0.18.0: defense in depth. config.py refuses to start # when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1` # is set; this branch catches the dev-bypass case (the only # path where `config.webhook_secret` can be empty) and surfaces # it loudly to the client. A POST that lands here with an # empty secret on a production deployment indicates a # mis-configuration (somebody flipped the bypass in prod), # and the loud 500 is the proposal's whole point. insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1" if not config.webhook_secret: if not insecure: log.error( "webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty " "and RFC_APP_INSECURE_WEBHOOKS=1 is not set" ) raise HTTPException(status_code=500, detail="Webhook receiver misconfigured") log.warning( "webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — " "signature verification is DISABLED. Production deployments MUST NOT set this." ) else: if not _verify_signature(body, x_gitea_signature, config.webhook_secret): raise HTTPException(status_code=401, detail="Invalid signature") event = x_gitea_event.lower() if event not in EVENTS_OF_INTEREST: return {"ok": True, "ignored": event} # Identify the originating repo. For the meta repo we refresh # the entry cache + meta-PR cache; for a per-RFC repo we refresh # just that repo's branches/PRs/main body. The handler stays # generous in what it accepts — refreshes are idempotent and # small enough that overlapping events do not pile up. try: payload = json.loads(body) if body else {} except Exception: payload = {} repo_full = (payload.get("repository") or {}).get("full_name") or "" meta_full = f"{config.gitea_org}/{config.meta_repo}" try: if repo_full == meta_full or not repo_full: await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_pulls(config, gitea) else: slug = _slug_for_repo(repo_full) if slug: await cache.refresh_rfc_repo(config, gitea, slug) else: # v0.18.0: the proposal's "unknown-repo logging" # gesture — a hook on a fork or a stale repo binding # used to silently 200-OK here, hiding the # misconfiguration. Now the operator sees it in # the log. log.info( "webhook received for unknown repo: repo_full=%s event=%s " "(no cached_rfcs row matched; hook may be on a fork or stale)", repo_full, event, ) except Exception: log.exception("webhook refresh failed") raise HTTPException(status_code=500, detail="Refresh failed") return {"ok": True} return router def _verify_signature(body: bytes, header: str, secret: str) -> bool: if not header: return False expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header) def _slug_for_repo(repo_full: str) -> str | None: row = db.conn().execute( "SELECT slug FROM cached_rfcs WHERE repo = ?", (repo_full,) ).fetchone() return row["slug"] if row else None