Files
ForgeBucket/internal/api/handlers/webhooks.go
T
2026-05-11 23:56:45 +02:00

258 lines
7.2 KiB
Go

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)
}()
}
}