Give each event its own page and show bodies the same everywhere (closes #369)
check / check (push) Successful in 3m20s

Each row of the recent events on the webhook page links to the
event's own page, /hook/{id}/events/{eventID}, and expands to show its
body; only the newest starts expanded. The event's page shows its
details, its whole body and every delivery with its attempts.

One renderer, newBodyView with templates/event_body.html, shows a body
in all three places: whole up to 32 KiB, cut there in the lists with a
link to the event's page, JSON pretty-printed, a body of more than 200
lines or 32 KiB in a scrolling box, and a body that is not text left
out beside its download link. A resubmitted copy links to its
original's page.

Model: opus-5-5
This commit is contained in:
clawbot
2026-10-02 18:33:27 +00:00
parent 9526e961b5
commit f01e5c45ad
23 changed files with 1009 additions and 172 deletions
+150
View File
@@ -0,0 +1,150 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"io"
"unicode"
"unicode/utf8"
)
// maxRenderedBodyBytes is the most of one event's body that the
// recent events on a webhook's page and the event log show; a larger
// body is cut there and shown whole only on the event's own page.
// Bodies come from the unauthenticated receiver under its 1 MB cap,
// and renderTemplate buffers a whole page before writing it, so a list
// of events cannot show every body whole.
const maxRenderedBodyBytes = 32 << 10
// maxInlineBodyLines is the most lines a body is shown at its full
// height with. A body with more lines, or larger than
// maxRenderedBodyBytes, is shown in a box of fixed height that
// scrolls, so that it does not make the page huge.
const maxInlineBodyLines = 200
// maxIndentDepth is how deeply a JSON body's objects and arrays may
// nest for it to be pretty-printed; a deeper one is shown as received.
// Each level indents every line inside it two more spaces, so 10 KB of
// nested brackets would indent to some 50 MB; within this depth a body
// grows at most 35 times.
const maxIndentDepth = 16
// jsonIndent is the indent of a pretty-printed JSON body.
const jsonIndent = " "
// BodyView is an event's body as the pages show it. newBodyView
// decides it and templates/event_body.html shows it, the same way in
// the recent events on a webhook's page, in the event log and on the
// event's own page.
type BodyView struct {
// EventURL is the event's own page. The stored body downloads
// from EventURL/body.
EventURL string
// Text is the body as shown, pretty-printed when it is JSON.
Text string
// Size is the stored body's size in bytes, and ShownBytes how
// many of them Text holds when Cut.
Size int64
ShownBytes int
// Cut reports that Text is only the start of the body.
Cut bool
// Binary reports a body that is not text. It is not shown.
Binary bool
// Scroll reports a body to show in a box that scrolls.
Scroll bool
}
// newBodyView decides how to show an event's body. body is the
// stored body, or its first maxRenderedBodyBytes when only those were
// read, and size is the stored body's size.
func newBodyView(eventURL string, body []byte, size int64) BodyView {
v := BodyView{EventURL: eventURL, Size: size}
if size > int64(len(body)) {
v.Cut = true
body = trimPartialRune(body)
v.ShownBytes = len(body)
}
// html/template shows invalid UTF-8 as replacement characters,
// and a browser shows a control character other than tab, line
// feed and carriage return as a box or not at all, so a body
// holding either is not text.
isControl := func(r rune) bool {
return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r'
}
if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 {
v.Binary = true
return v
}
// A cut JSON document is no longer valid JSON.
if !v.Cut {
body = indentJSON(body)
}
// A final newline ends the last line rather than starting another.
lines := bytes.Count(bytes.TrimSuffix(body, []byte("\n")), []byte("\n")) + 1
v.Text = string(body)
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
return v
}
// indentJSON returns body pretty-printed when it is a JSON document,
// and unchanged when it is not or nests deeper than maxIndentDepth.
func indentJSON(body []byte) []byte {
if !json.Valid(body) || !indentFits(body) {
return body
}
var out bytes.Buffer
err := json.Indent(&out, body, "", jsonIndent)
if err != nil {
return body
}
return out.Bytes()
}
// indentFits reports whether the objects and arrays of the JSON
// document body nest at most maxIndentDepth deep.
func indentFits(body []byte) bool {
depth := 0
dec := json.NewDecoder(bytes.NewReader(body))
// A number too large for a float64 is still valid JSON.
dec.UseNumber()
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
return true
}
if err != nil {
return false
}
switch tok {
case json.Delim('{'), json.Delim('['):
depth++
if depth > maxIndentDepth {
return false
}
case json.Delim('}'), json.Delim(']'):
depth--
}
}
}