mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 12:14:17 +00:00
368 lines
11 KiB
Go
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,
|
|
})
|
|
}
|