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= 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 "" }