Deploy main eacf1e7 to prod
check / check (push) Canceled after 0s

Replaces #559, which conflicted.

Model: opus-5-5
This commit is contained in:
2026-10-25 01:55:36 +00:00
parent 1647b43aa6
commit 537374e4cc
311 changed files with 35614 additions and 14120 deletions
+160 -52
View File
@@ -1,50 +1,80 @@
package handlers
import (
"encoding/json"
"net/http"
"slices"
"strings"
"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
"github.com/dustin/go-humanize"
"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.
// oversized body, query string or set of request headers never
// becomes a Go string at all.
const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " +
"resubmitted_from_id, entrypoint_id, " +
"substr(cast(raw_query as blob), 1, ?) AS raw_query, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, " +
"substr(cast(headers as blob), 1, ?) AS headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"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, the whole query string and every request
// header.
const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, raw_query, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"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, 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.
// 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 holds at most maxRenderedBodyBytes bytes of the
// stored body.
Body string
// Received is how long ago the event arrived, and ReceivedUTC
// the full timestamp.
Received string
ReceivedUTC string
// BodyBytes is the true size of the stored body.
BodyBytes int64
Body BodyView
// BodyTruncated reports that the stored body was larger
// than the cap, so the page owes the reader a marker.
BodyTruncated bool
// Entrypoint names the entrypoint the event arrived at. A
// resubmitted copy, even a copy of a copy, did not arrive; it
// names the one the request it copies arrived at. The name is
// the entrypoint's description, "Entrypoint" when it has none,
// or "deleted entrypoint", never its URL, which is the
// entrypoint's secret.
Entrypoint string
// RawQuery is the query string the event arrived with.
// RawQueryCut reports one left out, RawQuery then empty, because
// it holds more than maxRenderedBodyBytes; only the event log
// leaves it out.
RawQuery string
RawQueryCut bool
// Headers is the event's request headers as text, one
// "Name: value" line per value, sorted by name. HeadersCut
// reports headers left out because they hold more than
// maxRenderedBodyBytes, stored or as text; only the event log
// leaves them out.
Headers string
HeadersCut bool
// ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver.
@@ -65,55 +95,133 @@ func (v EventLogView) ResubmittedFrom() bool {
return v.ResubmittedFromID != ""
}
// 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.
// eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its query string, headers and
// body columns arrive already cut to the cap by SQLite, each with
// its true size beside it.
type eventLogRow struct {
ID string
CreatedAt time.Time
Method string
ContentType string
ResubmittedFromID *string
EntrypointID string
RawQuery string
RawQueryBytes int64
Headers string
HeadersBytes int64
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)
}
// view projects a loaded row of the webhook's events for
// rendering. It shows the request headers when the row holds them
// whole and their text holds at most maxHeaderBytes.
func (r *eventLogRow) view(
webhookID string, maxHeaderBytes int,
) EventLogView {
var from string
if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID
}
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
rawQuery := r.RawQuery
rawQueryCut := r.RawQueryBytes > int64(len(rawQuery))
if rawQueryCut {
rawQuery = ""
}
return EventLogView{
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: string(body),
BodyBytes: r.BodyBytes,
BodyTruncated: truncated,
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,
),
RawQuery: rawQuery,
RawQueryCut: rawQueryCut,
Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(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, as when the event log
// has cut them, show as none. It reports false, with no lines, when
// the lines, each with the newline that follows it, would hold more
// than maxBytes: a header sent many times is stored with its name
// once but shown with it on every line.
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
var headers http.Header
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
return nil, true
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var lines []string
size := 0
for _, name := range names {
for _, value := range headers[name] {
line := name + ": " + value
size += len(line) + len("\n")
if size > maxBytes {
return nil, false
}
lines = append(lines, line)
}
}
return lines, true
}
// 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.