236 lines
5.7 KiB
Python
Executable File
236 lines
5.7 KiB
Python
Executable File
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",
|
|
}
|
|
|
|
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 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.execute(
|
|
"""
|
|
INSERT INTO audit_events (
|
|
username,
|
|
action,
|
|
target_type,
|
|
target_id,
|
|
source,
|
|
metadata,
|
|
created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
""",
|
|
(
|
|
created_by_username,
|
|
"webhook.deploy.queued",
|
|
target_type,
|
|
target_id,
|
|
"webhook",
|
|
json.dumps(
|
|
{
|
|
"job_id": job_id,
|
|
"job_type": job_type,
|
|
**payload,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
),
|
|
)
|
|
|
|
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)
|
|
|
|
if repo_name in CORE_SERVICES:
|
|
job_type = "deploy_core_service"
|
|
target_type = "core_service"
|
|
else:
|
|
job_type = "deploy_app"
|
|
target_type = "app"
|
|
|
|
ref = payload.get("ref") or ""
|
|
job_payload = {
|
|
"repository": repo_name,
|
|
"ref": ref,
|
|
"commit_sha": attr["commit_sha"],
|
|
"commit_author": attr["commit_author"],
|
|
"pusher": attr["pusher"],
|
|
}
|
|
|
|
job_id = create_job(
|
|
job_type=job_type,
|
|
target_type=target_type,
|
|
target_id=repo_name,
|
|
payload=job_payload,
|
|
created_by_username=attr["pusher_username"],
|
|
created_by_display_name=attr["pusher"],
|
|
source="webhook",
|
|
)
|
|
|
|
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"],
|
|
}
|