Files
webhooker/internal/handlers/event_log_view.go
clawbot 4a89e4088e
All checks were successful
check / check (push) Successful in 3m16s
Bound the event log's rendered bodies in the query (closes #135)
templates/source_logs.html rendered {{.Body}} untruncated. Bodies come
from the unauthenticated receiver under a 1 MB ingest cap, and since
renderTemplate started buffering a page instead of streaming it, a
25-event page of maximal bodies is tens of megabytes of resident memory
per concurrent viewer — inflated further by HTML escaping.

The cut happens in SQL, not in the template: loadEventsWithDeliveries
now selects substr(cast(body as blob), 1, 8192) with
length(cast(body as blob)) beside it, so an oversized body never
becomes a Go string at all. Truncating template-side would still
materialise the whole value and miss the point. The casts to blob make
substr and length count bytes rather than characters, so the bound
holds for any encoding.

Events reach the page as EventLogView, alongside the existing
DeliveryView and TargetView projections, carrying BodyTruncated and
BodyBytes so the page shows a marker with the true stored size.

SQLite cuts at an arbitrary byte, so trimPartialRune drops a trailing
sequence the cut left incomplete. Bytes that are merely invalid UTF-8 —
binary payloads, which this service receives — are left exactly as
stored: utf8.FullRune reports a complete sequence for an invalid
encoding too, so only a valid prefix awaiting its continuation bytes is
removed, and a tail with no rune start in its last utf8.UTFMax bytes is
untouched. A body that was not cut is never repaired.

Also corrects the executeTemplate comment that claimed these pages are
small.
2026-08-17 20:44:32 +00:00

121 lines
3.6 KiB
Go

package handlers
import (
"time"
"unicode/utf8"
)
// maxRenderedBodyBytes caps how many bytes of a stored event
// body reach the event log page. Bodies come from the
// unauthenticated receiver under the 1 MB ingest cap and
// renderTemplate buffers a whole page before writing it, so
// an uncapped page of paginationPerPage events is tens of
// megabytes of resident memory per concurrent viewer.
const maxRenderedBodyBytes = 8192
// 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, " +
"substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// EventLogView is the display-safe projection of an event for
// the event log page, alongside DeliveryView and TargetView.
// It carries a capped body plus the true stored size, so the
// page can mark a body as truncated without ever holding the
// whole thing.
type EventLogView struct {
ID string
CreatedAt time.Time
Method string
ContentType string
// Body holds at most maxRenderedBodyBytes bytes of the
// stored body.
Body string
// BodyBytes is the true size of the stored body.
BodyBytes int64
// BodyTruncated reports that the stored body was larger
// than the cap, so the page owes the reader a marker.
BodyTruncated bool
Deliveries []DeliveryView
}
// BodyShownBytes is how many body bytes the page is actually
// rendering, which the truncation marker reports beside the
// true size.
func (v EventLogView) BodyShownBytes() int {
return len(v.Body)
}
// eventLogRow is one row of the event log projection. 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
Body []byte
BodyBytes int64
}
// view projects a loaded row for rendering.
func (r *eventLogRow) view() EventLogView {
body := r.Body
truncated := r.BodyBytes > int64(len(body))
// Only a cut body can have been left mid-sequence by
// this query. A whole body is passed through exactly as
// stored, however malformed.
if truncated {
body = trimPartialRune(body)
}
return EventLogView{
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: string(body),
BodyBytes: r.BodyBytes,
BodyTruncated: truncated,
}
}
// 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
}