package handlers import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/models" ) type WebhookHandler struct{ db *xorm.Engine } func NewWebhookHandler(db *xorm.Engine) *WebhookHandler { return &WebhookHandler{db: db} } type webhookResponse struct { ID int64 `json:"id"` Title string `json:"title"` URL string `json:"url"` Events string `json:"events"` Active bool `json:"active"` HasSecret bool `json:"hasSecret"` LastStatus int `json:"lastStatus"` LastDeliveredAt *string `json:"lastDeliveredAt"` CreatedAt string `json:"createdAt"` } func toWebhookResp(wh models.Webhook) webhookResponse { var last *string if wh.LastDeliveredAt != nil { s := wh.LastDeliveredAt.Format(time.RFC3339) last = &s } return webhookResponse{ ID: wh.ID, Title: wh.Title, URL: wh.URL, Events: wh.Events, Active: wh.Active, HasSecret: wh.Secret != "", LastStatus: wh.LastStatus, LastDeliveredAt: last, CreatedAt: wh.CreatedAt.Format(time.RFC3339), } } func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) { return resolveRepo(h.db, w, r) } func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool { if callerID == repo.OwnerID { return true } var m models.RepoMember found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m) return found } func (h *WebhookHandler) List(w http.ResponseWriter, r *http.Request) { repo, ok := h.resolveRepo(w, r) if !ok { return } var hooks []models.Webhook h.db.Where("repo_id = ?", repo.ID).OrderBy("created_at").Find(&hooks) resp := make([]webhookResponse, len(hooks)) for i, wh := range hooks { resp[i] = toWebhookResp(wh) } jsonOK(w, resp) } func (h *WebhookHandler) Create(w http.ResponseWriter, r *http.Request) { repo, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } var body struct { Title string `json:"title"` URL string `json:"url"` Secret string `json:"secret"` Events string `json:"events"` Active bool `json:"active"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { jsonError(w, "url is required", http.StatusBadRequest) return } if body.Events == "" { body.Events = "push" } wh := &models.Webhook{ RepoID: repo.ID, Title: body.Title, URL: body.URL, Secret: body.Secret, Events: body.Events, Active: body.Active, } if _, err := h.db.Insert(wh); err != nil { jsonError(w, "could not create webhook", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(toWebhookResp(*wh)) } func (h *WebhookHandler) Update(w http.ResponseWriter, r *http.Request) { repo, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } whID, _ := strconv.ParseInt(chi.URLParam(r, "whID"), 10, 64) var wh models.Webhook if found, _ := h.db.Where("id = ? AND repo_id = ?", whID, repo.ID).Get(&wh); !found { jsonError(w, "webhook not found", http.StatusNotFound) return } var body struct { Title string `json:"title"` URL string `json:"url"` Secret string `json:"secret"` Events string `json:"events"` Active bool `json:"active"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid body", http.StatusBadRequest) return } wh.Title = body.Title wh.URL = body.URL if body.Secret != "" { wh.Secret = body.Secret } wh.Events = body.Events wh.Active = body.Active h.db.ID(wh.ID).Cols("title", "url", "secret", "events", "active").Update(&wh) jsonOK(w, toWebhookResp(wh)) } func (h *WebhookHandler) Delete(w http.ResponseWriter, r *http.Request) { repo, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } whID, _ := strconv.ParseInt(chi.URLParam(r, "whID"), 10, 64) h.db.Where("id = ? AND repo_id = ?", whID, repo.ID).Delete(&models.Webhook{}) w.WriteHeader(http.StatusNoContent) } func (h *WebhookHandler) Test(w http.ResponseWriter, r *http.Request) { repo, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } whID, _ := strconv.ParseInt(chi.URLParam(r, "whID"), 10, 64) var wh models.Webhook if found, _ := h.db.Where("id = ? AND repo_id = ?", whID, repo.ID).Get(&wh); !found { jsonError(w, "webhook not found", http.StatusNotFound) return } payload := map[string]interface{}{ "event": "ping", "repository": map[string]interface{}{ "id": repo.ID, "name": repo.Name, }, "zen": "Keep it simple.", } status := deliverWebhook(wh, payload) jsonOK(w, map[string]interface{}{"status": status, "ok": status >= 200 && status < 300}) } // ── delivery ────────────────────────────────────────────────────────────────── func deliverWebhook(wh models.Webhook, payload interface{}) int { body, err := json.Marshal(payload) if err != nil { return 0 } req, err := http.NewRequest("POST", wh.URL, bytes.NewReader(body)) if err != nil { return 0 } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-ForgeBucket-Event", fmt.Sprintf("%v", payload.(map[string]interface{})["event"])) req.Header.Set("X-ForgeBucket-Delivery", strconv.FormatInt(time.Now().UnixNano(), 36)) if wh.Secret != "" { mac := hmac.New(sha256.New, []byte(wh.Secret)) mac.Write(body) req.Header.Set("X-ForgeBucket-Signature-256", "sha256="+hex.EncodeToString(mac.Sum(nil))) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return 0 } defer resp.Body.Close() return resp.StatusCode } // FireWebhooks sends event payloads to all active webhooks for a repo that match the event. // Called from other handlers; runs deliveries in a background goroutine. func FireWebhooks(db *xorm.Engine, repoID int64, event string, payload map[string]interface{}) { var hooks []models.Webhook db.Where("repo_id = ? AND active = ?", repoID, true).Find(&hooks) for _, wh := range hooks { if !strings.Contains(","+wh.Events+",", ","+event+",") { continue } wh := wh // capture payload["event"] = event go func() { status := deliverWebhook(wh, payload) now := time.Now() wh.LastStatus = status wh.LastDeliveredAt = &now db.ID(wh.ID).Cols("last_status", "last_delivered_at").Update(&wh) }() } }