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 }