mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
The webhook limit was only checked at creation, so losing entitlement (a
hold switch or a plan downgrade) left previously-created webhooks firing
paid behavior forever.
- Dispatcher takes a WebhookLimiter, consulted on every dispatch. It
caps the list to the current allowance, keeping the oldest N to match
what the creation gate would have permitted, and masks paid trigger
bits.
- GetWebhooksForUser orders by created_at ASC, id ASC so that cap is
deterministic. ListWebhooks gets the same tiebreak: it feeds the
settings UI, and without it the list a user sees could disagree with
the one the dispatcher truncates.
- webhooks.FreeTriggerMask is shared by the creation gate and the
dispatch backstop so the two cannot drift.
Capping is logged when it actually truncates. The webhooks stay visible in
settings, so from the user's side delivery would otherwise just stop with
no signal — and the same line is the only evidence if the limiter itself
degraded, since a billing lookup failure falls back to free-tier limits
and would quietly demote a paying user mid-dispatch.
Two cost fixes, both because this puts the entitlement lookup on a hot
path it was never on before:
findCustomerByDID now consults the customer cache instead of always
issuing a Stripe customer search. GetWebhookLimits reaches it via
GetSubscriptionInfo on every delivery, so uncached it meant a
rate-limited Search API call for every push and every scan record of
every user with a webhook configured.
DispatchForQuota checks whether the user has any quota webhook at all
before fetching the allowance. The original code filtered first precisely
so the common path (no quota webhooks) did no work; taking the allowance
up front would have spent the expensive lookup on every push. The cap
itself is still computed over the full list, since the count limit spans
all webhook types.
Note DeliverTest is deliberately not capped: it is an explicit,
user-initiated "send test" from the settings page, not automatic delivery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
367 lines
11 KiB
Go
367 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 the free trigger
|
|
// flags. Check only the flag bits — the packed threshold percent (bits 8-15)
|
|
// must not be compared as a "trigger". Same mask the dispatch gate uses.
|
|
if !limits.AllTriggers && webhooks.TriggerFlags(triggers) & ^webhooks.FreeTriggerMask != 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,
|
|
})
|
|
}
|