Files
webhooker/internal/delivery/target_config_view.go
T
clawbot 48176e6425
check / check (push) Successful in 3m21s
Remove an http target's max_queue_size (closes #477)
Nothing in delivery ever read it, so setting it bounded nothing. The
field, the "Max Queue Size" line in the target list, its README row and
the tests that set or checked it are gone. An existing database keeps
its old column, which is no longer read.

Model: opus-5-5
2026-10-02 23:23:58 +00:00

258 lines
6.8 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),
),
})
}
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.
func maxRetriesField(t *database.Target) ConfigField {
retries := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
retries += " (fire-and-forget)"
}
return ConfigField{
Label: "Max Retries",
Value: retries,
}
}
// databaseConfigFields describes an archive target by its
// expiry in plain units, such as "30 days", or "never" when
// the archive is kept forever. An expiry that is set but not
// a valid duration is reported as unavailable rather than
// echoed back.
func databaseConfigFields(configJSON string) []ConfigField {
expiry, err := parseArchiveExpiry(configJSON)
if err != nil {
return unavailableConfigFields()
}
value := archiveExpiryNever
if expiry > 0 {
value = plainDuration(expiry)
}
return []ConfigField{{
Label: "Archive Expiry",
Value: value,
}}
}
// 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)
}