mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 07:46:55 +00:00
309 lines
8.2 KiB
Go
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,
|
|
})
|
|
}
|