package handlers import ( "encoding/json" "errors" "net/http" "strconv" "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") // 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) { data := map[string]any{ "Error": "", } h.renderTemplate(w, r, "sources_new.html", data) } } // 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< 0 { retentionDays = v } } h.createWebhookWithEntrypoint( w, r, userID, name, description, retentionDays, ) } } // createWebhookWithEntrypoint creates a webhook and its default // entrypoint in a transaction. func (h *Handlers) createWebhookWithEntrypoint( w http.ResponseWriter, r *http.Request, userID, name, description string, retentionDays int, ) { webhook := &database.Webhook{ UserID: userID, Name: name, Description: description, RetentionDays: retentionDays, } err := h.commitWebhook(webhook) if err != nil { h.serverError(w, "failed to create webhook", err) return } err = h.dbMgr.CreateDB(webhook.ID) if err != nil { h.log.Error( "failed to create webhook event database", "webhook_id", webhook.ID, "error", err, ) } h.log.Info("webhook created", "webhook_id", webhook.ID, "name", name, "user_id", userID, ) http.Redirect( w, r, "/source/"+webhook.ID, http.StatusSeeOther, ) } // commitWebhook creates a webhook and default entrypoint in // a transaction. Returns an error on failure (rolls back). func (h *Handlers) commitWebhook( webhook *database.Webhook, ) error { tx := h.db.DB().Begin() if tx.Error != nil { return tx.Error } err := tx.Create(webhook).Error if err != nil { tx.Rollback() return err } entrypoint := &database.Entrypoint{ WebhookID: webhook.ID, Path: uuid.New().String(), Description: "Default entrypoint", Active: true, } err = tx.Create(entrypoint).Error if err != nil { tx.Rollback() return err } return tx.Commit().Error } // HandleSourceDetail shows details for a specific webhook. func (h *Handlers) HandleSourceDetail() 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 } h.renderSourceDetail(w, r, webhook) } } // renderSourceDetail loads and renders a source detail page. func (h *Handlers) renderSourceDetail( w http.ResponseWriter, r *http.Request, webhook database.Webhook, ) { var entrypoints []database.Entrypoint h.db.DB().Where( "webhook_id = ?", webhook.ID, ).Find(&entrypoints) var targets []database.Target h.db.DB().Where( "webhook_id = ?", webhook.ID, ).Find(&targets) var events []database.Event if h.dbMgr.DBExists(webhook.ID) { webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID) if dbErr == nil { webhookDB.Where( "webhook_id = ?", webhook.ID, ).Order("created_at DESC").Limit( recentEventLimit, ).Find(&events) } } host := r.Host scheme := "https" if r.TLS == nil { scheme = "http" } if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" { scheme = fwdProto } data := map[string]any{ "Webhook": webhook, "Entrypoints": entrypoints, "Targets": targets, "Events": events, "BaseURL": scheme + "://" + host, } h.renderTemplate(w, r, "source_detail.html", data) } // HandleSourceEdit shows the form to edit a webhook. func (h *Handlers) HandleSourceEdit() 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 } data := map[string]any{ "Webhook": webhook, "Error": "", } h.renderTemplate(w, r, "source_edit.html", data) } } // HandleSourceEditSubmit handles the webhook edit form // submission. func (h *Handlers) HandleSourceEditSubmit() 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 { webhook.RetentionDays = v } } // HandleSourceDelete handles webhook deletion. func (h *Handlers) HandleSourceDelete() 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 } h.deleteWebhookResources(w, r, webhook, userID) } } // deleteWebhookResources soft-deletes config and hard-deletes // the per-webhook event database. func (h *Handlers) deleteWebhookResources( w http.ResponseWriter, r *http.Request, webhook database.Webhook, userID string, ) { tx := h.db.DB().Begin() if tx.Error != nil { h.log.Error( "failed to begin transaction", "error", tx.Error, ) http.Error( w, "Internal server error", http.StatusInternalServerError, ) return } tx.Where( "webhook_id = ?", webhook.ID, ).Delete(&database.Entrypoint{}) tx.Where( "webhook_id = ?", webhook.ID, ).Delete(&database.Target{}) tx.Delete(&webhook) err := tx.Commit().Error if err != nil { h.log.Error( "failed to commit deletion", "error", err, ) http.Error( w, "Internal server error", http.StatusInternalServerError, ) return } err = h.dbMgr.DeleteDB(webhook.ID) if err != nil { h.log.Error( "failed to delete webhook event database", "webhook_id", webhook.ID, "error", err, ) } h.log.Info( "webhook deleted", "webhook_id", webhook.ID, "user_id", userID, ) http.Redirect(w, r, "/sources", http.StatusSeeOther) } // HandleSourceLogs shows the request/response logs for a // webhook. func (h *Handlers) HandleSourceLogs() 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 } targets := h.loadTargetMap(webhook.ID) page := h.parsePage(r) evts, total := h.loadEventsWithDeliveries( w, webhook, targets, page, ) totalPages := int(total) / paginationPerPage if int(total)%paginationPerPage != 0 { totalPages++ } data := map[string]any{ "Webhook": webhook, "Events": evts, "Page": page, "TotalPages": totalPages, "TotalEvents": total, "HasPrev": page > 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. func (h *Handlers) buildTargetConfig( w http.ResponseWriter, r *http.Request, targetType database.TargetType, targetURL string, ) (string, error) { switch targetType { case database.TargetTypeHTTP: return h.buildHTTPTargetConfig(w, r, targetURL) case database.TargetTypeSlack: return h.buildSlackTargetConfig(w, targetURL) case database.TargetTypeDatabase, database.TargetTypeLog: return "", nil default: http.Error( w, "Invalid target type", http.StatusBadRequest, ) return "", errMissingURL } } // buildHTTPTargetConfig builds config JSON for an HTTP target. func (h *Handlers) buildHTTPTargetConfig( w http.ResponseWriter, r *http.Request, targetURL string, ) (string, error) { if targetURL == "" { http.Error( w, "URL is required for HTTP targets", 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{"url": targetURL} configBytes, err := json.Marshal(cfg) if err != nil { http.Error( w, "Internal server error", http.StatusInternalServerError, ) return "", err } return string(configBytes), nil } // buildSlackTargetConfig builds config JSON for a Slack target. func (h *Handlers) buildSlackTargetConfig( w http.ResponseWriter, targetURL string, ) (string, error) { if targetURL == "" { http.Error( w, "Webhook URL is required for Slack targets", http.StatusBadRequest, ) return "", errMissingURL } cfg := map[string]any{"webhookUrl": targetURL} 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) }