package handlers import ( "time" "unicode/utf8" "github.com/dustin/go-humanize" ) // 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, " + "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, " + "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 // 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 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, 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, ), ResubmittedFromID: from, } } // 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 }