package handlers import ( "encoding/json" "errors" "net/http" "slices" "strconv" "strings" "github.com/go-chi/chi" "github.com/google/uuid" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/signature" ) // 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") // errRetentionTooLarge signals a retention_days form value that is a // whole number but larger than the reaper's cutoff arithmetic can // represent. It is distinguished from errInvalidRetention so the form // can tell the user the actual ceiling instead of implying their input // was not a number. var errRetentionTooLarge = errors.New("retention days out of range") // retentionErrorMessage returns the message the create and edit forms // show the user for a rejected retention_days value. Any error other // than errRetentionTooLarge falls back to the generic wording, so an // unrecognised parse failure still produces a sensible 400 rather than // an empty alert. func retentionErrorMessage(err error) string { if errors.Is(err, errRetentionTooLarge) { return "Retention must be at most " + strconv.Itoa(database.MaxFiniteRetentionDays) + " days, or 0 to retain events forever." } return "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. Anything unparseable // or negative is an error rather than a silently substituted default. // // The upper bound is not cosmetic. The reaper computes its cutoff as a // time.Duration, an int64 nanosecond count, so a day count above // database.MaxFiniteRetentionDays overflows, puts the cutoff in the // future, and deletes every event the webhook has. A finite value // above that ceiling is therefore a 400. // // A value at or above the retain-forever sentinel is not out of range: // it is what the edit form pre-fills for a retain-forever webhook, so // submitting the form back unchanged has to keep meaning "forever" // rather than being rejected. 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 } if v >= database.RetentionForeverDays { return database.RetentionForeverDays, nil } if v > database.MaxFiniteRetentionDays { return 0, errRetentionTooLarge } return v, nil } // DeliveryView is the display-safe projection of a delivery // for the event log page. Its target is a TargetView, so the // stored configuration blob — which holds the target's // credential — has no path to the template. type DeliveryView struct { ID string Status database.DeliveryStatus Target delivery.TargetView // Results is this delivery's attempts in attempt order, // bounded by maxRenderedAttempts. Without them a failure // renders as the status word alone and says nothing about // why. Results []DeliveryResultView // AttemptCount is how many attempts were recorded, which // is more than len(Results) once the middle was dropped. AttemptCount int // AttemptsOmitted is how many attempts were dropped from // the middle of Results. The page must show it, or the // bound would hide history rather than fold it. AttemptsOmitted int } // eventLogTarget is what the event log needs to know about // one target: the display-safe view its template renders, and // the redactor that keeps that target's own credential out of // the text its remote peer chose. The two are kept together // so a caller cannot pick up one without the other, and apart // from TargetView so the secrets never reach a template. type eventLogTarget struct { View delivery.TargetView Redactor delivery.Redactor } // 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. // // It carries the retention default so the pre-filled value comes from // database.DefaultRetentionDays rather than being a third hardcoded // copy of the same policy, and it carries the submitted name and // description so that re-rendering the form after a validation failure // gives the user their input back instead of a blank form. The edit // form already behaves that way; create now matches it. func newSourceFormData( errMsg, name, description string, ) map[string]any { return map[string]any{ tmplKeyError: errMsg, "Name": name, "Description": description, "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 } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err := r.ParseForm() if err != nil { http.Error( w, "Bad request", http.StatusBadRequest, ) return } name := r.PostFormValue("name") description := r.PostFormValue("description") retentionStr := r.PostFormValue("retention_days") if name == "" { w.WriteHeader(http.StatusBadRequest) h.renderTemplate( w, r, "sources_new.html", newSourceFormData( "Name is required", name, description, ), ) return } retentionDays, retErr := parseRetentionDays( retentionStr, database.DefaultRetentionDays, ) if retErr != nil { w.WriteHeader(http.StatusBadRequest) h.renderTemplate( w, r, "sources_new.html", newSourceFormData( retentionErrorMessage(retErr), name, description, ), ) return } 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 } // The template calls Webhook methods, which take pointer // receivers; html/template cannot address a value stored in a map. data := map[string]any{ tmplKeyWebhook: &webhook, // Entrypoints and targets are both projected to // display-safe views: an entrypoint carries the shared // secret its senders sign with and a target's stored // config blob holds a credential, and neither must ever // reach a template. "Entrypoints": NewEntrypointViews(entrypoints), "Targets": delivery.NewTargetViews(targets), "SignatureSchemes": signature.Schemes(), "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{ tmplKeyWebhook: &webhook, tmplKeyError: "", } 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 } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err = r.ParseForm() if err != nil { http.Error( w, "Bad request", http.StatusBadRequest, ) return } h.applyWebhookEdit(w, r, &webhook) } } // applyWebhookEdit validates and saves webhook edits. func (h *Handlers) applyWebhookEdit( w http.ResponseWriter, r *http.Request, webhook *database.Webhook, ) { // The body size cap is enforced by the MaxBodySize middleware, // which runs before CSRF parses the form. name := r.PostFormValue("name") if name == "" { data := map[string]any{ tmplKeyWebhook: webhook, tmplKeyError: "Name is required", } w.WriteHeader(http.StatusBadRequest) h.renderTemplate(w, r, "source_edit.html", data) return } webhook.Name = name webhook.Description = r.PostFormValue("description") // An empty field falls back to the stored value, so submitting the // form without touching retention leaves the policy alone. retentionDays, retErr := parseRetentionDays( r.PostFormValue("retention_days"), webhook.RetentionDays, ) if retErr != nil { data := map[string]any{ tmplKeyWebhook: webhook, tmplKeyError: retentionErrorMessage(retErr), } w.WriteHeader(http.StatusBadRequest) h.renderTemplate(w, r, "source_edit.html", data) return } webhook.RetentionDays = retentionDays err := h.db.DB().Save(webhook).Error if err != nil { h.serverError(w, "failed to update webhook", err) return } http.Redirect( w, r, "/source/"+webhook.ID, http.StatusSeeOther, ) } // 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 } // Release the delivery engine's per-webhook archiving state // so a deleted webhook's archive writer (and any handle open // within its debounce window) does not linger for the // process lifetime. The archive file itself is deliberately // left on disk; see evictArchiveWriter. h.evictArchiveWriter(webhook.ID) 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) } // evictArchiveWriter asks the delivery engine to drop its // cached archive writer for a webhook, closing the archive file // handle. // // The archive database file is NOT deleted. Unlike the event // database — which is per-webhook working storage and is // hard-deleted with the webhook — an archive is explicitly // long-term storage that an operator may want to keep or move // away for offline retention. Destroying it as a side effect of // deleting a webhook would be a surprising and unrecoverable // data loss, so the file is left for the operator to handle. func (h *Handlers) evictArchiveWriter(webhookID string) { if h.evictor == nil { return } h.evictor.EvictWebhook(webhookID) } // evictArchiveWriterIfUnused releases a webhook's archive // writer once the webhook has no database target left to feed // it. // // It is called after any child resource of a webhook is // deleted, and is correct without knowing which kind was: it // evicts only when no database target remains, so deleting one // of several database targets — or deleting an unrelated // target type — leaves a still-needed writer alone. When no // database target ever existed there is no writer and eviction // is a no-op. Soft-deleted targets are excluded by GORM's // default scope, so the row just deleted is not counted. func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) { var remaining int64 err := h.db.DB(). Model(&database.Target{}). Where( "webhook_id = ? AND type = ?", webhookID, database.TargetTypeDatabase, ). Count(&remaining).Error if err != nil { h.log.Error( "failed to count remaining database targets", "webhook_id", webhookID, "error", err, ) return } if remaining > 0 { return } h.evictArchiveWriter(webhookID) } // ownedWebhook resolves the request's sourceID parameter to a // webhook the session's user owns. // // Ownership and existence are decided by one query, so a // webhook belonging to another user is indistinguishable from // one that does not exist: both are a 404, and neither confirms // the id. Callers that reach further into a webhook's data — // the event log page and the event body download — share this // one check rather than restating it, so the download cannot // come to authorize differently from the page that links to it. // // It reports false once it has written the response, which is a // redirect to the login page for an unauthenticated request and // a 404 otherwise. The caller returns without writing more. func (h *Handlers) ownedWebhook( w http.ResponseWriter, r *http.Request, ) (database.Webhook, bool) { var webhook database.Webhook userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return database.Webhook{}, false } sourceID := chi.URLParam(r, "sourceID") err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { http.NotFound(w, r) return database.Webhook{}, false } return webhook, true } // HandleSourceLogs shows the request/response logs for a // webhook. func (h *Handlers) HandleSourceLogs() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { webhook, ok := h.ownedWebhook(w, r) if !ok { return } targets, err := h.loadTargetMap(webhook.ID) if err != nil { // Without the map every delivery renders through a // zero redactor, so failing the page is the only // safe answer. h.serverError(w, "failed to load targets", err) return } page := h.parsePage(r) evts, total, ok := h.loadEventsWithDeliveries( w, webhook, targets, page, ) if !ok { return } totalPages := int(total) / paginationPerPage if int(total)%paginationPerPage != 0 { totalPages++ } data := map[string]any{ tmplKeyWebhook: &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 of display-safe // views keyed by target ID, each paired with its redactor. // The projection happens here so that no caller can hand a // raw target, configuration blob and all, to a template: the // raw rows do not leave this function. // // The load is Unscoped because deleting a target only soft // deletes the row while its deliveries survive in the // per-webhook database: a scoped load leaves those deliveries // with a zero redactor, which renders their response bodies // unredacted. Only the redactor half of the map is built from // deleted rows. The view half, which is what the page lists, // stays scoped. func (h *Handlers) loadTargetMap( webhookID string, ) (map[string]eventLogTarget, error) { var targets []database.Target err := h.db.DB().Unscoped().Where( "webhook_id = ?", webhookID, ).Find(&targets).Error if err != nil { return nil, err } targetMap := make( map[string]eventLogTarget, len(targets), ) live := make([]database.Target, 0, len(targets)) for i := range targets { targetMap[targets[i].ID] = eventLogTarget{ Redactor: delivery.NewRedactor(&targets[i]), } if !targets[i].DeletedAt.Valid { live = append(live, targets[i]) } } // The views come from NewTargetViews rather than being // rebuilt here, so the masking rules stay in one place. for _, v := range delivery.NewTargetViews(live) { entry := targetMap[v.ID] entry.View = v targetMap[v.ID] = entry } return targetMap, nil } // 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. Events come back // as capped projections rather than database.Event rows: see // eventLogColumns for why the cut happens in SQL. // // The bool reports whether the load succeeded. It is false // once this has answered the request with an error, and the // caller must then render nothing further. func (h *Handlers) loadEventsWithDeliveries( w http.ResponseWriter, webhook database.Webhook, targetMap map[string]eventLogTarget, page int, ) ([]EventLogView, int64, bool) { var totalEvents int64 var result []EventLogView if !h.dbMgr.DBExists(webhook.ID) { return result, totalEvents, true } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { h.serverError( w, "failed to get webhook database", err, ) return nil, 0, false } webhookDB.Model(&database.Event{}).Where( "webhook_id = ?", webhook.ID, ).Count(&totalEvents) offset := (page - 1) * paginationPerPage var rows []eventLogRow webhookDB.Model(&database.Event{}).Select( eventLogColumns, maxRenderedBodyBytes, ).Where( "webhook_id = ?", webhook.ID, ).Order("created_at DESC").Offset(offset).Limit( paginationPerPage, ).Find(&rows) result = make([]EventLogView, len(rows)) eventDeliveries := make([][]database.Delivery, len(rows)) var deliveryIDs []string for i := range rows { result[i] = rows[i].view() webhookDB.Where( "event_id = ?", rows[i].ID, ).Find(&eventDeliveries[i]) for j := range eventDeliveries[i] { deliveryIDs = append( deliveryIDs, eventDeliveries[i][j].ID, ) } } attempts, err := h.loadDeliveryResults( webhookDB, deliveryIDs, ) if err != nil { h.serverError( w, "failed to load delivery attempts", err, ) return nil, 0, false } for i := range rows { result[i].Deliveries = newDeliveryViews( eventDeliveries[i], targetMap, attempts, ) } return result, totalEvents, true } // deliveryIDChunkSize bounds how many delivery IDs go into one // IN clause. SQLite refuses a statement carrying more than // SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a // page holds one delivery per target per event, so a webhook // with enough targets would turn the whole query into an error // and the page into zero attempts. const deliveryIDChunkSize = 500 // loadDeliveryResults loads the recorded attempts for the // page's deliveries, keyed by delivery ID. // // Each response body is cut by SQLite rather than in Go, for // the reason deliveryResultColumns gives. How many attempts a // delivery has is the target's MaxRetries, which the // authenticated operator sets; how many of them reach the page // is bounded again by maxRenderedAttempts. func (h *Handlers) loadDeliveryResults( webhookDB *gorm.DB, deliveryIDs []string, ) (map[string][]deliveryResultRow, error) { byDelivery := make(map[string][]deliveryResultRow) for chunk := range slices.Chunk( deliveryIDs, deliveryIDChunkSize, ) { var rows []deliveryResultRow err := webhookDB.Model( &database.DeliveryResult{}, ).Select( deliveryResultColumns, maxRenderedResponseBytes, ).Where( "delivery_id IN ?", chunk, ).Order("attempt_num ASC").Find(&rows).Error if err != nil { // Returning what was loaded so far renders the // deliveries in the failed chunk as never having run, // which is indistinguishable from ones that really // never ran. The page fails instead. return nil, err } for i := range rows { byDelivery[rows[i].DeliveryID] = append( byDelivery[rows[i].DeliveryID], rows[i], ) } } return byDelivery, nil } // newDeliveryViews projects deliveries for rendering, // resolving each one's target to its display-safe view and // each one's attempts through that target's redactor. func newDeliveryViews( deliveries []database.Delivery, targetMap map[string]eventLogTarget, attempts map[string][]deliveryResultRow, ) []DeliveryView { views := make([]DeliveryView, len(deliveries)) for i := range deliveries { target := targetMap[deliveries[i].TargetID] rows := attempts[deliveries[i].ID] results, omitted := renderedAttempts( rows, target.Redactor, ) views[i] = DeliveryView{ ID: deliveries[i].ID, Status: deliveries[i].Status, Target: target.View, Results: results, AttemptCount: len(rows), AttemptsOmitted: omitted, } } return views } // maxRenderedAttempts bounds how many of one delivery's // attempts the page renders. Past it the middle is dropped and // counted, keeping the first attempts and the last ones: how // the delivery started failing and how it ended are what a // reader needs, and the count says plainly that the rest was // dropped rather than never recorded. const ( renderedAttemptsHead = 10 renderedAttemptsTail = 10 maxRenderedAttempts = renderedAttemptsHead + renderedAttemptsTail ) // renderedAttempts projects a delivery's attempts through the // target's redactor, at most maxRenderedAttempts of them, and // reports how many it dropped. func renderedAttempts( rows []deliveryResultRow, redactor delivery.Redactor, ) ([]DeliveryResultView, int) { omitted := 0 if len(rows) > maxRenderedAttempts { omitted = len(rows) - maxRenderedAttempts kept := make( []deliveryResultRow, 0, maxRenderedAttempts, ) kept = append(kept, rows[:renderedAttemptsHead]...) kept = append( kept, rows[len(rows)-renderedAttemptsTail:]..., ) rows = kept } views := make([]DeliveryResultView, len(rows)) for i := range rows { views[i] = rows[i].view(redactor) } return views, omitted } // 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 } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err = r.ParseForm() if err != nil { http.Error( w, "Bad request", http.StatusBadRequest, ) return } description := r.PostFormValue("description") entrypoint := &database.Entrypoint{ WebhookID: webhook.ID, Path: uuid.New().String(), Description: description, Active: true, } err = h.db.DB().Create(entrypoint).Error if err != nil { h.serverError(w, "failed to create entrypoint", err) return } http.Redirect( w, r, "/source/"+webhook.ID, http.StatusSeeOther, ) } } // HandleEntrypointSecret sets, rotates or removes the shared secret // an entrypoint verifies inbound requests with. // // Setting and rotating are the same operation: the form always takes // the secret afresh and the stored value is never sent to the browser // to be edited, so there is no path by which the page can display a // credential it holds. Rotation is therefore "submit the new secret", // and the operator already has that value — both supported senders // require them to enter the same string on the sender's side, so // there is no generated value for webhooker to reveal once. func (h *Handlers) HandleEntrypointSecret() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { webhook, ok := h.ownedWebhook(w, r) if !ok { return } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err := r.ParseForm() if err != nil { http.Error( w, "Bad request", http.StatusBadRequest, ) return } var entrypoint database.Entrypoint err = h.db.DB().Where( "id = ? AND webhook_id = ?", chi.URLParam(r, "entrypointID"), webhook.ID, ).First(&entrypoint).Error if err != nil { http.NotFound(w, r) return } h.applyEntrypointSecret(w, r, &entrypoint) } } // applyEntrypointSecret validates the submitted scheme and secret and // stores them. // // A scheme this build does not support is a 400, never a stored value // the receiver would later have to interpret: the receiver fails such // a row closed, so letting one be created would take the entrypoint // offline through a form that reported success. func (h *Handlers) applyEntrypointSecret( w http.ResponseWriter, r *http.Request, entrypoint *database.Entrypoint, ) { // PostFormValue, not FormValue: a credential must come from the // body. FormValue falls back to the query string, and the request // line — unlike the body — is what logs, proxies, Referer headers // and error trackers record. scheme := database.SignatureScheme( r.PostFormValue("signature_scheme"), ) // Surrounding whitespace is stripped, because a secret pasted from // a password manager routinely carries some and the resulting // mismatch is undiagnosable from the sender's side. A secret whose // own first or last character is a space cannot be stored; the // README says so. secret := strings.TrimSpace(r.PostFormValue("secret")) if !signature.Supported(scheme) { http.Error( w, "Invalid signature scheme", http.StatusBadRequest, ) return } if scheme == database.SignatureSchemeNone { // Turning verification off drops the secret with it: a stored // credential nothing reads is one more copy to leak, and // Verify refuses that pairing in any case. secret = "" } else if secret == "" { http.Error( w, "A shared secret is required for this signature scheme.", http.StatusBadRequest, ) return } h.storeEntrypointSecret(w, r, entrypoint, scheme, secret) } // storeEntrypointSecret writes a validated scheme and secret to an // entrypoint and returns the operator to the webhook page. func (h *Handlers) storeEntrypointSecret( w http.ResponseWriter, r *http.Request, entrypoint *database.Entrypoint, scheme database.SignatureScheme, secret string, ) { // Updates with a map rather than a struct: a struct update skips // zero values, and the empty pair is exactly what has to be // written when verification is being turned off. err := h.db.DB().Model(entrypoint).Updates(map[string]any{ "signature_scheme": scheme, "signature_secret": secret, }).Error if err != nil { // The error is logged by serverError; GORM's error text // carries the statement, not the bound values, so the secret // does not travel with it. h.serverError( w, "failed to update entrypoint signature", err, ) return } h.log.Info( "entrypoint signature configuration updated", "entrypoint_id", entrypoint.ID, "webhook_id", entrypoint.WebhookID, "scheme", string(scheme), ) http.Redirect( w, r, "/source/"+entrypoint.WebhookID, http.StatusSeeOther, ) } // HandleTargetCreate handles adding a new target to a webhook. func (h *Handlers) HandleTargetCreate() 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 } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err = r.ParseForm() if err != nil { http.Error( w, "Bad request", http.StatusBadRequest, ) return } h.processTargetCreate(w, r, webhook) } } // processTargetCreate validates and creates a new target. func (h *Handlers) processTargetCreate( w http.ResponseWriter, r *http.Request, webhook database.Webhook, ) { // The body size cap is enforced by the MaxBodySize middleware, // which runs before CSRF parses the form. // // Every field here is read with PostFormValue, not FormValue. // FormValue falls back to the query string, which would let // `POST /source/{id}/targets?url=https://hooks.slack.com/...` // configure a target from a value the request line carries — and // the request line, unlike the body, is what logs, proxies, // Referer headers and error trackers record. name := r.PostFormValue("name") targetType := database.TargetType(r.PostFormValue("type")) maxRetriesStr := r.PostFormValue("max_retries") if name == "" { http.Error( w, "Name is required", http.StatusBadRequest, ) return } if !isValidTargetType(targetType) { http.Error( w, "Invalid target type", http.StatusBadRequest, ) return } configJSON, err := h.buildTargetConfig( w, r, targetType, targetFormInputFrom(r), ) if err != nil { return } maxRetries := parseNonNegativeInt(maxRetriesStr) target := &database.Target{ WebhookID: webhook.ID, Name: name, Type: targetType, Active: true, Config: configJSON, MaxRetries: maxRetries, } err = h.db.DB().Create(target).Error if err != nil { h.serverError(w, "failed to create target", err) return } http.Redirect( w, r, "/source/"+webhook.ID, http.StatusSeeOther, ) } // isValidTargetType checks whether the target type is supported. func isValidTargetType(tt database.TargetType) bool { switch tt { case database.TargetTypeHTTP, database.TargetTypeDatabase, database.TargetTypeLog, database.TargetTypeSlack: return true default: return false } } // parseNonNegativeInt parses s as a non-negative integer, // returning 0 if s is empty or invalid. func parseNonNegativeInt(s string) int { if s == "" { return 0 } v, err := strconv.Atoi(s) if err == nil && v >= 0 { return v } return 0 } // targetFormInput carries the raw form values describing a target's // configuration. Both the create and the edit path fill one and hand // it to buildTargetConfig, so neither can come to validate a // destination differently from the other. type targetFormInput struct { // URL is the destination for an HTTP target and the webhook URL // for a Slack target. URL string // Headers is an HTTP target's headers, one "Name: value" per // line. Headers string // Timeout is an HTTP target's per-request timeout in seconds. Timeout string // Expiry is a database (archive) target's row expiry. Expiry string } // targetFormInputFrom reads the configuration fields from a request // body. The body size cap is enforced by the MaxBodySize middleware, // which runs before CSRF parses the form. // // Every field is read with PostFormValue, not FormValue. FormValue // falls back to the query string, which would let // `POST /source/{id}/targets?url=https://hooks.slack.com/...` // configure a target from a value the request line carries — and the // request line, unlike the body, is what logs, proxies, Referer // headers and error trackers record. The headers field is under the // same rule and for the same reason: its values are authorization // tokens. func targetFormInputFrom(r *http.Request) targetFormInput { return targetFormInput{ URL: r.PostFormValue("url"), Headers: r.PostFormValue("headers"), Timeout: r.PostFormValue("timeout"), Expiry: r.PostFormValue("expiry"), } } // buildTargetConfig builds the JSON config string for a target from // the submitted form values, writing its own 4xx response on // rejection. Which fields of in apply depends on the target type. func (h *Handlers) buildTargetConfig( w http.ResponseWriter, r *http.Request, targetType database.TargetType, in targetFormInput, ) (string, error) { switch targetType { case database.TargetTypeHTTP: return h.buildHTTPTargetConfig(w, r, in) case database.TargetTypeSlack: return h.buildSlackTargetConfig(w, r, in.URL) case database.TargetTypeDatabase: return h.buildDatabaseTargetConfig(w, in.Expiry) case database.TargetTypeLog: return "", nil default: http.Error( w, "Invalid target type", http.StatusBadRequest, ) return "", errMissingURL } } // buildHTTPTargetConfig builds config JSON for an HTTP target: an // SSRF-validated destination plus the optional headers and timeout // the delivery path honours. func (h *Handlers) buildHTTPTargetConfig( w http.ResponseWriter, r *http.Request, in targetFormInput, ) (string, error) { err := h.validateTargetURL( w, r, in.URL, "URL is required for HTTP targets", ) if err != nil { return "", err } headers, err := delivery.ParseTargetHeaders(in.Headers) if err != nil { http.Error( w, "Invalid headers: "+err.Error(), http.StatusBadRequest, ) return "", err } timeout, err := delivery.ParseTargetTimeout(in.Timeout) if err != nil { http.Error( w, "Invalid timeout: "+err.Error(), http.StatusBadRequest, ) return "", err } return marshalTargetConfig(w, delivery.HTTPTargetConfig{ URL: in.URL, Headers: headers, Timeout: timeout, }) } // buildSlackTargetConfig builds config JSON for a Slack target, // whose whole configuration is one SSRF-validated webhook URL. func (h *Handlers) buildSlackTargetConfig( w http.ResponseWriter, r *http.Request, targetURL string, ) (string, error) { err := h.validateTargetURL( w, r, targetURL, "Webhook URL is required for Slack targets", ) if err != nil { return "", err } return marshalTargetConfig(w, delivery.SlackTargetConfig{ WebhookURL: targetURL, }) } // validateTargetURL rejects an empty or SSRF-blocked destination, // writing the 400 itself. missingMsg is the error shown when no URL // is given. // // It is the single point at which a user-supplied destination enters // the SSRF guard, on create and on edit alike. An edit path that // reached storage without passing through here would reopen the hole // the guard closes. func (h *Handlers) validateTargetURL( w http.ResponseWriter, r *http.Request, targetURL, missingMsg string, ) error { if targetURL == "" { http.Error( w, missingMsg, http.StatusBadRequest, ) return errMissingURL } err := delivery.ValidateTargetURL( r.Context(), targetURL, ) if err != nil { // The submitted URL can be a credential (a Slack // incoming webhook URL is a bearer token), so the log // records only its scheme and host. h.log.Warn( "target URL blocked by SSRF protection", "url", delivery.MaskURL(targetURL), "error", err, ) http.Error( w, "Invalid target URL: "+err.Error(), http.StatusBadRequest, ) return err } return nil } // marshalTargetConfig serialises a target configuration for storage, // writing a 500 itself if it cannot. func marshalTargetConfig( w http.ResponseWriter, cfg any, ) (string, error) { 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 } return marshalTargetConfig( w, map[string]any{"expiry": expiry}, ) } // HandleEntrypointDelete handles deleting an entrypoint. func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { return h.deleteChildResource( "entrypointID", &database.Entrypoint{}, "failed to delete entrypoint", nil, ) } // HandleTargetDelete handles deleting a target. Deleting the // last database target of a webhook leaves its archive writer // with nothing to write, so the writer is evicted and its // handle closed; the archive file is left on disk. func (h *Handlers) HandleTargetDelete() http.HandlerFunc { return h.deleteChildResource( "targetID", &database.Target{}, "failed to delete target", h.evictArchiveWriterIfUnused, ) } // deleteChildResource returns a handler that deletes a child // resource (entrypoint or target) belonging to a webhook. The // optional afterDelete hook runs with the webhook's id once the // delete has succeeded, before the redirect. func (h *Handlers) deleteChildResource( idParam string, model any, errMsg string, afterDelete func(webhookID 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 } if afterDelete != nil { afterDelete(webhook.ID) } 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) }