Files
at-container-registry/pkg/appview/handlers/webhooks.go
T

309 lines
8.2 KiB
Go

package handlers
import (
"log/slog"
"net/http"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/webhooks"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// webhookEntry is the template data for displaying a webhook
type webhookEntry struct {
ID string
Triggers int
URL string
HasSecret bool
CreatedAt string
// Computed fields from bitmask
HasPush bool
HasFirst bool
HasAll bool
HasChanged bool
}
type webhookLimits struct {
Max int
AllTriggers bool
PaidTierName string // Name of the first tier that enables all triggers
}
// WebhooksHandler returns the webhooks list partial via HTMX
type WebhooksHandler struct {
BaseUIHandler
}
func (h *WebhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
if err != nil {
slog.Warn("Failed to list webhooks", "error", err)
h.renderWebhookError(w, "Failed to load webhooks")
return
}
// Get tier limits from billing manager
limits := h.getWebhookLimits(user.DID)
h.renderWebhookList(w, webhookList, limits)
}
// AddWebhookHandler handles adding a new webhook via form POST
type AddWebhookHandler struct {
BaseUIHandler
}
func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
webhookURL := r.FormValue("url")
secret := r.FormValue("secret")
if webhookURL == "" {
h.renderWebhookError(w, "URL is required")
return
}
// Validate URL scheme
if !strings.HasPrefix(webhookURL, "https://") && !strings.HasPrefix(webhookURL, "http://") {
h.renderWebhookError(w, "Invalid webhook URL: must be https")
return
}
// Parse trigger checkboxes
triggers := 0
if r.FormValue("trigger_push") == "on" {
triggers |= webhooks.TriggerPush
}
if r.FormValue("trigger_first") == "on" {
triggers |= webhooks.TriggerFirst
}
if r.FormValue("trigger_all") == "on" {
triggers |= webhooks.TriggerAll
}
if r.FormValue("trigger_changed") == "on" {
triggers |= webhooks.TriggerChanged
}
if triggers == 0 {
triggers = webhooks.TriggerFirst // default
}
// Tier enforcement
limits := h.getWebhookLimits(user.DID)
// Check webhook count limit
count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID)
if err != nil {
h.renderWebhookError(w, "Failed to check webhook count")
return
}
if limits.Max >= 0 && count >= limits.Max {
h.renderWebhookError(w, "Webhook limit reached")
return
}
// Trigger bitmask enforcement: free users can only set TriggerFirst and TriggerPush
freeMask := webhooks.TriggerFirst | webhooks.TriggerPush
if !limits.AllTriggers && triggers & ^freeMask != 0 {
h.renderWebhookError(w, "Additional trigger types require a paid plan")
return
}
// Create webhook
webhook := &db.Webhook{
ID: uuid.New().String(),
UserDID: user.DID,
URL: webhookURL,
Secret: secret,
Triggers: triggers,
CreatedAt: time.Now(),
}
if err := db.InsertWebhook(h.DB, webhook); err != nil {
slog.Warn("Failed to insert webhook", "error", err)
h.renderWebhookError(w, "Failed to add webhook")
return
}
// Re-render the full list
h.refetchAndRender(w, user)
}
// DeleteWebhookHandler handles deleting a webhook
type DeleteWebhookHandler struct {
BaseUIHandler
}
func (h *DeleteWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
id := chi.URLParam(r, "id")
if id == "" {
h.renderWebhookError(w, "Missing webhook ID")
return
}
if err := db.DeleteWebhook(h.DB, id, user.DID); err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not owned") {
h.renderWebhookError(w, "Webhook not found")
} else {
h.renderWebhookError(w, "Failed to delete webhook")
}
return
}
// Re-render the full list
h.refetchAndRender(w, user)
}
// TestWebhookHandler sends a test payload
type TestWebhookHandler struct {
BaseUIHandler
}
func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderWebhookError(w, "Authentication required")
return
}
id := chi.URLParam(r, "id")
if id == "" {
h.renderWebhookError(w, "Missing webhook ID")
return
}
if h.WebhookDispatcher == nil {
h.renderAlert(w, "error", "Webhooks not configured")
return
}
success, err := h.WebhookDispatcher.DeliverTest(r.Context(), id, user.DID, user.Handle)
if err != nil {
h.renderAlert(w, "error", "Webhook not found or unauthorized")
return
}
if success {
h.renderAlert(w, "success", "Test webhook delivered successfully!")
} else {
h.renderAlert(w, "error", "Test delivery failed - check the webhook URL")
}
}
// ---- Shared helpers ----
// getWebhookLimits returns the webhook limits for a user based on their billing tier.
func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits {
limits := webhookLimits{Max: 1}
if h.BillingManager != nil {
if h.BillingManager.Enabled() {
limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID)
}
limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers()
}
return limits
}
func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) {
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
if err != nil {
h.renderWebhookError(w, "Failed to refresh webhook list")
return
}
limits := h.getWebhookLimits(user.DID)
h.renderWebhookList(w, webhookList, limits)
}
func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) {
w.Header().Set("Content-Type", "text/html")
// Convert DB webhooks to template entries with computed trigger fields
entries := make([]webhookEntry, len(dbWebhooks))
for i, wh := range dbWebhooks {
entries[i] = webhookEntry{
ID: wh.ID,
Triggers: wh.Triggers,
URL: wh.URL,
HasSecret: wh.HasSecret,
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
HasPush: wh.Triggers&webhooks.TriggerPush != 0,
HasFirst: wh.Triggers&webhooks.TriggerFirst != 0,
HasAll: wh.Triggers&webhooks.TriggerAll != 0,
HasChanged: wh.Triggers&webhooks.TriggerChanged != 0,
}
}
templateData := struct {
Webhooks []webhookEntry
Limits webhookLimits
ContainerID string
TriggerInfo []triggerInfo
}{
Webhooks: entries,
Limits: limits,
ContainerID: "webhooks-content",
TriggerInfo: webhookTriggerInfo(),
}
if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil {
slog.Error("Failed to render webhooks template", "error", err)
h.renderWebhookError(w, "Failed to render template")
}
}
type triggerInfo struct {
Name string
Bit int
Label string
Description string
AlwaysAvailable bool // Available to free-tier users
DefaultChecked bool // Checked by default in the form
}
// webhookTriggerInfo returns the canonical list of webhook trigger types.
func webhookTriggerInfo() []triggerInfo {
return []triggerInfo{
{Name: "push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true},
{Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true},
{Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"},
{Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"},
}
}
func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html")
_ = h.Templates.ExecuteTemplate(w, "alert", map[string]string{
"Type": "error",
"Message": message,
})
}
func (h *BaseUIHandler) renderAlert(w http.ResponseWriter, alertType, message string) {
w.Header().Set("Content-Type", "text/html")
_ = h.Templates.ExecuteTemplate(w, "alert", map[string]string{
"Type": alertType,
"Message": message,
})
}