From f71d3a01a97e5cc8381e53fca1cc393b68fa5354 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 3 Oct 2026 03:46:55 +0000 Subject: [PATCH] Split source_management.go along its CRUD seams (closes #274) Pure code movement. Every declaration of internal/handlers/source_management.go moves unchanged into one of: webhook_list.go, webhook_create.go, webhook_detail.go, webhook_edit.go and webhook_delete.go for the webhook pages; event_log.go for the event log; entrypoint.go for the entrypoint handlers; target_create.go, target_delete.go and target_toggle.go for the target handlers; and shared.go for the helpers several of them use. Only each file's package line and imports are new. The README and a middleware comment that named the removed file now name the new ones. Model: opus-5-5 --- README.md | 17 +- internal/handlers/entrypoint.go | 169 ++ internal/handlers/event_log.go | 620 +++++++ internal/handlers/shared.go | 230 +++ internal/handlers/source_management.go | 2371 ------------------------ internal/handlers/target_create.go | 384 ++++ internal/handlers/target_delete.go | 31 + internal/handlers/target_toggle.go | 35 + internal/handlers/webhook_create.go | 263 +++ internal/handlers/webhook_delete.go | 170 ++ internal/handlers/webhook_detail.go | 133 ++ internal/handlers/webhook_edit.go | 245 +++ internal/handlers/webhook_list.go | 178 ++ internal/middleware/middleware.go | 5 +- 14 files changed, 2475 insertions(+), 2376 deletions(-) create mode 100644 internal/handlers/entrypoint.go create mode 100644 internal/handlers/event_log.go create mode 100644 internal/handlers/shared.go delete mode 100644 internal/handlers/source_management.go create mode 100644 internal/handlers/target_create.go create mode 100644 internal/handlers/target_delete.go create mode 100644 internal/handlers/target_toggle.go create mode 100644 internal/handlers/webhook_create.go create mode 100644 internal/handlers/webhook_delete.go create mode 100644 internal/handlers/webhook_detail.go create mode 100644 internal/handlers/webhook_edit.go create mode 100644 internal/handlers/webhook_list.go diff --git a/README.md b/README.md index 8b0197c..d1e32ca 100644 --- a/README.md +++ b/README.md @@ -2840,8 +2840,9 @@ read as more than it is: - **Lines carrying an authenticated operator's own input**, which are not truncated at all. `webhook created` logs the submitted `name` - verbatim and `target URL blocked by SSRF protection` logs the target - host (both `internal/handlers/source_management.go`), as do the + verbatim (`internal/handlers/webhook_create.go`) and + `target URL blocked by SSRF protection` logs the target host + (`internal/handlers/target_create.go`), as do the `target_name` lines in `internal/delivery/engine.go` and `internal/delivery/target_http.go`. The only bound on any of them is the 1 MB form body cap, so a 100 KB `name` writes a single line of @@ -3220,7 +3221,17 @@ webhooker/ │ │ ├── index.go # Index page handler │ │ ├── profile.go # User profile handler │ │ ├── settings.go # Read-only Settings page handler -│ │ ├── source_management.go # Webhook CRUD handlers +│ │ ├── webhook_list.go # Webhook list page +│ │ ├── webhook_create.go # Webhook create +│ │ ├── webhook_detail.go # Webhook detail page +│ │ ├── webhook_edit.go # Webhook edit, archive renaming +│ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal +│ │ ├── event_log.go # Event log page: loaders, filters, delivery views +│ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle +│ │ ├── target_create.go # Target create, per-type config builders +│ │ ├── target_delete.go # Target delete +│ │ ├── target_toggle.go # Target toggle +│ │ ├── shared.go # Helpers shared by several handlers │ │ └── webhook.go # Webhook receiver handler │ ├── healthcheck/ │ │ └── healthcheck.go # Health check service (uptime, version) diff --git a/internal/handlers/entrypoint.go b/internal/handlers/entrypoint.go new file mode 100644 index 0000000..6faac72 --- /dev/null +++ b/internal/handlers/entrypoint.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "sneak.berlin/go/webhooker/internal/database" +) + +// 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, + ) + } +} + +// HandleEntrypointDelete handles deleting an entrypoint. +func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { + return h.deleteChildResource( + "entrypointID", &database.Entrypoint{}, + "failed to delete entrypoint", + nil, + entrypointDeleted, + ) +} + +// 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, + ) +} diff --git a/internal/handlers/event_log.go b/internal/handlers/event_log.go new file mode 100644 index 0000000..2cad0dc --- /dev/null +++ b/internal/handlers/event_log.go @@ -0,0 +1,620 @@ +package handlers + +import ( + "net/http" + "slices" + "time" + + "github.com/dustin/go-humanize" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// 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 +} + +// The event log's show query parameter and its two values: the events +// with a failed delivery, and those with a delivery still pending or +// retrying. +const ( + showParam = "show" + showFailed = "failed" + showPending = "pending" +) + +// 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 + } + + // Any other value of show lists every event, as no value + // does. + show := r.URL.Query().Get(showParam) + + statuses := eventLogStatuses(show) + if statuses == nil { + show = "" + } + + evts, total, ok := h.loadEventsWithDeliveries( + w, r, webhook, targets, statuses, + ) + if !ok { + return + } + + failed, pending, err := h.countFailedAndPendingEvents(webhook.ID) + if err != nil { + h.serverError(w, r, "failed to count events", err) + + return + } + + data := map[string]any{ + tmplKeyWebhook: &webhook, + "Events": evts, + "TotalEvents": total, + "Show": show, + "FailedEvents": failed, + "PendingEvents": pending, + } + + 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. Given delivery statuses, both cover only +// the events with a delivery in one of them. 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, + statuses []database.DeliveryStatus, +) ([]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, err := loadEventLogRows( + webhookDB, webhook.ID, statuses, + ) + if err != nil { + h.serverError(w, r, "failed to load events", err) + + return nil, 0, false + } + + result, ok := h.eventLogViews( + w, r, webhookDB, webhook.ID, rows, targetMap, + maxRenderedBodyBytes, + ) + + return result, totalEvents, ok +} + +// eventLogViews projects loaded events for rendering, each with +// its deliveries, how many times it has been resubmitted and the +// entrypoint it arrived at (for a resubmitted copy, the one the +// request it copies arrived at), and with its request headers only +// when their text holds at most maxHeaderBytes. 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, + maxHeaderBytes int, +) ([]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, maxHeaderBytes) + 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 + } + + entrypoints, err := h.entrypointNames(webhookID) + if err != nil { + h.serverError(w, r, "failed to load entrypoints", err) + + return nil, false + } + + for i := range rows { + result[i].Deliveries = h.newDeliveryViews( + eventDeliveries[i], targetMap, attempts, + ) + result[i].ResubmitCount = resubmits[rows[i].ID] + + name, ok := entrypoints[rows[i].EntrypointID] + if !ok { + name = "deleted entrypoint" + } + + result[i].Entrypoint = name + } + + return result, true +} + +// loadEventLogRows reads the event log projection of the +// recentEventLimit newest events, newest first, and the total number +// of events stored, both narrowed by statuses as eventsWithStatus +// narrows them. +func loadEventLogRows( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) ([]eventLogRow, int64, error) { + totalEvents, err := countEventsWithStatus( + webhookDB, webhookID, statuses, + ) + if err != nil { + return nil, 0, err + } + + var rows []eventLogRow + + err = eventsWithStatus(webhookDB, webhookID, statuses).Select( + eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, + ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error + + return rows, totalEvents, err +} + +// eventLogStatuses returns the delivery statuses the event log's show +// value lists events by, or nil for one that lists every event. +func eventLogStatuses(show string) []database.DeliveryStatus { + switch show { + case showFailed: + return []database.DeliveryStatus{database.DeliveryStatusFailed} + case showPending: + return []database.DeliveryStatus{ + database.DeliveryStatusPending, + database.DeliveryStatusRetrying, + } + default: + return nil + } +} + +// eventsWithStatus selects the webhook's events, or, given statuses, +// the recentEventLimit newest of those with at least one delivery in +// one of them. +// +// Given statuses, its cost follows the matching deliveries. SQLite +// never reorders a CROSS JOIN, so it reads each join's left side +// first: the distinct event IDs of the matching deliveries, through +// idx_deliveries_status; then each of those events by ID, sorted to +// keep the newest; then the rows of only the events kept, so no other +// event's body is read. With a plain "id IN (matching deliveries)" +// condition instead, SQLite, which keeps no statistics on these +// tables, walks every event newest first. +func eventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) *gorm.DB { + if statuses == nil { + return webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ) + } + + matching := webhookDB.Model(&database.Delivery{}). + Distinct("event_id").Where("status IN ?", statuses) + + newest := webhookDB.Table("(?) AS matching", matching). + Joins("CROSS JOIN events ON events.id = matching.event_id"). + Where( + "events.webhook_id = ? AND events.deleted_at IS NULL", + webhookID, + ). + Order("events.created_at DESC").Limit(recentEventLimit). + Select("events.id AS event_id") + + return webhookDB.Table("(?) AS newest", newest). + Joins("CROSS JOIN events ON events.id = newest.event_id") +} + +// countEventsWithStatus counts the webhook's events with at least one +// delivery in one of the statuses, or every event when statuses is +// nil. Given statuses, it counts the distinct events of the matching +// deliveries and reads nothing but those deliveries, through +// idx_deliveries_status, where counting the events would read every +// event row. That is the same number, because retention deletes an +// event's deliveries with it. +func countEventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) (int64, error) { + var count int64 + + if statuses == nil { + err := webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ).Count(&count).Error + + return count, err + } + + err := webhookDB.Model(&database.Delivery{}).Distinct("event_id"). + Where("status IN ?", statuses).Count(&count).Error + + return count, err +} + +// countFailedAndPendingEvents returns how many of the webhook's events +// the event log lists when it shows only those with a failed delivery, +// and when it shows only those with a delivery pending or retrying. +func (h *Handlers) countFailedAndPendingEvents( + webhookID string, +) (int64, int64, error) { + if !h.dbMgr.DBExists(webhookID) { + return 0, 0, nil + } + + webhookDB, err := h.dbMgr.GetDB(webhookID) + if err != nil { + return 0, 0, err + } + + failed, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showFailed), + ) + if err != nil { + return 0, 0, err + } + + pending, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showPending), + ) + if err != nil { + return 0, 0, err + } + + return failed, pending, nil +} + +// 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 +} diff --git a/internal/handlers/shared.go b/internal/handlers/shared.go new file mode 100644 index 0000000..43e13fc --- /dev/null +++ b/internal/handlers/shared.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" +) + +// 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, "" +} + +// 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 +} + +// 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, + ) + } +} + +// 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) +} diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go deleted file mode 100644 index 0f5531f..0000000 --- a/internal/handlers/source_management.go +++ /dev/null @@ -1,2371 +0,0 @@ -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 -} - -// The event log's show query parameter and its two values: the events -// with a failed delivery, and those with a delivery still pending or -// retrying. -const ( - showParam = "show" - showFailed = "failed" - showPending = "pending" -) - -// 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 - } - - // Any other value of show lists every event, as no value - // does. - show := r.URL.Query().Get(showParam) - - statuses := eventLogStatuses(show) - if statuses == nil { - show = "" - } - - evts, total, ok := h.loadEventsWithDeliveries( - w, r, webhook, targets, statuses, - ) - if !ok { - return - } - - failed, pending, err := h.countFailedAndPendingEvents(webhook.ID) - if err != nil { - h.serverError(w, r, "failed to count events", err) - - return - } - - data := map[string]any{ - tmplKeyWebhook: &webhook, - "Events": evts, - "TotalEvents": total, - "Show": show, - "FailedEvents": failed, - "PendingEvents": pending, - } - - 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. Given delivery statuses, both cover only -// the events with a delivery in one of them. 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, - statuses []database.DeliveryStatus, -) ([]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, err := loadEventLogRows( - webhookDB, webhook.ID, statuses, - ) - if err != nil { - h.serverError(w, r, "failed to load events", err) - - return nil, 0, false - } - - result, ok := h.eventLogViews( - w, r, webhookDB, webhook.ID, rows, targetMap, - maxRenderedBodyBytes, - ) - - return result, totalEvents, ok -} - -// eventLogViews projects loaded events for rendering, each with -// its deliveries, how many times it has been resubmitted and the -// entrypoint it arrived at (for a resubmitted copy, the one the -// request it copies arrived at), and with its request headers only -// when their text holds at most maxHeaderBytes. 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, - maxHeaderBytes int, -) ([]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, maxHeaderBytes) - 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 - } - - entrypoints, err := h.entrypointNames(webhookID) - if err != nil { - h.serverError(w, r, "failed to load entrypoints", err) - - return nil, false - } - - for i := range rows { - result[i].Deliveries = h.newDeliveryViews( - eventDeliveries[i], targetMap, attempts, - ) - result[i].ResubmitCount = resubmits[rows[i].ID] - - name, ok := entrypoints[rows[i].EntrypointID] - if !ok { - name = "deleted entrypoint" - } - - result[i].Entrypoint = name - } - - return result, true -} - -// loadEventLogRows reads the event log projection of the -// recentEventLimit newest events, newest first, and the total number -// of events stored, both narrowed by statuses as eventsWithStatus -// narrows them. -func loadEventLogRows( - webhookDB *gorm.DB, - webhookID string, - statuses []database.DeliveryStatus, -) ([]eventLogRow, int64, error) { - totalEvents, err := countEventsWithStatus( - webhookDB, webhookID, statuses, - ) - if err != nil { - return nil, 0, err - } - - var rows []eventLogRow - - err = eventsWithStatus(webhookDB, webhookID, statuses).Select( - eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, - ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error - - return rows, totalEvents, err -} - -// eventLogStatuses returns the delivery statuses the event log's show -// value lists events by, or nil for one that lists every event. -func eventLogStatuses(show string) []database.DeliveryStatus { - switch show { - case showFailed: - return []database.DeliveryStatus{database.DeliveryStatusFailed} - case showPending: - return []database.DeliveryStatus{ - database.DeliveryStatusPending, - database.DeliveryStatusRetrying, - } - default: - return nil - } -} - -// eventsWithStatus selects the webhook's events, or, given statuses, -// the recentEventLimit newest of those with at least one delivery in -// one of them. -// -// Given statuses, its cost follows the matching deliveries. SQLite -// never reorders a CROSS JOIN, so it reads each join's left side -// first: the distinct event IDs of the matching deliveries, through -// idx_deliveries_status; then each of those events by ID, sorted to -// keep the newest; then the rows of only the events kept, so no other -// event's body is read. With a plain "id IN (matching deliveries)" -// condition instead, SQLite, which keeps no statistics on these -// tables, walks every event newest first. -func eventsWithStatus( - webhookDB *gorm.DB, - webhookID string, - statuses []database.DeliveryStatus, -) *gorm.DB { - if statuses == nil { - return webhookDB.Model(&database.Event{}).Where( - "webhook_id = ?", webhookID, - ) - } - - matching := webhookDB.Model(&database.Delivery{}). - Distinct("event_id").Where("status IN ?", statuses) - - newest := webhookDB.Table("(?) AS matching", matching). - Joins("CROSS JOIN events ON events.id = matching.event_id"). - Where( - "events.webhook_id = ? AND events.deleted_at IS NULL", - webhookID, - ). - Order("events.created_at DESC").Limit(recentEventLimit). - Select("events.id AS event_id") - - return webhookDB.Table("(?) AS newest", newest). - Joins("CROSS JOIN events ON events.id = newest.event_id") -} - -// countEventsWithStatus counts the webhook's events with at least one -// delivery in one of the statuses, or every event when statuses is -// nil. Given statuses, it counts the distinct events of the matching -// deliveries and reads nothing but those deliveries, through -// idx_deliveries_status, where counting the events would read every -// event row. That is the same number, because retention deletes an -// event's deliveries with it. -func countEventsWithStatus( - webhookDB *gorm.DB, - webhookID string, - statuses []database.DeliveryStatus, -) (int64, error) { - var count int64 - - if statuses == nil { - err := webhookDB.Model(&database.Event{}).Where( - "webhook_id = ?", webhookID, - ).Count(&count).Error - - return count, err - } - - err := webhookDB.Model(&database.Delivery{}).Distinct("event_id"). - Where("status IN ?", statuses).Count(&count).Error - - return count, err -} - -// countFailedAndPendingEvents returns how many of the webhook's events -// the event log lists when it shows only those with a failed delivery, -// and when it shows only those with a delivery pending or retrying. -func (h *Handlers) countFailedAndPendingEvents( - webhookID string, -) (int64, int64, error) { - if !h.dbMgr.DBExists(webhookID) { - return 0, 0, nil - } - - webhookDB, err := h.dbMgr.GetDB(webhookID) - if err != nil { - return 0, 0, err - } - - failed, err := countEventsWithStatus( - webhookDB, webhookID, eventLogStatuses(showFailed), - ) - if err != nil { - return 0, 0, err - } - - pending, err := countEventsWithStatus( - webhookDB, webhookID, eventLogStatuses(showPending), - ) - if err != nil { - return 0, 0, err - } - - return failed, pending, nil -} - -// 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 delivery attempts: " + 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) -} diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go new file mode 100644 index 0000000..c8cd048 --- /dev/null +++ b/internal/handlers/target_create.go @@ -0,0 +1,384 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// 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 delivery attempts: " + 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 +} diff --git a/internal/handlers/target_delete.go b/internal/handlers/target_delete.go new file mode 100644 index 0000000..4402b3a --- /dev/null +++ b/internal/handlers/target_delete.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "net/http" + + "sneak.berlin/go/webhooker/internal/database" +) + +// 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, + ) +} + +// 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) +} diff --git a/internal/handlers/target_toggle.go b/internal/handlers/target_toggle.go new file mode 100644 index 0000000..d9780f4 --- /dev/null +++ b/internal/handlers/target_toggle.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "net/http" + + "sneak.berlin/go/webhooker/internal/database" +) + +// 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, + ) +} diff --git a/internal/handlers/webhook_create.go b/internal/handlers/webhook_create.go new file mode 100644 index 0000000..bfa1fb9 --- /dev/null +++ b/internal/handlers/webhook_create.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "github.com/google/uuid" + "sneak.berlin/go/webhooker/internal/database" +) + +// 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 +} diff --git a/internal/handlers/webhook_delete.go b/internal/handlers/webhook_delete.go new file mode 100644 index 0000000..209081d --- /dev/null +++ b/internal/handlers/webhook_delete.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" +) + +// 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) +} diff --git a/internal/handlers/webhook_detail.go b/internal/handlers/webhook_detail.go new file mode 100644 index 0000000..ed3e783 --- /dev/null +++ b/internal/handlers/webhook_detail.go @@ -0,0 +1,133 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/reqtls" +) + +// 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) +} diff --git a/internal/handlers/webhook_edit.go b/internal/handlers/webhook_edit.go new file mode 100644 index 0000000..f3ca94b --- /dev/null +++ b/internal/handlers/webhook_edit.go @@ -0,0 +1,245 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// 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) +} + +// 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...) +} diff --git a/internal/handlers/webhook_list.go b/internal/handlers/webhook_list.go new file mode 100644 index 0000000..ee151dc --- /dev/null +++ b/internal/handlers/webhook_list.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "sneak.berlin/go/webhooker/internal/database" +) + +// 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 +} + +// 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 +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 7c452ae..930abbf 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -131,8 +131,9 @@ const ( // // - Lines carrying an AUTHENTICATED operator's own input, which // are not truncated at all: the webhook name on "webhook - // created" and the target host on "target URL blocked by SSRF - // protection" (both internal/handlers/source_management.go), + // created" (internal/handlers/webhook_create.go) and the target + // host on "target URL blocked by SSRF protection" + // (internal/handlers/target_create.go), // and target_name in internal/delivery/engine.go and // target_http.go. Each is bounded only by the 1 MB form body // cap, so a 100 KB name writes one line of roughly 600 KB.