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
275 lines
7.3 KiB
Go
275 lines
7.3 KiB
Go
package delivery
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// configUnavailable is what a target's configuration renders
|
|
// as when it is absent, of an unknown type, or does not
|
|
// parse. The stored blob is never shown as a fallback: it can
|
|
// hold a credential (a Slack incoming webhook URL is a bearer
|
|
// token) and a UI that prints it leaks that credential into
|
|
// browser history, screenshots and screen shares.
|
|
const configUnavailable = "(unavailable)"
|
|
|
|
// ConfigField is one labelled, display-safe value derived
|
|
// from a target's stored configuration.
|
|
type ConfigField struct {
|
|
Label string
|
|
Value string
|
|
}
|
|
|
|
// deletedNameSuffix marks the name of a target that no longer
|
|
// exists. Deletes are soft and delivery history outlives the
|
|
// target, so the event log shows names of targets that are gone;
|
|
// an operator reading one needs to know it cannot be delivered
|
|
// to, replayed to, or configured.
|
|
const deletedNameSuffix = " (deleted)"
|
|
|
|
// TargetView is the display-safe projection of a target for
|
|
// the UI. It deliberately has no raw configuration field, so
|
|
// no template — present or future — can render the stored
|
|
// blob.
|
|
type TargetView struct {
|
|
ID string
|
|
Name string
|
|
|
|
// Deleted reports that this target's row is soft deleted.
|
|
// Only views built for historical display carry it set:
|
|
// every other projection is of a live row.
|
|
Deleted bool
|
|
|
|
Type database.TargetType
|
|
Active bool
|
|
Config []ConfigField
|
|
}
|
|
|
|
// DisplayName is the name to render, marked when the target has
|
|
// been deleted. Templates showing a name against historical data
|
|
// must use it rather than Name, which stays the stored name.
|
|
func (v TargetView) DisplayName() string {
|
|
if v.Deleted {
|
|
return v.Name + deletedNameSuffix
|
|
}
|
|
|
|
return v.Name
|
|
}
|
|
|
|
// NewTargetViews projects targets for rendering, replacing
|
|
// each stored configuration blob with named, display-safe
|
|
// fields.
|
|
//
|
|
// A soft-deleted row projects exactly as a live one does, minus
|
|
// the deleted marker on its name: masking is a property of the
|
|
// projection, not of the row's state, so a deleted target's
|
|
// credential is as unreachable from a template as a live
|
|
// target's.
|
|
func NewTargetViews(
|
|
targets []database.Target,
|
|
) []TargetView {
|
|
views := make([]TargetView, 0, len(targets))
|
|
|
|
for i := range targets {
|
|
t := &targets[i]
|
|
|
|
views = append(views, TargetView{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
Deleted: t.DeletedAt.Valid,
|
|
Type: t.Type,
|
|
Active: t.Active,
|
|
Config: targetConfigFields(t),
|
|
})
|
|
}
|
|
|
|
return views
|
|
}
|
|
|
|
// targetConfigFields returns the display-safe fields for a
|
|
// target's configuration. Anything it cannot parse becomes
|
|
// the neutral placeholder.
|
|
func targetConfigFields(
|
|
t *database.Target,
|
|
) []ConfigField {
|
|
switch t.Type {
|
|
case database.TargetTypeSlack:
|
|
return slackConfigFields(t)
|
|
case database.TargetTypeHTTP:
|
|
return httpConfigFields(t)
|
|
case database.TargetTypeDatabase:
|
|
return databaseConfigFields(t.Config)
|
|
case database.TargetTypeLog:
|
|
// The log target takes no configuration.
|
|
return nil
|
|
default:
|
|
return unavailableConfigFields()
|
|
}
|
|
}
|
|
|
|
// unavailableConfigFields is the neutral placeholder shown
|
|
// for a configuration that could not be presented.
|
|
func unavailableConfigFields() []ConfigField {
|
|
return []ConfigField{{
|
|
Label: "Configuration",
|
|
Value: configUnavailable,
|
|
}}
|
|
}
|
|
|
|
// slackConfigFields describes a Slack target: its masked
|
|
// webhook URL and its retry count. Only the masked URL is
|
|
// shown; the full URL is the credential.
|
|
func slackConfigFields(t *database.Target) []ConfigField {
|
|
cfg, err := parseSlackConfig(t.Config)
|
|
if err != nil {
|
|
return unavailableConfigFields()
|
|
}
|
|
|
|
return []ConfigField{{
|
|
Label: "Webhook URL",
|
|
Value: cfg.MaskedWebhookURL(),
|
|
}, maxRetriesField(t)}
|
|
}
|
|
|
|
// httpConfigFields describes an HTTP target: its destination
|
|
// and its retry settings. Header values are not shown — they
|
|
// routinely carry authorization tokens — only how many are
|
|
// configured.
|
|
//
|
|
// The destination is masked to scheme and host by the same
|
|
// rule the Slack target uses. An HTTP target's destination is
|
|
// commonly a Slack, Discord or Teams incoming-webhook endpoint
|
|
// whose path segments are the credential, and the field takes
|
|
// an arbitrary URL, so no segment can be assumed non-secret.
|
|
func httpConfigFields(t *database.Target) []ConfigField {
|
|
cfg, err := parseHTTPConfig(t.Config)
|
|
if err != nil {
|
|
return unavailableConfigFields()
|
|
}
|
|
|
|
fields := []ConfigField{{
|
|
Label: "Destination URL",
|
|
Value: MaskURL(cfg.URL),
|
|
}}
|
|
|
|
if cfg.Timeout > 0 {
|
|
fields = append(fields, ConfigField{
|
|
Label: "Timeout",
|
|
Value: strconv.Itoa(cfg.Timeout) + "s",
|
|
})
|
|
}
|
|
|
|
if len(cfg.Headers) > 0 {
|
|
fields = append(fields, ConfigField{
|
|
Label: "Headers",
|
|
Value: fmt.Sprintf(
|
|
"%d configured", len(cfg.Headers),
|
|
),
|
|
})
|
|
}
|
|
|
|
if cfg.ForwardQuery {
|
|
fields = append(fields, ConfigField{
|
|
Label: "Query string",
|
|
Value: "passed on to this target",
|
|
})
|
|
}
|
|
|
|
fields = append(fields, maxRetriesField(t))
|
|
|
|
return fields
|
|
}
|
|
|
|
// maxRetriesField describes a target's retry count, which lives
|
|
// on the target row rather than in its configuration blob. A
|
|
// stored 0 makes a single attempt, so it is shown as 1.
|
|
func maxRetriesField(t *database.Target) ConfigField {
|
|
attempts := strconv.Itoa(t.MaxRetries)
|
|
if t.MaxRetries == 0 {
|
|
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
|
|
}
|
|
|
|
return ConfigField{
|
|
Label: "Delivery attempts",
|
|
Value: attempts,
|
|
}
|
|
}
|
|
|
|
// databaseConfigFields describes an archive target by its
|
|
// expiry in plain units, such as "30 days", or "never" when
|
|
// the archive is kept forever, and by its rotation. An expiry
|
|
// that is set but not a valid duration, or a rotation that is
|
|
// not one of the four, is reported as unavailable rather than
|
|
// echoed back.
|
|
func databaseConfigFields(configJSON string) []ConfigField {
|
|
expiry, err := parseArchiveExpiry(configJSON)
|
|
if err != nil {
|
|
return unavailableConfigFields()
|
|
}
|
|
|
|
rotation, err := parseArchiveRotation(configJSON)
|
|
if err != nil {
|
|
return unavailableConfigFields()
|
|
}
|
|
|
|
value := archiveExpiryNever
|
|
if expiry > 0 {
|
|
value = plainDuration(expiry)
|
|
}
|
|
|
|
return []ConfigField{{
|
|
Label: "Archive expiry",
|
|
Value: value,
|
|
}, {
|
|
Label: "Archive rotation",
|
|
Value: rotation,
|
|
}}
|
|
}
|
|
|
|
// plainDuration writes a positive duration as a count of the
|
|
// largest whole unit it divides into: "30 days", "12 hours",
|
|
// "1 minute". A duration with a fraction of a second is
|
|
// written as Go writes it.
|
|
func plainDuration(d time.Duration) string {
|
|
const day = 24 * time.Hour
|
|
|
|
units := []struct {
|
|
size time.Duration
|
|
name string
|
|
}{
|
|
{day, "day"},
|
|
{time.Hour, "hour"},
|
|
{time.Minute, "minute"},
|
|
{time.Second, "second"},
|
|
}
|
|
|
|
for _, unit := range units {
|
|
if d%unit.size != 0 {
|
|
continue
|
|
}
|
|
|
|
count := int64(d / unit.size)
|
|
if count == 1 {
|
|
return "1 " + unit.name
|
|
}
|
|
|
|
return fmt.Sprintf("%d %ss", count, unit.name)
|
|
}
|
|
|
|
return d.String()
|
|
}
|
|
|
|
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
|
// its scheme and host, with the path, query and any userinfo
|
|
// elided. The path segments are the credential, so none of
|
|
// them is shown: the field accepts an arbitrary URL, so no
|
|
// segment can be assumed non-secret. A URL that does not
|
|
// parse into a scheme and host yields the neutral
|
|
// placeholder, never the raw string.
|
|
func (c *SlackTargetConfig) MaskedWebhookURL() string {
|
|
return MaskURL(c.WebhookURL)
|
|
}
|