Última atividade 1 week ago

Github webhook proxy server (only Issues implemented)

Revisão a293d7b1a3c3e075990360db1bc76492cce54607

__main__.py Bruto
1from fastapi import FastAPI, Request, Depends, Header
2from contextlib import asynccontextmanager
3import niquests
4import logging
5
6import handlers
7from dependencies import get_request_client
8
9logger = logging.getLogger(__name__)
10
11
12@asynccontextmanager
13async def lifespan(app: FastAPI):
14 app.state.request_client = niquests.AsyncSession()
15 yield
16 await app.state.request_client.close()
17
18
19app = FastAPI(lifespan=lifespan)
20
21
22@app.post("/github/{repo_name}")
23async def github_webhook(
24 request: Request,
25 repo_name: str,
26 client: niquests.AsyncSession = Depends(get_request_client),
27 x_real_ip: str = Header(...),
28 x_github_event: str = Header(...),
29 x_hub_signature: str = Header(...),
30):
31 payload = await request.json()
32 match x_github_event:
33 case "issues":
34 await handlers.issues(payload, client)
35 case _:
36 logger.debug(f"unhandled event {x_github_event}")
37 return {
38 "message": f"received webhook for {repo_name}: {x_github_event}",
39 "status": "success",
40 }
41
dependencies.py Bruto
1from fastapi import Request, Header, Depends
2import niquests
3from fastapi import HTTPException
4import logging
5import hashlib
6import hmac
7from settings import settings
8from ipaddress import ip_address, ip_network
9from typing import Annotated
10
11logger = logging.getLogger(__name__)
12
13
14def get_request_client(request: Request) -> niquests.AsyncSession:
15 return request.app.state.request_client
16
17
18async def validate_github_ip(
19 request_client: Annotated[niquests.AsyncSession, Depends(get_request_client)],
20 x_real_ip: str = Header(...),
21) -> None:
22 resp = await request_client.get("https://api.github.com/meta")
23 allowed_ips = resp.json().get("hooks", [])
24 if not any(
25 ip_address(x_real_ip) in ip_network(valid_ip) for valid_ip in allowed_ips
26 ):
27 logger.debug(f"Received request from invalid IP: {x_real_ip}")
28 ERR_MSG = f"IP address {x_real_ip} is not allowed"
29 raise HTTPException(status_code=403, detail=ERR_MSG)
30
31
32async def validate_webhook_secret(
33 request: Request, x_hub_signature: str = Header(...)
34) -> None:
35 if not x_hub_signature:
36 logger.debug("Missing X-Hub-Signature header")
37 raise HTTPException(status_code=400, detail="Missing X-Hub-Signature header")
38
39 mac = hmac.new(
40 settings.GITHUB_WEBHOOK_SECRET.encode(),
41 msg=await request.body(),
42 digestmod=hashlib.sha1,
43 )
44
45 if not hmac.compare_digest("sha1=" + mac.hexdigest(), x_hub_signature):
46 logger.debug("Invalid HMAC signature")
47 raise HTTPException(status_code=403, detail="Invalid HMAC signature")
48
handlers.py Bruto
1from niquests import AsyncSession
2from settings import settings
3import logging
4from fastapi import HTTPException
5
6logger = logging.getLogger(__name__)
7
8
9async def issues(payload, request_client: AsyncSession):
10 if payload["action"] not in ["opened", "closed", "reopened", "deleted"]:
11 return
12
13 embed = {
14 "author": {
15 "name": payload["sender"]["login"],
16 "icon_url": payload["sender"]["avatar_url"],
17 },
18 "title": f"[{payload['repository']['full_name']}] Issue {payload['action'].capitalize()}: #{payload['issue']['number']} {payload['issue']['title']}",
19 "url": payload["issue"]["html_url"],
20 "description": payload["issue"].get("body"),
21 }
22 data = {
23 "username": "Github",
24 "embeds": [embed],
25 }
26 await send_discord_webhook(request_client=request_client, data=data)
27
28
29async def send_discord_webhook(request_client: AsyncSession, data):
30 resp = await request_client.post(settings.DISCORD_WEBHOOK_URL, json=data)
31 if resp.status_code != 204:
32 raise HTTPException(status_code=400, detail="Failed to send Discord webhook")
33 logger.info(f"Discord webhook sent with status code {resp.status_code}")
34
settings.py Bruto
1from pydantic_settings import BaseSettings, SettingsConfigDict
2
3
4class Settings(BaseSettings):
5 """Application settings."""
6
7 GITHUB_WEBHOOK_SECRET: str
8 DISCORD_WEBHOOK_URL: str
9
10 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
11
12
13settings = Settings()
14