diff --git a/README.md b/README.md
index c026e6b..e205742 100644
--- a/README.md
+++ b/README.md
@@ -3104,7 +3104,7 @@ returns to the page that was asked for.
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
| `POST` | `/hook/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events` | Full Event Log |
-| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, its whole body and every delivery of it |
+| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one its original arrived at), its request headers, its whole body and every delivery of it |
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
diff --git a/internal/handlers/event_detail.go b/internal/handlers/event_detail.go
index 9ee452c..a64fc19 100644
--- a/internal/handlers/event_detail.go
+++ b/internal/handlers/event_detail.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "math"
"net/http"
"github.com/go-chi/chi"
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
return
}
+ // The page shows every request header.
views, ok := h.eventLogViews(
- w, r, webhookDB, webhook.ID, rows, targets,
+ w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
)
if !ok {
return
diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go
index 4a3446e..c387999 100644
--- a/internal/handlers/event_log_view.go
+++ b/internal/handlers/event_log_view.go
@@ -1,10 +1,14 @@
package handlers
import (
+ "encoding/json"
+ "net/http"
+ "slices"
"time"
"unicode/utf8"
"github.com/dustin/go-humanize"
+ "sneak.berlin/go/webhooker/internal/database"
)
// eventLogColumns is the event log's projection. The casts to
@@ -12,16 +16,20 @@ import (
// bytes rather than characters, so the cap bounds the page in
// bytes whatever the payload's encoding. Cutting in SQLite
// rather than in Go is the point of the projection — an
-// oversized body never becomes a Go string at all.
+// oversized body or set of request headers never becomes a Go
+// string at all.
const eventLogColumns = "id, created_at, method, content_type, " +
- "resubmitted_from_id, " +
+ "resubmitted_from_id, entrypoint_id, " +
+ "substr(cast(headers as blob), 1, ?) AS headers, " +
+ "length(cast(headers as blob)) AS headers_bytes, " +
"substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which
-// shows the whole body.
+// shows the whole body and every request header.
const eventColumns = "id, created_at, method, content_type, " +
- "resubmitted_from_id, " +
+ "resubmitted_from_id, entrypoint_id, headers, " +
+ "length(cast(headers as blob)) AS headers_bytes, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
@@ -40,6 +48,20 @@ type EventLogView struct {
Body BodyView
+ // Entrypoint names the entrypoint the event arrived at, or for a
+ // resubmitted copy, which did not arrive, the one its original
+ // arrived at: its description, "Entrypoint" when it has none, or
+ // "deleted entrypoint". Never its URL, which is the entrypoint's
+ // secret.
+ Entrypoint string
+
+ // Headers is the event's request headers, one "Name: value"
+ // per value, sorted by name. HeadersCut reports headers left
+ // out because they hold more than maxRenderedBodyBytes, stored
+ // or as lines; only the event log leaves them out.
+ Headers []string
+ HeadersCut bool
+
// ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver.
ResubmittedFromID string
@@ -60,27 +82,35 @@ func (v EventLogView) ResubmittedFrom() bool {
}
// eventLogRow is one row of the event log projection, or of
-// eventColumns. In the event log its body column arrives
-// already cut to the cap by SQLite, with the true size beside
-// it.
+// eventColumns. In the event log its headers and body columns
+// arrive already cut to the cap by SQLite, each with its true
+// size beside it.
type eventLogRow struct {
ID string
CreatedAt time.Time
Method string
ContentType string
ResubmittedFromID *string
+ EntrypointID string
+ Headers string
+ HeadersBytes int64
Body []byte
BodyBytes int64
}
// view projects a loaded row of the webhook's events for
-// rendering.
-func (r *eventLogRow) view(webhookID string) EventLogView {
+// rendering. It shows the request headers when the row holds them
+// whole and their lines hold at most maxHeaderBytes.
+func (r *eventLogRow) view(
+ webhookID string, maxHeaderBytes int,
+) EventLogView {
var from string
if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID
}
+ headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
+
return EventLogView{
ID: r.ID,
Method: r.Method,
@@ -90,10 +120,82 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
+ Headers: headers,
+ HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from,
}
}
+// requestHeaderLines turns an event's stored request headers, the
+// JSON the receiver writes, into one "Name: value" line per value,
+// sorted by name. Headers that do not parse, as when the event log
+// has cut them, show as none. It reports false, with no lines, when
+// the lines would hold more than maxBytes: a header sent many times
+// is stored with its name once but shown with it on every line.
+func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
+ var headers http.Header
+
+ if json.Unmarshal([]byte(headersJSON), &headers) != nil {
+ return nil, true
+ }
+
+ names := make([]string, 0, len(headers))
+ for name := range headers {
+ names = append(names, name)
+ }
+
+ slices.Sort(names)
+
+ var lines []string
+
+ size := 0
+
+ for _, name := range names {
+ for _, value := range headers[name] {
+ line := name + ": " + value
+
+ size += len(line)
+ if size > maxBytes {
+ return nil, false
+ }
+
+ lines = append(lines, line)
+ }
+ }
+
+ return lines, true
+}
+
+// entrypointNames maps each of the webhook's entrypoints to the name
+// an event that arrived at it shows: its description, or "Entrypoint"
+// when it has none, as the webhook page names it. A deleted
+// entrypoint is left out.
+func (h *Handlers) entrypointNames(
+ webhookID string,
+) (map[string]string, error) {
+ var entrypoints []database.Entrypoint
+
+ err := h.db.DB().Where(
+ "webhook_id = ?", webhookID,
+ ).Find(&entrypoints).Error
+ if err != nil {
+ return nil, err
+ }
+
+ names := make(map[string]string, len(entrypoints))
+
+ for i := range entrypoints {
+ name := entrypoints[i].Description
+ if name == "" {
+ name = "Entrypoint"
+ }
+
+ names[entrypoints[i].ID] = name
+ }
+
+ return names, nil
+}
+
// trimPartialRune drops a trailing UTF-8 sequence that the
// byte-wise cut left incomplete, so a multi-byte rune severed
// at the cap does not surface as a mojibake tail.
diff --git a/internal/handlers/event_request_test.go b/internal/handlers/event_request_test.go
new file mode 100644
index 0000000..33001c5
--- /dev/null
+++ b/internal/handlers/event_request_test.go
@@ -0,0 +1,276 @@
+package handlers_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm/clause"
+ "sneak.berlin/go/webhooker/internal/database"
+)
+
+// arrivedAt is how a page names the entrypoint an event arrived at.
+func arrivedAt(name string) string {
+ return `Arrived at ` + name + ``
+}
+
+// originalArrivedAt is how a page names the entrypoint a resubmitted
+// copy's original arrived at.
+func originalArrivedAt(name string) string {
+ return `Its original arrived at ` +
+ name + ``
+}
+
+// headerLine is how a page shows one request header line.
+func headerLine(line string) string {
+ return `
` + line + `
`
+}
+
+// showHeadersLink is the event log's link to an event's own page for
+// request headers it leaves out.
+func showHeadersLink(webhookID, eventID string) string {
+ return `Show the request headers`
+}
+
+// entrypoint records one of the fixture webhook's entrypoints.
+func (f *recentEventsFixture) entrypoint(
+ t *testing.T, description string,
+) *database.Entrypoint {
+ t.Helper()
+
+ ep := &database.Entrypoint{
+ WebhookID: f.webhook.ID,
+ Path: uuid.NewString(),
+ Description: description,
+ Active: true,
+ }
+
+ require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
+
+ return ep
+}
+
+// eventAt records an event that arrived at the entrypoint with the
+// given request headers, stored as JSON as the receiver stores them.
+func (f *recentEventsFixture) eventAt(
+ t *testing.T,
+ ep *database.Entrypoint,
+ headersJSON string,
+ receivedAt time.Time,
+) *database.Event {
+ t.Helper()
+
+ event := &database.Event{
+ WebhookID: f.webhook.ID,
+ EntrypointID: ep.ID,
+ Method: http.MethodPost,
+ Headers: headersJSON,
+ Body: "{}",
+ BodyBytes: 2,
+ ContentType: contentTypeJSON,
+ }
+ event.CreatedAt = receivedAt
+
+ require.NoError(t, f.webhookDB.Omit(
+ clause.Associations,
+ ).Create(event).Error)
+
+ return event
+}
+
+// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
+// events that arrived at two entrypoints each show their own
+// entrypoint and request headers, in the event log and on their own
+// pages, with the headers sorted by name, escaped and keeping their
+// whitespace, and never the entrypoint's URL.
+func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ f := newRecentEventsFixture(t)
+ billing := f.entrypoint(t, "Billing sender")
+ unnamed := f.entrypoint(t, "")
+
+ // Stored in reverse name order.
+ older := f.eventAt(t, billing,
+ `{"X-Shop-Event":["order.created"],`+
+ `"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`,
+ time.Now().Add(-time.Minute))
+ newer := f.eventAt(t, unnamed,
+ `{"X-Shop-Event":["order.paid"],"X-Note":["hi"]}`,
+ time.Now())
+
+ olderShows := func(t *testing.T, page string) {
+ t.Helper()
+
+ const (
+ accept = "Accept: */*"
+ userAgent = "User-Agent: shop/1 build\t7"
+ shopEvent = "X-Shop-Event: order.created"
+ )
+
+ assert.Contains(t, page, arrivedAt("Billing sender"))
+ assert.Contains(t, page, headerLine(accept))
+ assert.Contains(t, page, headerLine(userAgent))
+ assert.Contains(t, page, headerLine(shopEvent))
+ assert.Less(t,
+ strings.Index(page, accept), strings.Index(page, userAgent),
+ "headers are sorted by name",
+ )
+ assert.Less(t,
+ strings.Index(page, userAgent), strings.Index(page, shopEvent),
+ "headers are sorted by name",
+ )
+ assert.NotContains(t, page, "order.paid")
+ assert.NotContains(t, page, billing.Path)
+ }
+
+ newerShows := func(t *testing.T, page string) {
+ t.Helper()
+
+ assert.Contains(t, page, arrivedAt("Entrypoint"))
+ assert.Contains(t, page, headerLine("X-Shop-Event: order.paid"))
+ assert.Contains(t, page, headerLine("X-Note: <b>hi</b>"))
+ assert.NotContains(t, page, "hi")
+ assert.NotContains(t, page, "order.created")
+ assert.NotContains(t, page, unnamed.Path)
+ }
+
+ // The log lists the newer event first, so everything between
+ // the two events' first mentions belongs to the newer one.
+ _, rest, found := strings.Cut(renderSourceLogsPage(
+ t, f.h, f.sess, f.webhook.ID,
+ ), newer.ID)
+ require.True(t, found)
+
+ newerPart, olderPart, found := strings.Cut(rest, older.ID)
+ require.True(t, found)
+
+ newerShows(t, newerPart)
+ olderShows(t, olderPart)
+
+ w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
+ require.Equal(t, http.StatusOK, w.Code)
+ newerShows(t, w.Body.String())
+
+ w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
+ require.Equal(t, http.StatusOK, w.Code)
+ olderShows(t, w.Body.String())
+}
+
+// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
+// has since been deleted says so in the event log and on its own page.
+func TestEventRequest_DeletedEntrypoint(t *testing.T) {
+ t.Parallel()
+
+ f := newRecentEventsFixture(t)
+ ep := f.entrypoint(t, "Retired sender")
+ event := f.eventAt(t, ep, `{}`, time.Now())
+
+ require.NoError(t, f.db.DB().Delete(ep).Error)
+
+ page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
+ assert.Contains(t, page, arrivedAt("deleted entrypoint"))
+ assert.NotContains(t, page, "Retired sender")
+
+ w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
+ assert.NotContains(t, w.Body.String(), "Retired sender")
+}
+
+// TestEventRequest_ResubmittedCopy proves a resubmitted copy says its
+// original arrived at the entrypoint, in the event log and on its own
+// page, and never that the copy did.
+func TestEventRequest_ResubmittedCopy(t *testing.T) {
+ t.Parallel()
+
+ f := newRecentEventsFixture(t)
+ ep := f.entrypoint(t, "Billing sender")
+ original := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
+ copied := f.eventAt(t, ep, `{}`, time.Now())
+
+ require.NoError(t, f.webhookDB.Model(copied).Update(
+ "resubmitted_from_id", original.ID,
+ ).Error)
+
+ // The log lists the copy first, and the original's Resubmit form
+ // comes after all of the copy and before the original's
+ // entrypoint.
+ copyPart, originalPart, found := strings.Cut(
+ renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
+ "/events/"+original.ID+"/resubmit",
+ )
+ require.True(t, found)
+
+ assert.Contains(t, copyPart, originalArrivedAt("Billing sender"))
+ assert.NotContains(t, copyPart, arrivedAt("Billing sender"))
+ assert.Contains(t, originalPart, arrivedAt("Billing sender"))
+ assert.NotContains(t, originalPart, originalArrivedAt("Billing sender"))
+
+ w := serveEventPage(t, f.h, f.sess, f.webhook.ID, copied.ID)
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), originalArrivedAt("Billing sender"))
+ assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
+}
+
+// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
+// request headers that hold more than it shows of a body, whether
+// stored or as lines, and links to the event's own page, which shows
+// them all.
+func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
+ t.Parallel()
+
+ // The receiver stores each "<" as six bytes of JSON, so this
+ // header is over the limit stored but not as a line.
+ const lessThans = bodyCap/6 + 1
+
+ // A header sent many times is stored with its name once, and
+ // shown with it on every line.
+ repeatedName := "X-Repeated-" + strings.Repeat("r", 1000)
+
+ tests := map[string]struct {
+ headers http.Header
+ line string
+ }{
+ "stored": {
+ headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}},
+ line: "X-Long: " + strings.Repeat("<", lessThans),
+ },
+ "as lines": {
+ headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)},
+ line: repeatedName + ": ",
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ headersJSON, err := json.Marshal(tc.headers)
+ require.NoError(t, err)
+
+ f := newRecentEventsFixture(t)
+ ep := f.entrypoint(t, "Billing sender")
+ event := f.eventAt(t, ep, string(headersJSON), time.Now())
+
+ page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
+ assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID))
+ assert.NotContains(t, page, tc.line)
+ assert.Less(t, len(page), 4*bodyCap)
+
+ w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), headerLine(tc.line))
+ assert.NotContains(t, w.Body.String(), "Show the request headers")
+ })
+ }
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index f132349..2b689e6 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -167,12 +167,12 @@ func New(
),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate(
- "source_logs.html", "event_body.html", "delivery_row.html",
- "delivery_attempts.html",
+ "source_logs.html", "event_request.html", "event_body.html",
+ "delivery_row.html", "delivery_attempts.html",
),
"event_detail.html": parsePageTemplate(
- "event_detail.html", "event_body.html", "delivery_row.html",
- "delivery_attempts.html",
+ "event_detail.html", "event_request.html", "event_body.html",
+ "delivery_row.html", "delivery_attempts.html",
),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go
index 6959aba..51ac794 100644
--- a/internal/handlers/source_management.go
+++ b/internal/handlers/source_management.go
@@ -1231,13 +1231,16 @@ func (h *Handlers) loadEventsWithDeliveries(
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 and how many times it has been resubmitted. Like
+// its deliveries, how many times it has been resubmitted and the
+// entrypoint it arrived at, and with its request headers only when
+// their lines hold at most maxHeaderBytes. Like
// loadEventsWithDeliveries, it reports false once it has answered
// the request with an error.
func (h *Handlers) eventLogViews(
@@ -1247,6 +1250,7 @@ func (h *Handlers) eventLogViews(
webhookID string,
rows []eventLogRow,
targetMap map[string]eventLogTarget,
+ maxHeaderBytes int,
) ([]EventLogView, bool) {
result := make([]EventLogView, len(rows))
eventDeliveries := make([][]database.Delivery, len(rows))
@@ -1256,7 +1260,7 @@ func (h *Handlers) eventLogViews(
eventIDs := make([]string, len(rows))
for i := range rows {
- result[i] = rows[i].view(webhookID)
+ result[i] = rows[i].view(webhookID, maxHeaderBytes)
eventIDs[i] = rows[i].ID
webhookDB.Where(
@@ -1290,11 +1294,25 @@ func (h *Handlers) eventLogViews(
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
@@ -1315,7 +1333,7 @@ func loadEventLogRows(
var rows []eventLogRow
webhookDB.Model(&database.Event{}).Select(
- eventLogColumns, maxRenderedBodyBytes,
+ eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
).Where(
"webhook_id = ?", webhookID,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
diff --git a/templates/event_detail.html b/templates/event_detail.html
index 3756cad..30756cb 100644
--- a/templates/event_detail.html
+++ b/templates/event_detail.html
@@ -50,6 +50,15 @@
+