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

368 lines
11 KiB
Go

package handlers
import (
"log/slog"
"net/http"
"strconv"
"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
HasQuota bool
Threshold int // quota threshold percent, 0 if HasQuota is false
}
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
flags := 0
if r.FormValue("trigger_push") == "on" {
flags |= webhooks.TriggerPush
}
if r.FormValue("trigger_first") == "on" {
flags |= webhooks.TriggerFirst
}
if r.FormValue("trigger_all") == "on" {
flags |= webhooks.TriggerAll
}
if r.FormValue("trigger_changed") == "on" {
flags |= webhooks.TriggerChanged
}
thresholdPct := 0
if r.FormValue("trigger_quota") == "on" {
flags |= webhooks.TriggerQuota
rawThreshold := strings.TrimSpace(r.FormValue("quota_threshold"))
if rawThreshold == "" {
h.renderWebhookError(w, "Quota threshold is required when the quota trigger is enabled")
return
}
n, err := strconv.Atoi(rawThreshold)
if err != nil || n < 1 || n > 100 {
h.renderWebhookError(w, "Quota threshold must be a whole number between 1 and 100")
return
}
thresholdPct = n
}
if flags == 0 {
flags = webhooks.TriggerFirst // default
}
triggers := webhooks.PackTriggers(flags, thresholdPct)
// Tier enforcement
limits := h.getWebhookLimits(user.DID)
// Dedupe: refuse to add a second webhook with the same URL for this user.
// A duplicate is almost always an accidental double-submit and creates
// confusing behavior (same payload fires twice, separate delete buttons).
existing, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
if err != nil {
h.renderWebhookError(w, "Failed to check existing webhooks")
return
}
for _, ex := range existing {
if ex.URL == webhookURL {
h.renderWebhookError(w, "A webhook with this URL is already configured")
return
}
}
if limits.Max >= 0 && len(existing) >= limits.Max {
h.renderWebhookError(w, "Webhook limit reached")
return
}
// Trigger bitmask enforcement: free users can only set TriggerFirst,
// TriggerPush, and TriggerQuota. Check only the flag bits — the packed
// threshold percent (bits 8-15) must not be compared as a "trigger".
freeMask := webhooks.TriggerFirst | webhooks.TriggerPush | webhooks.TriggerQuota
if !limits.AllTriggers && webhooks.TriggerFlags(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.
// When the billing manager is absent or disabled we treat the deployment as
// "all features free": unlimited webhooks and all trigger types allowed.
// Without this, self-hosted instances without billing config silently capped
// users at 1 webhook with restricted triggers.
func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits {
if h.BillingManager == nil || !h.BillingManager.Enabled() {
return webhookLimits{Max: -1, AllTriggers: true}
}
limits := webhookLimits{Max: 1}
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 {
flags := webhooks.TriggerFlags(wh.Triggers)
entries[i] = webhookEntry{
ID: wh.ID,
Triggers: wh.Triggers,
URL: wh.URL,
HasSecret: wh.HasSecret,
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
HasPush: flags&webhooks.TriggerPush != 0,
HasFirst: flags&webhooks.TriggerFirst != 0,
HasAll: flags&webhooks.TriggerAll != 0,
HasChanged: flags&webhooks.TriggerChanged != 0,
HasQuota: flags&webhooks.TriggerQuota != 0,
Threshold: webhooks.ThresholdPct(wh.Triggers),
}
}
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
FormName string // form field name, e.g. "trigger_push" — set so templates don't need a ternary
Bit int
Label string
Description string
AlwaysAvailable bool // Available to free-tier users
DefaultChecked bool // Checked by default in the form
// NeedsThreshold is true for triggers that pair with a configurable
// percent threshold (currently just quota). The template renders an
// inline number input next to the checkbox; the handler parses
// ThresholdFormName into the quota_threshold field.
NeedsThreshold bool
ThresholdFormName string
ThresholdDefault int
}
// webhookTriggerInfo returns the canonical list of webhook trigger types.
// FormName is the HTML form field name (kept in sync with handler parsing).
func webhookTriggerInfo() []triggerInfo {
return []triggerInfo{
{Name: "push", FormName: "trigger_push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true},
{Name: "scan:first", FormName: "trigger_first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true},
{Name: "scan:all", FormName: "trigger_all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"},
{Name: "scan:changed", FormName: "trigger_changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"},
{
Name: "quota",
FormName: "trigger_quota",
Bit: webhooks.TriggerQuota,
Label: "Storage quota",
Description: "When your hold storage crosses the threshold percent. Fires once per crossing; re-arms when usage drops below.",
AlwaysAvailable: true,
NeedsThreshold: true,
ThresholdFormName: "quota_threshold",
ThresholdDefault: 90,
},
}
}
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,
})
}