import hashlib import hmac import json import logging import threading from flask import Flask, request, abort import config import gitea_client import processor logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger(__name__) app = Flask(__name__) def _verify_signature(payload: bytes, signature_header: str | None) -> bool: """Validate the Gitea webhook HMAC-SHA256 signature.""" if not signature_header: return False try: scheme, provided_digest = signature_header.split("=", 1) except ValueError: return False if scheme != "sha256": return False expected = hmac.new( config.WEBHOOK_SECRET.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, provided_digest) def _handle_push(owner: str, repo: str, changed_files: list[str]) -> None: """Process all changed markdown files in a push event.""" for file_path in changed_files: if not file_path.endswith(".md"): continue logger.info("Checking %s/%s: %s", owner, repo, file_path) try: content, sha = gitea_client.get_file(owner, repo, file_path) updated, changed = processor.process_document(content) if changed: gitea_client.update_file(owner, repo, file_path, updated, sha) logger.info("Updated %s", file_path) else: logger.info("No BLIGHT triggers found in %s", file_path) except Exception as exc: logger.error("Failed processing %s: %s", file_path, exc) @app.post("/webhook") def webhook(): payload = request.get_data() if not _verify_signature(payload, request.headers.get("X-Gitea-Signature")): logger.warning("Rejected webhook: invalid signature") abort(403) event = request.headers.get("X-Gitea-Event") if event != "push": return {"status": "ignored", "event": event}, 200 data = json.loads(payload) owner = data["repository"]["owner"]["login"] repo = data["repository"]["name"] # Collect unique file paths from all commits in the push seen: set[str] = set() changed_files: list[str] = [] for commit in data.get("commits", []): for path in commit.get("added", []) + commit.get("modified", []): if path not in seen: seen.add(path) changed_files.append(path) if not changed_files: return {"status": "no files"}, 200 # Process in background so we return 200 to Gitea immediately thread = threading.Thread( target=_handle_push, args=(owner, repo, changed_files), daemon=True, ) thread.start() return {"status": "processing", "files": len(changed_files)}, 200 if __name__ == "__main__": logger.info("BLIGHT: CUE starting on port %d", config.WEBHOOK_PORT) app.run(host="0.0.0.0", port=config.WEBHOOK_PORT)