package handlers import ( "context" "encoding/json" "errors" "fmt" "net/http" "slices" "strconv" "strings" "time" "github.com/dustin/go-humanize" "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/reqtls" ) // WebhookListItem holds data for the webhook list view. type WebhookListItem struct { database.Webhook EntrypointCount int InactiveEntrypointCount int TargetCount int InactiveTargetCount int // EventCount is how many events the webhook holds, LastEventAt // when the newest arrived (nil before the first), and // FailedLast24Hours how many of its deliveries failed in the last // 24 hours. When the webhook's event database could not be read, // EventsUnreadable is set and these three are not known. EventCount int64 LastEventAt *time.Time FailedLast24Hours int64 EventsUnreadable bool } // parseRetentionDays interprets a retention_days form value. It // returns the number of days, or, for a value it refuses, the message // the create and edit forms show; the message is empty when the value // is accepted. // // 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 refused rather than silently given a 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 refused, and the message names the // ceiling rather than implying the input was not a number. // // 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, string) { raw = strings.TrimSpace(raw) if raw == "" { return fallback, "" } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return 0, "Retention must be a whole number of days, or 0 to " + "retain events forever." } if v >= database.RetentionForeverDays { return database.RetentionForeverDays, "" } if v > database.MaxFiniteRetentionDays { return 0, "Retention must be at most " + strconv.Itoa(database.MaxFiniteRetentionDays) + " days, or 0 to retain events forever." } return v, "" } // 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 // Replay is set on a delivery the Replay action created. Replay bool // Created is how long ago the delivery was created, and // CreatedUTC the full timestamp the page shows on hover. Created string CreatedUTC string // 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 // Paused is set while the delivery is retrying and its // target's circuit breaker is open, and nil otherwise. Paused *PausedView } // 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.serverError(w, r, "failed to list webhooks", err) return } items, err := h.buildWebhookListItems(webhooks) if err != nil { h.serverError(w, r, "failed to list webhooks", err) return } data := map[string]any{ "Webhooks": items, } h.renderTemplate(w, r, "sources_list.html", data) } } // buildWebhookListItems builds the list's entry for each webhook. It // fails when the main database cannot be read. A webhook whose event // database cannot be read is marked on its own entry, and the error is // logged. func (h *Handlers) buildWebhookListItems( webhooks []database.Webhook, ) ([]WebhookListItem, error) { items := make([]WebhookListItem, len(webhooks)) since := time.Now().Add(-longWindow) for i := range webhooks { item := &items[i] item.Webhook = webhooks[i] var err error item.EntrypointCount, item.InactiveEntrypointCount, err = h.countWithInactive(&database.Entrypoint{}, item.ID) if err != nil { return nil, err } item.TargetCount, item.InactiveTargetCount, err = h.countWithInactive(&database.Target{}, item.ID) if err != nil { return nil, err } // Opening an event database that does not exist would create // it, and it would hold nothing to count. if !h.dbMgr.DBExists(item.ID) { continue } err = h.readListEventFigures(item, since) if err != nil { h.log.Error( "failed to read webhook list figures", "webhook_id", item.ID, "error", err, ) item.EventsUnreadable = true } } return items, nil } // countWithInactive returns how many entrypoints or targets, as model // says, a webhook has, and how many of them are inactive. func (h *Handlers) countWithInactive( model any, webhookID string, ) (int, int, error) { var active []bool err := h.db.DB().Model(model). Where("webhook_id = ?", webhookID). Pluck("active", &active).Error if err != nil { return 0, 0, fmt.Errorf( "reading active flags of webhook %s: %w", webhookID, err, ) } inactive := 0 for _, a := range active { if !a { inactive++ } } return len(active), inactive, nil } // readListEventFigures fills in the figures the list shows from the // webhook's event database, with the statistics pane's own queries: // the event count and last arrival from the event totals row, and the // deliveries that failed since the given time from the deliveries' // status index. func (h *Handlers) readListEventFigures( item *WebhookListItem, since time.Time, ) error { webhookDB, err := h.dbMgr.GetDB(item.ID) if err != nil { return err } var totals database.EventTotals err = webhookDB.Take(&totals).Error if err != nil { return fmt.Errorf("reading event totals: %w", err) } item.EventCount = totals.Events - totals.EventsRemoved item.LastEventAt = totals.LastEventAt byTarget, err := finishedByTarget(webhookDB, since) if err != nil { return err } for _, f := range byTarget { item.FailedLast24Hours += f.Failed } return nil } // 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("", sourceFormInput{ RetentionDays: strconv.Itoa( database.DefaultRetentionDays, ), }), ) } } // sourceFormInput carries the raw values of the new webhook form. A // refused submission is shown again from it, so every value entered // comes back, retention included. type sourceFormInput struct { Name string Description string RetentionDays string // HTTPURL, when not empty, asks for an HTTP target with this // destination. HTTPURL string // Archive asks for a database (archive) target, whose rows expire // after ArchiveExpiry and whose files rotate by ArchiveRotation. Archive bool ArchiveExpiry string ArchiveRotation string } // newSourceFormData builds the template data for the webhook creation // form. It carries the retention default, which the form's help text // names, from database.DefaultRetentionDays rather than a hardcoded // copy of the same policy. func newSourceFormData( errMsg string, in sourceFormInput, ) map[string]any { return map[string]any{ tmplKeyError: errMsg, "Form": in, "DefaultRetentionDays": database.DefaultRetentionDays, tmplKeyArchiveExpiryChoices: archiveExpiryOptions( in.ArchiveExpiry, ), tmplKeyArchiveRotationChoices: archiveRotationOptions( in.ArchiveRotation, ), } } // 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 { h.renderError(w, r, http.StatusBadRequest) return } in := sourceFormInput{ Name: r.PostFormValue("name"), Description: r.PostFormValue("description"), RetentionDays: r.PostFormValue("retention_days"), HTTPURL: r.PostFormValue("http_url"), Archive: r.PostFormValue("archive") != "", ArchiveExpiry: r.PostFormValue("archive_expiry"), ArchiveRotation: r.PostFormValue("archive_rotation"), } refuse := func(errMsg string) { h.renderTemplateStatus( w, r, "sources_new.html", newSourceFormData(errMsg, in), http.StatusBadRequest, ) } if in.Name == "" { refuse("Name is required") return } retentionDays, errMsg := parseRetentionDays( in.RetentionDays, database.DefaultRetentionDays, ) if errMsg != "" { refuse(errMsg) return } targets, errMsg, err := h.newWebhookTargets(r.Context(), in) if err != nil { h.serverError(w, r, "failed to encode target config", err) return } if errMsg != "" { refuse(errMsg) return } h.createWebhookWithEntrypoint(w, r, &database.Webhook{ UserID: userID, Name: in.Name, Description: in.Description, RetentionDays: retentionDays, }, targets) } } // newWebhookTargets validates the targets the new webhook form asks // for and returns the rows to create with the webhook, or the message // the form shows for the first one it refuses. A filled-in HTTP URL // asks for an HTTP target named "HTTP", and the archive checkbox for a // database target named "Archive". Each goes through newTarget, as on // the webhook page's add target form. The rows have no WebhookID yet: // the webhook has no ID until it is created. func (h *Handlers) newWebhookTargets( ctx context.Context, in sourceFormInput, ) ([]*database.Target, string, error) { var requested []targetFormInput if in.HTTPURL != "" { requested = append(requested, targetFormInput{ Name: "HTTP", Type: database.TargetTypeHTTP, URL: in.HTTPURL, }) } if in.Archive { requested = append(requested, targetFormInput{ Name: "Archive", Type: database.TargetTypeDatabase, Expiry: in.ArchiveExpiry, Rotation: in.ArchiveRotation, }) } targets := make([]*database.Target, 0, len(requested)) for _, form := range requested { target, errMsg, err := h.newTarget(ctx, "", form) if err != nil || errMsg != "" { return nil, errMsg, err } targets = append(targets, target) } return targets, "", nil } // createWebhookWithEntrypoint creates a webhook, its default // entrypoint and the given targets in a transaction. func (h *Handlers) createWebhookWithEntrypoint( w http.ResponseWriter, r *http.Request, webhook *database.Webhook, targets []*database.Target, ) { err := h.commitWebhook(webhook, targets) if err != nil { h.serverError(w, r, "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", webhook.Name, "user_id", webhook.UserID, ) http.Redirect( w, r, withNotice("/hook/"+webhook.ID, webhookCreated), http.StatusSeeOther, ) } // commitWebhook creates a webhook, its default entrypoint and the // given targets in a transaction. Returns an error on failure (rolls // back). func (h *Handlers) commitWebhook( webhook *database.Webhook, targets []*database.Target, ) 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 } for _, target := range targets { target.WebhookID = webhook.ID err = tx.Create(target).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 { h.renderError(w, r, http.StatusNotFound) return } h.renderSourceDetail(w, r, webhook, targetFormInput{}, "") } } // renderSourceDetail loads and renders a source detail page. With a // targetErr, it is the page shown again for a refused add target // form: it answers 400, and the form opens on targetForm's type with // its values and the message. func (h *Handlers) renderSourceDetail( w http.ResponseWriter, r *http.Request, webhook database.Webhook, targetForm targetFormInput, targetErr string, ) { 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) entrypointViews := NewEntrypointViews(entrypoints) var events []RecentEventView if h.dbMgr.DBExists(webhook.ID) { webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { h.serverError(w, r, "failed to get webhook database", err) return } events, err = loadRecentEvents( webhookDB, webhook.ID, singleHTTPTargetID(targets), ) if err != nil { h.serverError(w, r, "failed to load recent events", err) return } err = addEntrypointEvents( webhookDB, &webhook, entrypointViews, time.Now(), ) if err != nil { h.serverError(w, r, "failed to count entrypoint events", err) return } } scheme := "http" if reqtls.IsTLS(r) { scheme = "https" } // The host is the client's Host header, unvalidated. It is // inert only because source_detail.html renders BaseURL as // text, inside a element and in an entrypoint's delete // prompt; putting it in an href or any other URL context // needs it constrained first. baseURL := scheme + "://" + r.Host // 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, // Targets are projected to a display-safe view: a // target's stored config blob holds a credential, and it // must never reach a template. "Entrypoints": entrypointViews, "Targets": h.targetRows(&webhook, targets), "Events": events, "BaseURL": baseURL, "Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets), tmplKeyTargetForm: targetForm, "TargetError": targetErr, // The add target form's selects start on its expiry and // rotation through Alpine, so no choice is selected here. tmplKeyArchiveExpiryChoices: archiveExpiryChoices(), tmplKeyArchiveRotationChoices: archiveRotationChoices(), } status := http.StatusOK if targetErr != "" { status = http.StatusBadRequest } h.renderTemplateStatus(w, r, "source_detail.html", data, status) } // 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 { h.renderError(w, r, http.StatusNotFound) return } h.renderWebhookEdit( w, r, &webhook, webhook.Name, webhook.Description, strconv.Itoa(webhook.RetentionDays), "", http.StatusOK, ) } } // 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") h.renameMu.Lock() defer h.renameMu.Unlock() var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { h.renderError(w, r, http.StatusNotFound) return } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err = r.ParseForm() if err != nil { h.renderError(w, r, http.StatusBadRequest) return } h.applyWebhookEdit(w, r, &webhook) } } // applyWebhookEdit validates and saves webhook edits. A refused save // shows the edit form again with the values submitted and the reason. 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") description := r.PostFormValue("description") retention := r.PostFormValue("retention_days") if name == "" { h.renderWebhookEdit( w, r, webhook, name, description, retention, "Name is required", http.StatusBadRequest, ) return } // An empty field falls back to the stored value, so submitting the // form without touching retention leaves the policy alone. retentionDays, errMsg := parseRetentionDays( retention, webhook.RetentionDays, ) if errMsg != "" { h.renderWebhookEdit( w, r, webhook, name, description, retention, errMsg, http.StatusBadRequest, ) return } // edited is the webhook as the submission leaves it; webhook stays // as stored, for the page shown again when the save is refused. edited := *webhook edited.Name = name edited.Description = description edited.RetentionDays = retentionDays // A new name renames the archive files before it is saved (see // delivery.Engine.Rename). If either step fails, the same targets' // archives go back to the name that is still stored, without // reading the main database again. targets, err := h.renameWebhookArchives( webhook.ID, webhook.Name, edited.Name, ) if err == nil { err = h.db.DB().Save(&edited).Error } if err != nil { restoreErr := h.renameArchives(targets, webhook.Name) if restoreErr != nil { h.log.Error( "failed to rename archives back", "webhook_id", webhook.ID, "error", restoreErr, ) } if errors.Is(err, delivery.ErrArchiveNameTaken) { h.renderWebhookEdit( w, r, webhook, name, description, retention, "Not saved: "+err.Error()+ ". Move that archive out of the data directory, "+ "its .db together with any -wal and -shm beside "+ "it, then save again.", http.StatusConflict, ) return } h.serverError(w, r, "failed to update webhook", err) return } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, webhookSaved), http.StatusSeeOther, ) } // renderWebhookEdit renders the webhook edit page for the webhook as // stored, its form showing name, description and retentionDays, with // an optional error message above it. func (h *Handlers) renderWebhookEdit( w http.ResponseWriter, r *http.Request, webhook *database.Webhook, name, description, retentionDays, errMsg string, status int, ) { data := map[string]any{ tmplKeyWebhook: webhook, tmplKeyError: errMsg, "Name": name, "Description": description, "RetentionDays": retentionDays, } h.renderTemplateStatus(w, r, "source_edit.html", data, status) } // 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 { h.renderError(w, r, http.StatusNotFound) return } h.deleteWebhookResources(w, r, webhook, userID) } } // The messages deleteWebhookResources logs when a file of the event // database cannot be removed: the database file itself, or only a // sidecar once the database file is gone. const ( eventDBLeftMsg = "webhook deleted, but its event database file is " + "still on disk; remove it by hand" sidecarLeftMsg = "webhook deleted and its events are gone, but a " + "-wal or -shm sidecar of its event database is " + "still on disk; remove it by hand" ) // 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, ) { // The configuration delete commits before the event database // is touched. No transaction spans the main database and the // filesystem, so one side has to go first: committing the // configuration first means a later failure leaves an unused // event database file on disk, while removing the event // database first would mean a failed commit destroys the // history of a webhook that still exists. A leftover file can // be removed by hand; deleted history cannot be recovered. err := h.commitWebhookDeletion(&webhook) if err != nil { h.serverError(w, r, "failed to delete webhook", err) return } h.log.Info( "webhook deleted", "webhook_id", webhook.ID, "user_id", userID, ) // 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 { // The configuration is committed, so the webhook is gone, // but a file of its event database is still on disk with // nothing referencing it. Report the failure rather than // redirecting as though everything succeeded: the file // needs removing by hand, and the logged error names it. // When only a sidecar is left, the events are already // gone, and the message must not suggest they survive. msg := eventDBLeftMsg if errors.Is(err, database.ErrSidecarNotRemoved) { msg = sidecarLeftMsg } h.serverError(w, r, msg, err) return } http.Redirect( w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther, ) } // commitWebhookDeletion soft-deletes a webhook's entrypoints, // targets and the webhook row in one transaction. Every // statement is checked and any failure rolls the whole // transaction back, so a caller that gets an error knows the // configuration is untouched and the event database must be // left alone. func (h *Handlers) commitWebhookDeletion( webhook *database.Webhook, ) error { tx := h.db.DB().Begin() if tx.Error != nil { return tx.Error } err := tx.Where( "webhook_id = ?", webhook.ID, ).Delete(&database.Entrypoint{}).Error if err != nil { tx.Rollback() return err } err = tx.Where( "webhook_id = ?", webhook.ID, ).Delete(&database.Target{}).Error if err != nil { tx.Rollback() return err } err = tx.Delete(webhook).Error if err != nil { tx.Rollback() return err } return tx.Commit().Error } // evictArchiveWriter asks the delivery engine to drop the cached // archive writers of a webhook's database targets, closing their // archive file handles. // // The archive database files are 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.archives == nil { return } h.archives.EvictWebhook(webhookID) } // evictTargetArchiveWriter is evictArchiveWriter for one deleted // target, and leaves its archive file on disk for the same reason. // A target that is not a database target has no writer, and // evicting it does nothing. func (h *Handlers) evictTargetArchiveWriter(targetID string) { if h.archives == nil { return } h.archives.EvictTarget(targetID) } // renameWebhookArchives renames the archive file of every database // target of a webhook from the webhook name oldName to newName, // keeping each target's own name. It does nothing when the name is // unchanged. It returns the targets it read, so that a failed edit can // move those same archives back with renameArchives. func (h *Handlers) renameWebhookArchives( webhookID, oldName, newName string, ) ([]database.Target, error) { if h.archives == nil || oldName == newName { return nil, nil } var targets []database.Target err := h.db.DB(). Where( "webhook_id = ? AND type = ?", webhookID, database.TargetTypeDatabase, ). Find(&targets).Error if err != nil { return nil, err } return targets, h.renameArchives(targets, newName) } // renameArchives renames the archive file of each of the given // database targets to the webhook name webhookName, keeping each // target's own name. It tries every target even after one fails, so // that moving the archives back after a failed edit leaves none under // the new name, and returns every failure joined. func (h *Handlers) renameArchives( targets []database.Target, webhookName string, ) error { var errs []error for i := range targets { err := h.archives.Rename( targets[i].ID, webhookName, targets[i].Name, ) if err != nil { errs = append(errs, err) } } return errors.Join(errs...) } // 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 { h.renderError(w, r, http.StatusNotFound) 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, r, "failed to load targets", err) return } evts, total, ok := h.loadEventsWithDeliveries( w, r, webhook, targets, ) if !ok { return } data := map[string]any{ tmplKeyWebhook: &webhook, "Events": evts, "TotalEvents": total, } 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. Both halves of the map need those rows: // a scoped load leaves an old delivery with a zero redactor, // which renders its response bodies unredacted, and with a zero // view, which renders its target as a blank name. // // This map is historical display only. It is built for the event // log and an event's own page, and reaches nothing but // DeliveryView.Target: the target list on the source detail page, // the edit form and the replay path each resolve targets // themselves, and a deleted row is refused there as before. 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), ) for i := range targets { targetMap[targets[i].ID] = eventLogTarget{ Redactor: delivery.NewRedactor(&targets[i]), } } // The views come from NewTargetViews rather than being // rebuilt here, so the masking rules stay in one place and a // deleted target's configuration is masked by the same code // that masks a live one's. for _, v := range delivery.NewTargetViews(targets) { entry := targetMap[v.ID] entry.View = v targetMap[v.ID] = entry } return targetMap, nil } // loadEventsWithDeliveries loads the recentEventLimit newest events // and their deliveries from the per-webhook database, and the total // number of events stored. 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, r *http.Request, webhook database.Webhook, targetMap map[string]eventLogTarget, ) ([]EventLogView, int64, bool) { if !h.dbMgr.DBExists(webhook.ID) { return nil, 0, true } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { h.serverError( w, r, "failed to get webhook database", err, ) return nil, 0, false } rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID) result, ok := h.eventLogViews( w, r, webhookDB, webhook.ID, rows, targetMap, ) return result, totalEvents, ok } // eventLogViews projects loaded events for rendering, each with // its deliveries and how many times it has been resubmitted. Like // loadEventsWithDeliveries, it reports false once it has answered // the request with an error. func (h *Handlers) eventLogViews( w http.ResponseWriter, r *http.Request, webhookDB *gorm.DB, webhookID string, rows []eventLogRow, targetMap map[string]eventLogTarget, ) ([]EventLogView, bool) { result := make([]EventLogView, len(rows)) eventDeliveries := make([][]database.Delivery, len(rows)) var deliveryIDs []string eventIDs := make([]string, len(rows)) for i := range rows { result[i] = rows[i].view(webhookID) eventIDs[i] = rows[i].ID 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, r, "failed to load delivery attempts", err, ) return nil, false } resubmits, err := resubmitCounts(webhookDB, eventIDs) if err != nil { h.serverError( w, r, "failed to count event resubmissions", err, ) return nil, false } for i := range rows { result[i].Deliveries = h.newDeliveryViews( eventDeliveries[i], targetMap, attempts, ) result[i].ResubmitCount = resubmits[rows[i].ID] } return result, true } // loadEventLogRows reads the event log projection of the // recentEventLimit newest events, newest first, and the total number // of events stored. func loadEventLogRows( webhookDB *gorm.DB, webhookID string, ) ([]eventLogRow, int64) { var totalEvents int64 webhookDB.Model(&database.Event{}).Where( "webhook_id = ?", webhookID, ).Count(&totalEvents) var rows []eventLogRow webhookDB.Model(&database.Event{}).Select( eventLogColumns, maxRenderedBodyBytes, ).Where( "webhook_id = ?", webhookID, ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows) return rows, totalEvents } // resubmitCounts reports, for each of the page's events, how many // events have been resubmitted from it. // // One grouped query covers the page rather than one query per event. // The page shows at most recentEventLimit events, far below SQLite's // bound parameter ceiling, so it needs no chunking as the delivery // result load does. func resubmitCounts( webhookDB *gorm.DB, eventIDs []string, ) (map[string]int, error) { counts := make(map[string]int, len(eventIDs)) if len(eventIDs) == 0 { return counts, nil } var rows []struct { ResubmittedFromID string Total int } err := webhookDB.Model(&database.Event{}). Select("resubmitted_from_id, count(*) AS total"). Where("resubmitted_from_id IN ?", eventIDs). Group("resubmitted_from_id"). Find(&rows).Error if err != nil { return nil, err } for _, row := range rows { counts[row.ResubmittedFromID] = row.Total } return counts, nil } // 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. A // retrying delivery also reads its target's circuit breaker. func (h *Handlers) 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] created := deliveries[i].CreatedAt results, omitted := renderedAttempts( rows, target.Redactor, ) views[i] = DeliveryView{ ID: deliveries[i].ID, Status: deliveries[i].Status, Target: target.View, Replay: deliveries[i].Replay, Created: humanize.Time(created), CreatedUTC: created.UTC().Format(time.DateTime) + " UTC", Results: results, AttemptCount: len(rows), AttemptsOmitted: omitted, } if deliveries[i].Status == database.DeliveryStatusRetrying { views[i].Paused = h.deliveryPausedView( deliveries[i].TargetID, rows, ) } } 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 { h.renderError(w, r, http.StatusNotFound) return } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err = r.ParseForm() if err != nil { h.renderError(w, r, 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, r, "failed to create entrypoint", err) return } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, entrypointAdded), http.StatusSeeOther, ) } } // HandleEntrypointEdit handles changing an entrypoint's description. // It writes only the description column, so the entrypoint keeps its // URL, and an activate or deactivate saved since the page was shown // is not undone. func (h *Handlers) HandleEntrypointEdit() 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") entrypointID := chi.URLParam(r, "entrypointID") var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { h.renderError(w, r, http.StatusNotFound) return } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err = r.ParseForm() if err != nil { h.renderError(w, r, http.StatusBadRequest) return } result := h.db.DB().Model(&database.Entrypoint{}).Where( "id = ? AND webhook_id = ?", entrypointID, webhook.ID, ).Update("description", r.PostFormValue("description")) if result.Error != nil { h.serverError( w, r, "failed to edit entrypoint", result.Error, ) return } // The id came from the URL and may name another webhook's // entrypoint, which this webhook does not have. if result.RowsAffected == 0 { h.renderError(w, r, http.StatusNotFound) return } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, entrypointSaved), 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") h.renameMu.Lock() defer h.renameMu.Unlock() var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { h.renderError(w, r, http.StatusNotFound) return } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err = r.ParseForm() if err != nil { h.renderError(w, r, http.StatusBadRequest) return } h.processTargetCreate(w, r, webhook) } } // processTargetCreate validates and creates a new target. A refused // submission shows the webhook page again, with the add target form // open on the chosen type, the values entered, and the reason. func (h *Handlers) processTargetCreate( w http.ResponseWriter, r *http.Request, webhook database.Webhook, ) { in := targetFormInputFrom(r) target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in) if err != nil { h.serverError(w, r, "failed to encode target config", err) return } if errMsg != "" { h.renderSourceDetail(w, r, webhook, in, errMsg) return } err = h.db.DB().Create(target).Error if err != nil { h.serverError(w, r, "failed to create target", err) return } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, targetAdded), http.StatusSeeOther, ) } // newTarget validates a new target for a webhook and returns the row // to create, or, when it refuses the target, the message the form // shows. An error is the server's fault, not a refusal: the accepted // configuration could not be encoded. Every form that creates a // target goes through here, so they all accept and refuse the same // things. func (h *Handlers) newTarget( ctx context.Context, webhookID string, in targetFormInput, ) (*database.Target, string, error) { target := &database.Target{ WebhookID: webhookID, Type: in.Type, Active: true, } errMsg, err := h.setTargetFromForm(ctx, target, in) if err != nil || errMsg != "" { return nil, errMsg, err } return target, "", nil } // setTargetFromForm validates a target form against the target's type // and, when it accepts it, sets the target's name, configuration and // retry count from it. It returns the message the form shows for // anything it refuses, an unknown type among them, and then leaves the // target unchanged; an error is the server's fault, as for newTarget. // The add target form and the target edit form both go through here, // so the two cannot come to disagree about what a target may be. func (h *Handlers) setTargetFromForm( ctx context.Context, target *database.Target, in targetFormInput, ) (string, error) { if in.Name == "" { return "Name is required", nil } configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in) if err != nil || errMsg != "" { return errMsg, err } // An empty max_retries keeps the target's count: the // fire-and-forget default of 0 for a new target, and the stored // count for an edited one, since the forms for target types that // do not retry have no such field. A value that is filled in but // invalid is refused rather than becoming that count, so a typo // cannot destroy the count a target is delivering with. maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) if err != nil { return "Invalid max retries: " + retriesErrorMessage(err), nil } target.Name = in.Name target.Config = configJSON target.MaxRetries = maxRetries return "", nil } // targetFormInput carries the raw values of a target form. Both the // create and the edit path fill one and hand it to setTargetFromForm, // so neither can come to validate a target differently from the // other. Both forms are filled from one: the edit form with the // stored values, and a refused form with the values submitted. type targetFormInput struct { // Name is the target's name. Name string // Type is the type chosen on the add target form. The edit form // has none: a target's stored type decides. Type database.TargetType // 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 // MaxRetries is an HTTP or Slack target's max_retries. MaxRetries string // Expiry is a database (archive) target's row expiry. Expiry string // Rotation is a database (archive) target's rotation. Rotation string } // targetFormInputFrom reads a target form 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 /hook/{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{ Name: r.PostFormValue("name"), Type: database.TargetType(r.PostFormValue("type")), URL: r.PostFormValue("url"), Headers: r.PostFormValue("headers"), Timeout: r.PostFormValue("timeout"), MaxRetries: r.PostFormValue("max_retries"), Expiry: r.PostFormValue("expiry"), Rotation: r.PostFormValue("rotation"), } } // buildTargetConfig builds the JSON config string for a target from // the submitted form values, or returns the message the form shows // for a value it refuses. An error is the server's fault, not a // refusal: the accepted configuration could not be encoded. Which // fields of in apply depends on the target type; a type without a URL // ignores any URL submitted. func (h *Handlers) buildTargetConfig( ctx context.Context, targetType database.TargetType, in targetFormInput, ) (string, string, error) { switch targetType { case database.TargetTypeHTTP: return h.buildHTTPTargetConfig(ctx, in) case database.TargetTypeSlack: return h.buildSlackTargetConfig(ctx, in.URL) case database.TargetTypeDatabase: return buildDatabaseTargetConfig(in.Expiry, in.Rotation) case database.TargetTypeLog: return "", "", nil default: return "", "Invalid target type", nil } } // 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( ctx context.Context, in targetFormInput, ) (string, string, error) { errMsg := h.validateTargetURL( ctx, in.URL, "URL is required for HTTP targets", ) if errMsg != "" { return "", errMsg, nil } headers, err := delivery.ParseTargetHeaders(in.Headers) if err != nil { return "", fmt.Sprintf("Invalid headers: %v", err), nil } timeout, err := delivery.ParseTargetTimeout(in.Timeout) if err != nil { return "", fmt.Sprintf("Invalid timeout: %v", err), nil } configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ URL: in.URL, Headers: headers, Timeout: timeout, }) return configJSON, "", err } // buildSlackTargetConfig builds config JSON for a Slack target, // whose whole configuration is one SSRF-validated webhook URL. func (h *Handlers) buildSlackTargetConfig( ctx context.Context, targetURL string, ) (string, string, error) { errMsg := h.validateTargetURL( ctx, targetURL, "Webhook URL is required for Slack targets", ) if errMsg != "" { return "", errMsg, nil } configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{ WebhookURL: targetURL, }) return configJSON, "", err } // validateTargetURL refuses an empty or SSRF-blocked destination, // returning the message the form shows, or "" when the destination // is accepted. missingMsg is the message for no URL at all. // // 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( ctx context.Context, targetURL, missingMsg string, ) string { if targetURL == "" { return missingMsg } err := h.ssrf.ValidateTargetURL(ctx, 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, ) msg := "Invalid target URL: " + err.Error() // Only a private or reserved address's refusal says how // to allow it. Other refusals never do: link-local, the // unspecified addresses and the unconditional metadata // addresses cannot be opened, and the default // blocklist's public addresses, which listing does open, // hand out credentials. if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) { msg += ". Private and reserved addresses are refused " + "by default; the server's ALLOWED_EGRESS_CIDRS " + "setting allows named networks (see \"Allowing " + "egress to your own network\" in the README)." } return msg } return "" } // marshalTargetConfig serialises a target configuration for storage. func marshalTargetConfig(cfg any) (string, error) { configBytes, err := json.Marshal(cfg) if err != nil { return "", err } return string(configBytes), nil } // buildDatabaseTargetConfig builds config JSON for a database // (archive) target. The optional expiry and rotation are validated // here, at creation time, so a bad value is refused instead of // failing every subsequent delivery. Each is stored only when set, // and with neither the config is empty (the keep-forever, one-file // default). func buildDatabaseTargetConfig( expiry, rotation string, ) (string, string, error) { expiry = strings.TrimSpace(expiry) err := delivery.ValidateArchiveExpiry(expiry) if err != nil { return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil } err = delivery.ValidateArchiveRotation(rotation) if err != nil { return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil } cfg := map[string]any{} if expiry != "" { cfg["expiry"] = expiry } if rotation != "" { cfg["rotation"] = rotation } if len(cfg) == 0 { return "", "", nil } configJSON, err := marshalTargetConfig(cfg) return configJSON, "", err } // HandleEntrypointDelete handles deleting an entrypoint. func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { return h.deleteChildResource( "entrypointID", &database.Entrypoint{}, "failed to delete entrypoint", nil, entrypointDeleted, ) } // HandleTargetDelete handles deleting a target. A deleted // database target's archive 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.evictTargetArchiveWriter, targetDeleted, ) } // deleteChildResource returns a handler that deletes a child // resource (entrypoint or target) belonging to a webhook. The // optional afterDelete hook runs with the child's id once the // delete has removed it, before the redirect, which carries done as // its notice. func (h *Handlers) deleteChildResource( idParam string, model any, errMsg string, afterDelete func(childID string), done noticeCode, ) 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 { h.renderError(w, r, http.StatusNotFound) return } result := h.db.DB().Where( "id = ? AND webhook_id = ?", childID, webhook.ID, ).Delete(model) if result.Error != nil { h.serverError(w, r, errMsg, result.Error) return } // Only for a row this webhook really had: the id came from // the URL and may name another webhook's child. if afterDelete != nil && result.RowsAffected > 0 { afterDelete(childID) } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, done), http.StatusSeeOther, ) } } // HandleEntrypointToggle handles toggling an entrypoint's // active state. func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { return h.toggleChildResource( "entrypointID", func(webhookID, childID string) (bool, error) { var ep database.Entrypoint err := h.db.DB().Where( "id = ? AND webhook_id = ?", childID, webhookID, ).First(&ep).Error if err != nil { return false, err } // Only the active column: saving the whole row would // write back the description read above over an edit // saved since. active := !ep.Active return active, h.db.DB().Model(&ep). Update("active", active).Error }, "failed to toggle entrypoint", entrypointActivated, entrypointDeactivated, ) } // HandleTargetToggle handles toggling a target's active state. func (h *Handlers) HandleTargetToggle() http.HandlerFunc { return h.toggleChildResource( "targetID", func(webhookID, childID string) (bool, error) { var tgt database.Target err := h.db.DB().Where( "id = ? AND webhook_id = ?", childID, webhookID, ).First(&tgt).Error if err != nil { return false, err } // Only the active column: saving the whole row would // write back the name and settings read above over an // edit saved since. active := !tgt.Active return active, h.db.DB().Model(&tgt). Update("active", active).Error }, "failed to toggle target", targetActivated, targetDeactivated, ) } // toggleChildResource returns a handler that toggles the active // state of a child resource belonging to a webhook. toggleFn returns // the new state, and the redirect carries activated or deactivated as // its notice to match. func (h *Handlers) toggleChildResource( idParam string, toggleFn func(webhookID, childID string) (bool, error), errMsg string, activated, deactivated noticeCode, ) 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 { h.renderError(w, r, http.StatusNotFound) return } active, err := toggleFn(webhook.ID, childID) if err != nil { h.serverError(w, r, errMsg, err) return } done := deactivated if active { done = activated } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, done), 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) }