258 lines
7.2 KiB
Go
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)
|
|
}()
|
|
}
|
|
}
|