diff --git a/internal/server/router.go b/internal/server/router.go index 4937e4b..c3fcd0f 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -4,6 +4,7 @@ import ( "net/http" "forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter" + "forgejo.riotpiao.com/rock/homelab-frontend/internal/webhook" ) // Router implements an HTTP handler that routes health endpoints, @@ -14,6 +15,7 @@ type Router struct { dispatcher *serviceadapter.Dispatcher temporalHandler http.Handler upstreamHandler http.Handler + forgejoWebhook *webhook.ForgejoHandler } // NewRouter creates a new router with health endpoints. @@ -23,10 +25,11 @@ type Router struct { // All other paths are passed to the upstream handler. func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatcher, temporalHandler http.Handler, upstreamHandler http.Handler) *Router { return &Router{ - healthChecker: healthChecker, - dispatcher: dispatcher, + healthChecker: healthChecker, + dispatcher: dispatcher, temporalHandler: temporalHandler, upstreamHandler: upstreamHandler, + forgejoWebhook: webhook.NewForgejoHandler(), } } @@ -57,6 +60,12 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { } } + // Forgejo webhook receiver — no auth, HMAC-verified by handler + if req.URL.Path == "/v1/webhooks/forgejo" { + r.forgejoWebhook.ServeHTTP(w, req) + return + } + // Workflow endpoints // DEPRECATED: Path-based /workflow routing is legacy. // New clients should use X-Service: workflow header instead for consistent auth. diff --git a/internal/webhook/forgejo.go b/internal/webhook/forgejo.go new file mode 100644 index 0000000..fd8ee43 --- /dev/null +++ b/internal/webhook/forgejo.go @@ -0,0 +1,218 @@ +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 "" +} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 332d74e..94161e0 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -76,6 +76,22 @@ spec: value: "1.0.0" - name: OTEL_ENVIRONMENT value: "production" + # Gotify integration — Forgejo webhook → push notifications + - name: GOTIFY_URL + valueFrom: + secretKeyRef: + name: gotify-webhook-secret + key: gotify-url + - name: GOTIFY_APP_TOKEN + valueFrom: + secretKeyRef: + name: gotify-webhook-secret + key: gotify-app-token + - name: FORGEJO_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: gotify-webhook-secret + key: forgejo-webhook-secret volumeMounts: - name: config mountPath: /etc/gateway