package handlers import ( "encoding/json" "errors" "net/http" "strconv" "strings" "github.com/go-chi/chi" "github.com/google/uuid" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) // WebhookListItem holds data for the webhook list view. type WebhookListItem struct { database.Webhook EntrypointCount int64 TargetCount int64 EventCount int64 } // errMissingURL signals that a required URL was not provided. var errMissingURL = errors.New("missing URL") // errInvalidRetention signals a retention_days form value that is not // a non-negative whole number. var errInvalidRetention = errors.New("invalid retention days") // retentionErrorMessage is what the create and edit forms show the user // when parseRetentionDays returns errInvalidRetention. const retentionErrorMessage = "Retention must be a whole number of " + "days, or 0 to retain events forever." // parseRetentionDays interprets a retention_days form value. // // An empty value yields fallback, which lets the create path apply the // default and the edit path leave the stored value unchanged. A value // of 0 is returned as 0 and is rewritten to the retain-forever // sentinel by database.Webhook's BeforeSave hook, so no handler needs // to know the sentinel. Anything unparseable or negative is an error // rather than a silently substituted default. func parseRetentionDays(raw string, fallback int) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { return fallback, nil } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return 0, errInvalidRetention } return v, nil } // EventWithDeliveries holds an event and its deliveries. type EventWithDeliveries struct { database.Event Deliveries []database.Delivery } // HandleSourceList shows a list of user's webhooks. func (h *Handlers) HandleSourceList() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } var webhooks []database.Webhook err := h.db.DB().Where( "user_id = ?", userID, ).Order("created_at DESC").Find(&webhooks).Error if err != nil { h.log.Error( "failed to list webhooks", "error", err, ) http.Error( w, "Internal server error", http.StatusInternalServerError, ) return } items := h.buildWebhookListItems(webhooks) data := map[string]any{ "Webhooks": items, } h.renderTemplate(w, r, "sources_list.html", data) } } // buildWebhookListItems builds list items with counts. func (h *Handlers) buildWebhookListItems( webhooks []database.Webhook, ) []WebhookListItem { items := make([]WebhookListItem, len(webhooks)) for i := range webhooks { items[i].Webhook = webhooks[i] h.db.DB().Model(&database.Entrypoint{}).Where( "webhook_id = ?", webhooks[i].ID, ).Count(&items[i].EntrypointCount) h.db.DB().Model(&database.Target{}).Where( "webhook_id = ?", webhooks[i].ID, ).Count(&items[i].TargetCount) if h.dbMgr.DBExists(webhooks[i].ID) { webhookDB, err := h.dbMgr.GetDB( webhooks[i].ID, ) if err == nil { webhookDB.Model( &database.Event{}, ).Count(&items[i].EventCount) } } } return items } // HandleSourceCreate shows the form to create a new webhook. func (h *Handlers) HandleSourceCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.renderTemplate( w, r, "sources_new.html", newSourceFormData(""), ) } } // newSourceFormData builds the template data for the webhook creation // form, carrying the retention default so the pre-filled value comes // from database.DefaultRetentionDays rather than being a third // hardcoded copy of the same policy. func newSourceFormData(errMsg string) map[string]any { return map[string]any{ tmplKeyError: errMsg, "DefaultRetentionDays": database.DefaultRetentionDays, } } // HandleSourceCreateSubmit handles the webhook creation form // submission. func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } r.Body = http.MaxBytesReader( w, r.Body, 1< 1, "HasNext": page < totalPages, "PrevPage": page - 1, "NextPage": page + 1, } h.renderTemplate(w, r, "source_logs.html", data) } } // loadTargetMap loads targets into a map keyed by target ID. func (h *Handlers) loadTargetMap( webhookID string, ) map[string]database.Target { var targets []database.Target h.db.DB().Where( "webhook_id = ?", webhookID, ).Find(&targets) targetMap := make( map[string]database.Target, len(targets), ) for _, t := range targets { targetMap[t.ID] = t } return targetMap } // parsePage extracts a page number from the query string. func (h *Handlers) parsePage(r *http.Request) int { page := 1 if p := r.URL.Query().Get("page"); p != "" { v, err := strconv.Atoi(p) if err == nil && v > 0 { page = v } } return page } // loadEventsWithDeliveries loads paginated events and their // deliveries from the per-webhook database. func (h *Handlers) loadEventsWithDeliveries( w http.ResponseWriter, webhook database.Webhook, targetMap map[string]database.Target, page int, ) ([]EventWithDeliveries, int64) { var totalEvents int64 var result []EventWithDeliveries if !h.dbMgr.DBExists(webhook.ID) { return result, totalEvents } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { h.serverError( w, "failed to get webhook database", err, ) return nil, 0 } webhookDB.Model(&database.Event{}).Where( "webhook_id = ?", webhook.ID, ).Count(&totalEvents) offset := (page - 1) * paginationPerPage var events []database.Event webhookDB.Where( "webhook_id = ?", webhook.ID, ).Order("created_at DESC").Offset(offset).Limit( paginationPerPage, ).Find(&events) result = make([]EventWithDeliveries, len(events)) for i := range events { result[i].Event = events[i] webhookDB.Where( "event_id = ?", events[i].ID, ).Find(&result[i].Deliveries) for j := range result[i].Deliveries { tid := result[i].Deliveries[j].TargetID if target, ok := targetMap[tid]; ok { result[i].Deliveries[j].Target = target } } } return result, totalEvents } // HandleEntrypointCreate handles adding a new entrypoint. func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } sourceID := chi.URLParam(r, "sourceID") var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { http.NotFound(w, r) return } r.Body = http.MaxBytesReader( w, r.Body, 1<= 0 { return v } return 0 } // buildTargetConfig builds the JSON config string for a target. // The expiry form value is read by the caller (which bounds the // request body) and applies to database targets only. func (h *Handlers) buildTargetConfig( w http.ResponseWriter, r *http.Request, targetType database.TargetType, targetURL, expiry string, ) (string, error) { switch targetType { case database.TargetTypeHTTP: return h.buildURLTargetConfig( w, r, targetURL, "url", "URL is required for HTTP targets", ) case database.TargetTypeSlack: return h.buildURLTargetConfig( w, r, targetURL, "webhookUrl", "Webhook URL is required for Slack targets", ) case database.TargetTypeDatabase: return h.buildDatabaseTargetConfig(w, expiry) case database.TargetTypeLog: return "", nil default: http.Error( w, "Invalid target type", http.StatusBadRequest, ) return "", errMissingURL } } // buildURLTargetConfig builds config JSON for a target whose // configuration is a single SSRF-validated URL stored under // configKey. missingMsg is the error shown when no URL is given. func (h *Handlers) buildURLTargetConfig( w http.ResponseWriter, r *http.Request, targetURL, configKey, missingMsg string, ) (string, error) { if targetURL == "" { http.Error( w, missingMsg, http.StatusBadRequest, ) return "", errMissingURL } err := delivery.ValidateTargetURL( r.Context(), targetURL, ) if err != nil { h.log.Warn( "target URL blocked by SSRF protection", "url", targetURL, "error", err, ) http.Error( w, "Invalid target URL: "+err.Error(), http.StatusBadRequest, ) return "", err } cfg := map[string]any{configKey: targetURL} configBytes, err := json.Marshal(cfg) if err != nil { http.Error( w, "Internal server error", http.StatusInternalServerError, ) return "", err } return string(configBytes), nil } // buildDatabaseTargetConfig builds config JSON for a database // (archive) target. The optional expiry (a form value read by // the caller, which bounds the request body) is validated here, // at creation time, so an unparseable value is rejected with a // 400 instead of failing every subsequent delivery. An empty // expiry yields an empty config (the keep-forever default). func (h *Handlers) buildDatabaseTargetConfig( w http.ResponseWriter, expiry string, ) (string, error) { expiry = strings.TrimSpace(expiry) if expiry == "" { return "", nil } err := delivery.ValidateArchiveExpiry(expiry) if err != nil { http.Error( w, "Invalid archive expiry: "+err.Error(), http.StatusBadRequest, ) return "", err } cfg := map[string]any{"expiry": expiry} configBytes, err := json.Marshal(cfg) if err != nil { http.Error( w, "Internal server error", http.StatusInternalServerError, ) return "", err } return string(configBytes), nil } // HandleEntrypointDelete handles deleting an entrypoint. func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { return h.deleteChildResource( "entrypointID", &database.Entrypoint{}, "failed to delete entrypoint", ) } // HandleTargetDelete handles deleting a target. func (h *Handlers) HandleTargetDelete() http.HandlerFunc { return h.deleteChildResource( "targetID", &database.Target{}, "failed to delete target", ) } // deleteChildResource returns a handler that deletes a child // resource (entrypoint or target) belonging to a webhook. func (h *Handlers) deleteChildResource( idParam string, model any, errMsg string, ) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } sourceID := chi.URLParam(r, "sourceID") childID := chi.URLParam(r, idParam) var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { http.NotFound(w, r) return } result := h.db.DB().Where( "id = ? AND webhook_id = ?", childID, webhook.ID, ).Delete(model) if result.Error != nil { h.log.Error(errMsg, "error", result.Error) http.Error( w, "Internal server error", http.StatusInternalServerError, ) return } http.Redirect( w, r, "/source/"+webhook.ID, http.StatusSeeOther, ) } } // HandleEntrypointToggle handles toggling an entrypoint's // active state. func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { return h.toggleChildResource( "entrypointID", func(webhookID, childID string) error { var ep database.Entrypoint err := h.db.DB().Where( "id = ? AND webhook_id = ?", childID, webhookID, ).First(&ep).Error if err != nil { return err } ep.Active = !ep.Active return h.db.DB().Save(&ep).Error }, "failed to toggle entrypoint", ) } // HandleTargetToggle handles toggling a target's active state. func (h *Handlers) HandleTargetToggle() http.HandlerFunc { return h.toggleChildResource( "targetID", func(webhookID, childID string) error { var tgt database.Target err := h.db.DB().Where( "id = ? AND webhook_id = ?", childID, webhookID, ).First(&tgt).Error if err != nil { return err } tgt.Active = !tgt.Active return h.db.DB().Save(&tgt).Error }, "failed to toggle target", ) } // toggleChildResource returns a handler that toggles the active // state of a child resource belonging to a webhook. func (h *Handlers) toggleChildResource( idParam string, toggleFn func(webhookID, childID string) error, errMsg string, ) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } sourceID := chi.URLParam(r, "sourceID") childID := chi.URLParam(r, idParam) var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { http.NotFound(w, r) return } err = toggleFn(webhook.ID, childID) if err != nil { h.log.Error(errMsg, "error", err) http.Error( w, "Internal server error", http.StatusInternalServerError, ) return } http.Redirect( w, r, "/source/"+webhook.ID, http.StatusSeeOther, ) } } // getUserID extracts the user ID from the session. func (h *Handlers) getUserID( r *http.Request, ) (string, bool) { sess, err := h.session.Get(r) if err != nil { return "", false } if !h.session.IsAuthenticated(sess) { return "", false } return h.session.GetUserID(sess) }