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 // 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 never becomes a Go string at all. const eventLogColumns = "id, created_at, method, content_type, " + "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, entrypoint_id, headers, " + "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 CreatedAt time.Time Method string ContentType string 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 // 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 body column arrives // already cut to the cap by SQLite, with the true size beside // it. type eventLogRow struct { ID string CreatedAt time.Time Method string ContentType string ResubmittedFromID *string EntrypointID string Headers string Body []byte BodyBytes int64 } // view projects a loaded row of the webhook's events for // rendering. func (r *eventLogRow) view(webhookID string) EventLogView { var from string if r.ResubmittedFromID != nil { from = *r.ResubmittedFromID } return EventLogView{ ID: r.ID, CreatedAt: r.CreatedAt, Method: r.Method, ContentType: r.ContentType, 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. // // 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 }