Files
webhooker/internal/handlers/event_log_view.go
T
clawbot 719d7013ee
check / check (push) Waiting to run
Give each event its own page and show bodies the same everywhere (closes #369)
Each event now has its own page at /hook/ID/events/EVENTID, behind the login, showing its details, its whole body and every delivery; a resubmitted copy links to its original's page. The recent events on the webhook page link there and expand to show their bodies, only the newest expanded on load. One renderer and one template show a body the same way in the recent events, the event log and the event's page: whole up to 32 KiB, cut there in the two lists with links to the event's page and the download; JSON pretty-printed unless that would grow it past four times plus 1 KiB; over 200 lines in a scrolling box; a body holding NUL or control characters treated as binary and never dumped raw.

Model: opus-5-5
2026-10-02 22:00:42 +02:00

118 lines
3.5 KiB
Go

package handlers
import (
"time"
"unicode/utf8"
)
// 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
CreatedAt time.Time
Method string
ContentType 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,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
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
}