All checks were successful
check / check (push) Successful in 3m3s
Capturing real webhook traffic and firing it repeatedly at a backend under development is a primary function of this service, and per-delivery replay cannot do it: it only ever resolves the delivery's own original target, so a target created for a dev backend has no prior delivery and nothing can be replayed to it. The event log now offers a per-event Resubmit action. It stores a NEW event copying the stored one's method, headers, body and content type verbatim, and fans it out to the webhook's currently ACTIVE targets, resolved fresh by the query the receiver uses -- so a target created long after the original event arrived receives it. The original event's deliveries have no bearing on where the copy goes, inactive targets are skipped as the receiver skips them, and the action is repeatable: replay's in-flight refusal is deliberately not ported, because firing one captured event over and over is the point. The receiver and the resubmit path share one construction and one fan-out site. An eventSource value carries where the fields came from, live request or stored event, and createAndFanOut writes the event and its pending deliveries in one transaction and hands the tasks to the same Notifier, so a resubmitted delivery is retried, SSRF-guarded and circuit-broken exactly as a first one is. buildDeliveryTasks returns an error instead of writing a response, which is what lets both callers share it. The stored event is read once, before the write transaction, with a cast to blob, so a body over delivery.MaxInlineBodySize is copied byte for byte and the engine loads it from the new event row. A nullable resubmitted_from_id records provenance -- empty for an event that arrived on the receiver -- and the event log reports the relationship in both directions, without which the log is unreadable after a few resubmits of one event. The route sits in the owned-source group, so auth, CSRF and the body cap apply, with its own rate limit bucket and an events_resubmitted_total counter. Inbound signature verification is not re-run: there is no inbound signature to check on a copy an authenticated, CSRF-protected operator action submits. Per-delivery replay is unchanged; it serves recovery, which resubmit does not replace. The README claimed in four places that replay was unimplemented, one of them telling the operator that a delivery stranded by a target type change was lost; all four are corrected and resubmit is documented beside replay.
145 lines
4.4 KiB
Go
145 lines
4.4 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, " +
|
|
"resubmitted_from_id, " +
|
|
"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
|
|
|
|
// 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 != ""
|
|
}
|
|
|
|
// 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
|
|
ResubmittedFromID *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)
|
|
}
|
|
|
|
var from string
|
|
if r.ResubmittedFromID != nil {
|
|
from = *r.ResubmittedFromID
|
|
}
|
|
|
|
return EventLogView{
|
|
ID: r.ID,
|
|
CreatedAt: r.CreatedAt,
|
|
Method: r.Method,
|
|
ContentType: r.ContentType,
|
|
Body: string(body),
|
|
BodyBytes: r.BodyBytes,
|
|
BodyTruncated: truncated,
|
|
ResubmittedFromID: from,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|