import hashlib import hmac import json import os import sqlite3 from fastapi import FastAPI, Header, HTTPException, Request DB_FILE = "/opt/appfactory/data/appfactory/appfactory.db" def read_env_file_value(key: str, default: str = "") -> str: try: with open("/opt/appfactory/config/appfactory.env", "r", encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith(f"{key}="): return line.split("=", 1)[1].strip().strip('"') except Exception: pass return default WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", read_env_file_value("WEBHOOK_SECRET", "")) CORE_SERVICES = { "appfactory-portal", "appfactory-webhook", "appfactory-worker", } IGNORED_REPOSITORIES = { "appfactory-tools", "appfactory-infrastructure", } app = FastAPI(title="AppFactory Webhook") @app.get("/health") def health(): return {"status": "ok"} def verify_signature(body: bytes, signature: str | None): if not WEBHOOK_SECRET: raise HTTPException(status_code=500, detail="Webhook secret is not configured") if not signature: raise HTTPException(status_code=401, detail="Missing signature") digest = hmac.new(WEBHOOK_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest() accepted = {digest, f"sha256={digest}"} signature = signature.strip() if not any(hmac.compare_digest(signature, item) for item in accepted): raise HTTPException(status_code=401, detail="Invalid signature") def first_commit(payload: dict) -> dict: commits = payload.get("commits") or [] if commits: return commits[-1] or {} return payload.get("head_commit") or {} def extract_attribution(payload: dict) -> dict: pusher = payload.get("pusher") or {} sender = payload.get("sender") or {} commit = first_commit(payload) author = commit.get("author") or {} pusher_name = ( pusher.get("full_name") or pusher.get("username") or pusher.get("name") or sender.get("full_name") or sender.get("login") or sender.get("username") or "unknown" ) pusher_username = ( pusher.get("username") or pusher.get("login") or sender.get("login") or sender.get("username") or pusher_name or "unknown" ) commit_author = ( author.get("name") or author.get("username") or author.get("email") or "unknown" ) commit_sha = commit.get("id") or commit.get("sha") or payload.get("after") or "unknown" return { "pusher": str(pusher_name), "pusher_username": str(pusher_username), "commit_author": str(commit_author), "commit_sha": str(commit_sha), } def audit_event( username: str, action: str, target_type: str, target_id: str, source: str, metadata: dict, ): con = sqlite3.connect(DB_FILE) con.execute( """ INSERT INTO audit_events ( username, action, target_type, target_id, source, metadata, created_at ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, ( username, action, target_type, target_id, source, json.dumps(metadata, ensure_ascii=False), ), ) con.commit() con.close() def create_job( job_type: str, target_type: str, target_id: str, payload: dict, created_by_username: str, created_by_display_name: str, source: str, ) -> int: con = sqlite3.connect(DB_FILE) cur = con.execute( """ INSERT INTO jobs ( type, target_type, target_id, payload_json, status, created_by_username, created_by_display_name, source ) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?) """, ( job_type, target_type, target_id, json.dumps(payload, ensure_ascii=False), created_by_username, created_by_display_name, source, ), ) job_id = cur.lastrowid con.commit() con.close() return job_id @app.post("/gitea") async def gitea_webhook( request: Request, x_gitea_signature: str | None = Header(default=None), x_hub_signature: str | None = Header(default=None), x_hub_signature_256: str | None = Header(default=None), ): body = await request.body() signature = x_gitea_signature or x_hub_signature_256 or x_hub_signature verify_signature(body, signature) payload = json.loads(body.decode("utf-8")) repo_name = payload.get("repository", {}).get("name") if not repo_name: raise HTTPException(status_code=400, detail="Missing repository name") attr = extract_attribution(payload) ref = payload.get("ref") or "" metadata = { "repository": repo_name, "ref": ref, "commit_sha": attr["commit_sha"], "commit_author": attr["commit_author"], "pusher": attr["pusher"], } if repo_name in IGNORED_REPOSITORIES: audit_event( username=attr["pusher_username"], action="webhook.deploy.ignored", target_type="repository", target_id=repo_name, source="webhook", metadata=metadata, ) return { "status": "ignored", "repo": repo_name, "reason": "repository is not deployable", } if repo_name in CORE_SERVICES: job_type = "deploy_core_service" target_type = "core_service" else: job_type = "deploy_app" target_type = "app" job_id = create_job( job_type=job_type, target_type=target_type, target_id=repo_name, payload=metadata, created_by_username=attr["pusher_username"], created_by_display_name=attr["pusher"], source="webhook", ) audit_event( username=attr["pusher_username"], action="webhook.deploy.queued", target_type=target_type, target_id=repo_name, source="webhook", metadata={ "job_id": job_id, "job_type": job_type, **metadata, }, ) return { "status": "queued", "job_id": job_id, "repo": repo_name, "core": repo_name in CORE_SERVICES, "triggered_by": attr["pusher_username"], "commit_author": attr["commit_author"], }