Show an event's entrypoint and request headers (closes #389)
check / check (push) Successful in 3m19s

An expanded event in the event log and the event's own page now show
the entrypoint the event arrived at (its description, "Entrypoint"
when it has none, or "deleted entrypoint"; never its URL) and the
full request headers, one per line, sorted by name. Both pages draw
them through one shared template, event_request.html. The event
columns now load entrypoint_id and headers, and the webhook's
entrypoints are read once per page.

Model: opus-5-5
This commit is contained in:
2026-10-03 01:19:09 +00:00
parent bcdd4791ec
commit 5bfe5e74bd
8 changed files with 287 additions and 6 deletions
+77 -2
View File
@@ -1,8 +1,13 @@
package handlers
import (
"encoding/json"
"net/http"
"slices"
"time"
"unicode/utf8"
"sneak.berlin/go/webhooker/internal/database"
)
// eventLogColumns is the event log's projection. The casts to
@@ -12,14 +17,14 @@ import (
// rather than in Go is the point of the projection — an
// oversized body never becomes a Go string at all.
const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"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.
const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
@@ -34,6 +39,15 @@ type EventLogView struct {
Body BodyView
// Entrypoint names the entrypoint the event 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.
Headers []string
// ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver.
ResubmittedFromID string
@@ -63,6 +77,8 @@ type eventLogRow struct {
Method string
ContentType string
ResubmittedFromID *string
EntrypointID string
Headers string
Body []byte
BodyBytes int64
}
@@ -83,10 +99,69 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
Headers: requestHeaderLines(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 show as none.
func requestHeaderLines(headersJSON string) []string {
var headers http.Header
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
return nil
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var lines []string
for _, name := range names {
for _, value := range headers[name] {
lines = append(lines, name+": "+value)
}
}
return lines
}
// 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.