CI / CI (pull_request) Failing after 21m42s
POST /v1/webhooks/forgejo receives Forgejo payloads, verifies HMAC SHA256 signature, formats event into Gotify push notification. Supported events: push, pull_request, issues, issue_comment, pull_request_review_comment, release. Env vars: GOTIFY_URL, GOTIFY_APP_TOKEN, FORGEJO_WEBHOOK_SECRET Secret: gotify-webhook-secret in api namespace
219 lines
6.1 KiB
Go
219 lines
6.1 KiB
Go
package webhook
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
|
|
)
|
|
|
|
// ForgejoHandler receives Forgejo webhook payloads and forwards them to Gotify.
|
|
// Forgejo sends a Gitea-compatible JSON payload with X-Gitea-Signature-256 header.
|
|
type ForgejoHandler struct {
|
|
secret string
|
|
gotify *notification.GotifyClient
|
|
}
|
|
|
|
// NewForgejoHandler creates a handler from environment variables.
|
|
// Required: GOTIFY_URL, GOTIFY_APP_TOKEN
|
|
// Optional: FORGEJO_WEBHOOK_SECRET (if empty, HMAC verification is skipped)
|
|
func NewForgejoHandler() *ForgejoHandler {
|
|
gotifyURL := os.Getenv("GOTIFY_URL")
|
|
appToken := os.Getenv("GOTIFY_APP_TOKEN")
|
|
|
|
var client *notification.GotifyClient
|
|
if gotifyURL != "" && appToken != "" {
|
|
client = notification.NewGotifyClient(gotifyURL, appToken, "")
|
|
}
|
|
|
|
return &ForgejoHandler{
|
|
secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"),
|
|
gotify: client,
|
|
}
|
|
}
|
|
|
|
// ServeHTTP handles POST /v1/webhooks/forgejo
|
|
func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
|
if err != nil {
|
|
http.Error(w, "failed to read body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify HMAC if secret is set
|
|
if h.secret != "" {
|
|
sig := r.Header.Get("X-Gitea-Signature-256")
|
|
if sig == "" {
|
|
sig = r.Header.Get("X-Hub-Signature-256")
|
|
}
|
|
if !h.verifySignature(body, sig) {
|
|
http.Error(w, "invalid signature", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
if h.gotify == nil {
|
|
log.Printf("forgejo webhook received but Gotify not configured (GOTIFY_URL/GOTIFY_APP_TOKEN missing)")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
event := r.Header.Get("X-Gitea-Event")
|
|
if event == "" {
|
|
event = r.Header.Get("X-GitHub-Event")
|
|
}
|
|
|
|
title, message, priority := h.formatMessage(event, body)
|
|
if title == "" {
|
|
// Unhandled event type — ack and ignore
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
msg := notification.GotifyMessage{
|
|
Title: title,
|
|
Message: message,
|
|
Priority: priority,
|
|
}
|
|
|
|
if _, err := h.gotify.SendMessage(msg); err != nil {
|
|
log.Printf("forgejo webhook: failed to send gotify message: %v", err)
|
|
http.Error(w, "failed to send notification", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
log.Printf("forgejo webhook: sent gotify notification event=%s title=%q", event, title)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// verifySignature checks X-Gitea-Signature-256: sha256=<hex>
|
|
func (h *ForgejoHandler) verifySignature(body []byte, sig string) bool {
|
|
sig = strings.TrimPrefix(sig, "sha256=")
|
|
if sig == "" {
|
|
return false
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(h.secret))
|
|
mac.Write(body)
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
return hmac.Equal([]byte(sig), []byte(expected))
|
|
}
|
|
|
|
// formatMessage converts a Forgejo event payload into a Gotify title + message.
|
|
// Returns empty title if the event should be ignored.
|
|
func (h *ForgejoHandler) formatMessage(event string, body []byte) (title, message string, priority int) {
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return "", "", 0
|
|
}
|
|
|
|
repo := jsonStr(payload, "repository", "full_name")
|
|
sender := jsonStr(payload, "sender", "login")
|
|
|
|
switch event {
|
|
case "push":
|
|
ref := strings.TrimPrefix(fmt.Sprintf("%v", payload["ref"]), "refs/heads/")
|
|
commits, _ := payload["commits"].([]interface{})
|
|
count := len(commits)
|
|
commitMsg := ""
|
|
if count > 0 {
|
|
if c, ok := commits[0].(map[string]interface{}); ok {
|
|
commitMsg = fmt.Sprintf("%v", c["message"])
|
|
// truncate long commit messages
|
|
if len(commitMsg) > 80 {
|
|
commitMsg = commitMsg[:80] + "…"
|
|
}
|
|
}
|
|
}
|
|
return fmt.Sprintf("📦 %s", repo),
|
|
fmt.Sprintf("%s pushed %d commit(s) to %s\n%s", sender, count, ref, commitMsg),
|
|
5
|
|
|
|
case "pull_request":
|
|
action := fmt.Sprintf("%v", payload["action"])
|
|
if action != "opened" && action != "closed" && action != "reopened" && action != "merged" {
|
|
return "", "", 0 // ignore noise (labeled, assigned, etc.)
|
|
}
|
|
pr, _ := payload["pull_request"].(map[string]interface{})
|
|
number := fmt.Sprintf("%v", pr["number"])
|
|
prTitle := fmt.Sprintf("%v", pr["title"])
|
|
merged, _ := pr["merged"].(bool)
|
|
if action == "closed" && merged {
|
|
action = "merged"
|
|
}
|
|
return fmt.Sprintf("🔀 PR #%s %s — %s", number, action, repo),
|
|
fmt.Sprintf("%s: %s\nby %s", action, prTitle, sender),
|
|
5
|
|
|
|
case "issues":
|
|
action := fmt.Sprintf("%v", payload["action"])
|
|
if action != "opened" && action != "closed" && action != "reopened" {
|
|
return "", "", 0
|
|
}
|
|
issue, _ := payload["issue"].(map[string]interface{})
|
|
number := fmt.Sprintf("%v", issue["number"])
|
|
issueTitle := fmt.Sprintf("%v", issue["title"])
|
|
return fmt.Sprintf("🐛 Issue #%s %s — %s", number, action, repo),
|
|
fmt.Sprintf("%s: %s\nby %s", action, issueTitle, sender),
|
|
4
|
|
|
|
case "issue_comment", "pull_request_review_comment":
|
|
issue, _ := payload["issue"].(map[string]interface{})
|
|
comment, _ := payload["comment"].(map[string]interface{})
|
|
number := fmt.Sprintf("%v", issue["number"])
|
|
body := fmt.Sprintf("%v", comment["body"])
|
|
if len(body) > 100 {
|
|
body = body[:100] + "…"
|
|
}
|
|
return fmt.Sprintf("💬 Comment on #%s — %s", number, repo),
|
|
fmt.Sprintf("%s: %s", sender, body),
|
|
3
|
|
|
|
case "release":
|
|
action := fmt.Sprintf("%v", payload["action"])
|
|
if action != "published" {
|
|
return "", "", 0
|
|
}
|
|
release, _ := payload["release"].(map[string]interface{})
|
|
tag := fmt.Sprintf("%v", release["tag_name"])
|
|
name := fmt.Sprintf("%v", release["name"])
|
|
return fmt.Sprintf("🚀 Release %s — %s", tag, repo),
|
|
fmt.Sprintf("%s published by %s", name, sender),
|
|
7
|
|
|
|
default:
|
|
return "", "", 0
|
|
}
|
|
}
|
|
|
|
// jsonStr safely traverses nested map keys.
|
|
func jsonStr(m map[string]interface{}, keys ...string) string {
|
|
cur := m
|
|
for i, k := range keys {
|
|
v, ok := cur[k]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if i == len(keys)-1 {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
cur, ok = v.(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
}
|
|
return ""
|
|
}
|