Branch restrictions — fully enforced:

CRUD rules with pattern (exact or glob like release/*), requirePR, blockForcePush, bypass user list
Enforcement via pkt-line parsing inside the git HTTP handler — before any data reaches git http-backend, each ref update is extracted and checked against stored rules
Direct push to main with requirePR: true → 403 with message; push to unprotected branches still works
Inline checkboxes in the UI update rules immediately
Branching model — stored config:

GET/PUT per repo, defaults to feature/bugfix/release/hotfix prefixes
Toggle enabled/disabled, custom prefix per type with live preview
No enforcement (naming guide only, as Bitbucket does)
Merge strategies — enforced in PR merge endpoint:

GET/PUT per repo, defaults all three allowed
Merge handler now accepts strategy: "merge"|"squash"|"rebase" in request body, checks against stored policy
Disallowed strategy → 409 with clear error; allowed strategy → merges and fires pull_request webhook
Must have at least one strategy enabled (validated server-side)
Webhooks — full delivery with HMAC:

CRUD with title, URL, secret (optional), events (push/pull_request/issue), active toggle
Test button sends live HTTP POST to the configured URL and shows status code in UI
FireWebhooks() fires asynchronously from PR merge and can be called from any handler
X-ForgeBucket-Signature-256: sha256=<hmac> header when secret is set
Last delivery status and timestamp stored on webhook record and shown in list
This commit is contained in:
2026-05-07 15:27:48 +02:00
parent 53aa5cbbf5
commit f211cfc7db
11 changed files with 1438 additions and 4 deletions
+269
View File
@@ -0,0 +1,269 @@
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) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
}
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)
}()
}
}