check / check (push) Waiting to run
`internal/handlers/source_management.go` had grown to about 2,370 lines holding the webhook, event log, entrypoint and target handlers, so unrelated units had to wait on each other to touch it. It is split, as pure code movement, into files named for what they hold: `webhook_list.go`, `webhook_create.go`, `webhook_detail.go`, `webhook_edit.go`, `webhook_delete.go`, `event_log.go`, `entrypoint.go`, `target_create.go`, `target_delete.go`, `target_toggle.go` and `shared.go`. No function body, signature, comment or behaviour changed. The README's file tree and log-line caveat, and one middleware comment, name the new files. Model: opus-5-5
621 lines
17 KiB
Go
621 lines
17 KiB
Go
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
|
|
}
|