Son aktivite 1 week ago

Github webhook proxy server (only Issues implemented)

dependencies.py Ham
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_source_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 Ham
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-OpenGist",
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
main.py Ham
1from fastapi import FastAPI, Request, Depends, Header
2from contextlib import asynccontextmanager
3import niquests
4import logging
5
6import handlers
7from dependencies import (
8 get_request_client,
9 validate_source_ip,
10 validate_webhook_secret,
11)
12
13logger = logging.getLogger(__name__)
14
15
16@asynccontextmanager
17async def lifespan(app: FastAPI):
18 app.state.request_client = niquests.AsyncSession()
19 yield
20 await app.state.request_client.close()
21
22
23app = FastAPI(lifespan=lifespan)
24
25
26@app.post(
27 "/github/{repo_name}",
28 dependencies=[
29 Depends(validate_source_ip),
30 Depends(validate_webhook_secret),
31 ],
32)
33async def github_webhook(
34 repo_name: str,
35 request: Request,
36 client: niquests.AsyncSession = Depends(get_request_client),
37 x_real_ip: str = Header(...),
38 x_github_event: str = Header(...),
39 x_hub_signature: str = Header(...),
40):
41 payload = await request.json()
42 match x_github_event:
43 case "ping":
44 return {"message": "pong"}
45 case "issues":
46 await handlers.issues(payload, client)
47 case _:
48 logger.debug(f"unhandled event {x_github_event}")
49 return {
50 "message": f"received webhook for {repo_name}: {x_github_event}",
51 "status": "success",
52 }
53
settings.py Ham
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