from __future__ import annotations import hashlib import hmac import json import logging from yuxi.channel.extensions.gitlab.dedupe import GitLabDeduplicator logger = logging.getLogger(__name__) class GitLabWebhookHandler: MAX_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2 MB def __init__(self, webhook_token: str, bot_username: str, signing_secret: str = ""): self._token = webhook_token self._signing_secret = signing_secret self._bot_username = bot_username self._deduplicator = GitLabDeduplicator(max_size=10000) def _verify_hmac(self, body: bytes, headers: dict) -> bool: sig_parts = headers.get("webhook-signature", "").split(",") sig_map = {} for part in sig_parts: if "=" in part: k, v = part.split("=", 1) sig_map[k.strip()] = v.strip() t = sig_map.get("t", "") v1 = sig_map.get("v1", "") if not t or not v1: return False signed_payload = f"{t}.{body.decode('utf-8', errors='replace')}" expected = hmac.new( self._signing_secret.encode("utf-8"), signed_payload.encode("utf-8"), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, v1) def verify_token(self, token_header: str | None) -> bool: if not token_header or not self._token: return False return hmac.compare_digest(token_header, self._token) async def handle_webhook(self, body: bytes, headers: dict) -> dict: if len(body) > self.MAX_PAYLOAD_SIZE: logger.warning( "GitLab webhook: payload too large (%d bytes > %d)", len(body), self.MAX_PAYLOAD_SIZE, ) return {"status": "payload_too_large"} event_type = headers.get("X-Gitlab-Event", "") event_uuid = headers.get("X-Gitlab-Event-Uuid", "") authorized = False if self._signing_secret and headers.get("webhook-signature"): authorized = self._verify_hmac(body, headers) if not authorized: token_header = headers.get("X-Gitlab-Token", "") authorized = self.verify_token(token_header) if not authorized: logger.warning("GitLab webhook: authentication failed") return {"status": "unauthorized"} try: payload = json.loads(body) except json.JSONDecodeError: logger.error("GitLab webhook: invalid JSON payload") return {"status": "invalid_payload"} object_kind = payload.get("object_kind", "") if not object_kind and event_type not in ( "Push Hook", "Tag Push Hook", "Wiki Page Hook", "Feature Flag Hook", "Release Hook", "Milestone Hook", "Emoji Hook", "Vulnerability Hook", ): return {"status": "unknown_event"} if self._deduplicator.is_duplicate(payload, event_uuid): logger.debug("GitLab webhook: duplicate event %s", event_uuid) return {"status": "duplicate"} user = payload.get("user", {}) username = user.get("username", "") if username and username == self._bot_username: return {"status": "self_message"} return { "status": "ok", "object_kind": object_kind, "event_type": event_type, "payload": payload, }