check / check (push) Successful in 4m45s
The receiver dropped the query string of every request it received, so a sender's URL parameters were silently lost. Each event now keeps it, as sent, in a new `raw_query` column of the per-webhook `events` table; a resubmitted copy carries its original's. The event log and the event's page show it in the shared request block, the event log leaving out one over 32 KiB with a link, as for headers. The archive and log targets carry it. HTTP targets gain "Pass the query string on to this target", off by default: on, deliveries, replays and resubmits append it to the target URL, joined with `&` to one already there. The access log still hides it. Model: opus-5-5
253 lines
7.5 KiB
Go
253 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"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, 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, 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 and the event's own page, alongside
|
|
// DeliveryView and TargetView.
|
|
type EventLogView struct {
|
|
ID string
|
|
Method string
|
|
ContentType string
|
|
|
|
// Received is how long ago the event arrived, and ReceivedUTC
|
|
// the full timestamp.
|
|
Received string
|
|
ReceivedUTC string
|
|
|
|
Body BodyView
|
|
|
|
// 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.
|
|
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 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 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,
|
|
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.
|
|
//
|
|
// 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
|
|
}
|