check / check (push) Waiting to run
The receiver stored each event's request headers and entrypoint, but no page showed them, so with several entrypoints the operator could not tell which sender sent an event. An expanded event in the event log, and the event's own page, now show the entrypoint by its description ("Entrypoint" without one, "deleted entrypoint" once deleted), never its URL, and the headers as one escaped block, sorted, whitespace kept. A resubmitted copy says the request it copies arrived there. The event log reads at most 32 KiB of headers per event, the body's limit, and links to the event's page beyond it. Both pages share one template, `event_request.html`.
Model: opus-5-5
231 lines
6.8 KiB
Go
231 lines
6.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// eventLogColumns is the event log's projection. The casts to
|
|
// blob are load-bearing: they make substr and length count
|
|
// 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 or set of request headers never becomes a Go
|
|
// string at all.
|
|
const eventLogColumns = "id, created_at, method, content_type, " +
|
|
"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 and every request header.
|
|
const eventColumns = "id, created_at, method, content_type, " +
|
|
"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"
|
|
|
|
// EventLogView is the display-safe projection of an event for
|
|
// the event log page and the event's own page, alongside
|
|
// DeliveryView and TargetView.
|
|
type EventLogView struct {
|
|
ID string
|
|
Method string
|
|
ContentType string
|
|
|
|
// Received is how long ago the event arrived, and ReceivedUTC
|
|
// the full timestamp.
|
|
Received string
|
|
ReceivedUTC string
|
|
|
|
Body BodyView
|
|
|
|
// Entrypoint names the entrypoint the event arrived at. A
|
|
// resubmitted copy, even a copy of a copy, did not arrive; it
|
|
// names the one the request it copies arrived at. The name is
|
|
// the entrypoint's 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 as text, one
|
|
// "Name: value" line per value, sorted by name. HeadersCut
|
|
// reports headers left out because they hold more than
|
|
// maxRenderedBodyBytes, stored or as text; 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
|
|
|
|
// ResubmitCount is how many events have been resubmitted
|
|
// from this one. Both directions are shown, because after
|
|
// a few resubmits of one captured event the log is
|
|
// otherwise a row of identical bodies with nothing saying
|
|
// which came from which.
|
|
ResubmitCount int
|
|
|
|
Deliveries []DeliveryView
|
|
}
|
|
|
|
// ResubmittedFrom reports that this event is a copy of another.
|
|
func (v EventLogView) ResubmittedFrom() bool {
|
|
return v.ResubmittedFromID != ""
|
|
}
|
|
|
|
// eventLogRow is one row of the event log projection, or of
|
|
// 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. It shows the request headers when the row holds them
|
|
// whole and their text holds 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,
|
|
ContentType: r.ContentType,
|
|
Received: humanize.Time(r.CreatedAt),
|
|
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
|
Body: newBodyView(
|
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
|
),
|
|
Headers: strings.Join(headers, "\n"),
|
|
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, each with the newline that follows it, 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) + len("\n")
|
|
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.
|
|
//
|
|
// Bytes that are merely invalid UTF-8 are left exactly as
|
|
// stored: this service receives binary payloads, and rewriting
|
|
// them would misreport what was delivered. The distinction is
|
|
// utf8.FullRune's — it reports a complete sequence for an
|
|
// invalid encoding too, since that decodes to a width-1 error
|
|
// rune, so only a valid prefix still waiting for its
|
|
// continuation bytes is removed. A tail with no rune start in
|
|
// its last utf8.UTFMax bytes cannot be an incomplete sequence
|
|
// either, and is likewise left alone.
|
|
func trimPartialRune(b []byte) []byte {
|
|
for i := len(b) - 1; i >= 0 && len(b)-i <= utf8.UTFMax; i-- {
|
|
if !utf8.RuneStart(b[i]) {
|
|
continue
|
|
}
|
|
|
|
if utf8.FullRune(b[i:]) {
|
|
return b
|
|
}
|
|
|
|
return b[:i]
|
|
}
|
|
|
|
return b
|
|
}
|