Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user