Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
@@ -1,23 +1,25 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"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.
|
||||
// configUnavailable is what a target's settings render as when
|
||||
// the target is of an unknown type, and what its URL renders as
|
||||
// when it does not parse into a scheme and host. The stored URL is
|
||||
// never shown as a fallback: it can be 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.
|
||||
// from a target's settings.
|
||||
type ConfigField struct {
|
||||
Label string
|
||||
Value string
|
||||
@@ -31,9 +33,9 @@ type ConfigField struct {
|
||||
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.
|
||||
// the UI. It deliberately has no URL or header field, so no
|
||||
// template — present or future — can render the stored
|
||||
// credential.
|
||||
type TargetView struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -60,8 +62,7 @@ func (v TargetView) DisplayName() string {
|
||||
}
|
||||
|
||||
// NewTargetViews projects targets for rendering, replacing
|
||||
// each stored configuration blob with named, display-safe
|
||||
// fields.
|
||||
// each target's settings 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
|
||||
@@ -90,47 +91,37 @@ func NewTargetViews(
|
||||
}
|
||||
|
||||
// targetConfigFields returns the display-safe fields for a
|
||||
// target's configuration. Anything it cannot parse becomes
|
||||
// the neutral placeholder.
|
||||
// target's settings. A target of an unknown type gets the
|
||||
// neutral placeholder.
|
||||
func targetConfigFields(
|
||||
t *database.Target,
|
||||
) []ConfigField {
|
||||
switch t.Type {
|
||||
case database.TargetTypeSlack:
|
||||
return slackConfigFields(t.Config)
|
||||
return slackConfigFields(t)
|
||||
case database.TargetTypeHTTP:
|
||||
return httpConfigFields(t)
|
||||
case database.TargetTypeDatabase:
|
||||
return databaseConfigFields(t.Config)
|
||||
return databaseConfigFields(t)
|
||||
case database.TargetTypeLog:
|
||||
// The log target takes no configuration.
|
||||
// The log target takes no settings.
|
||||
return nil
|
||||
default:
|
||||
return unavailableConfigFields()
|
||||
return []ConfigField{{
|
||||
Label: "Configuration",
|
||||
Value: configUnavailable,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// 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. Only the masked
|
||||
// webhook URL is shown; the full URL is the credential.
|
||||
func slackConfigFields(configJSON string) []ConfigField {
|
||||
cfg, err := parseSlackConfig(configJSON)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
// slackConfigFields describes a Slack target: its masked
|
||||
// webhook URL and its number of delivery attempts. Only the
|
||||
// masked URL is shown; the full URL is the credential.
|
||||
func slackConfigFields(t *database.Target) []ConfigField {
|
||||
return []ConfigField{{
|
||||
Label: "Webhook URL",
|
||||
Value: cfg.MaskedWebhookURL(),
|
||||
}}
|
||||
Value: MaskURL(t.URL),
|
||||
}, maxAttemptsField(t)}
|
||||
}
|
||||
|
||||
// httpConfigFields describes an HTTP target: its destination
|
||||
@@ -144,96 +135,95 @@ func slackConfigFields(configJSON string) []ConfigField {
|
||||
// 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),
|
||||
Value: MaskURL(t.URL),
|
||||
}}
|
||||
|
||||
if cfg.Timeout > 0 {
|
||||
if t.TimeoutSeconds > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Timeout",
|
||||
Value: strconv.Itoa(cfg.Timeout) + "s",
|
||||
Value: strconv.Itoa(t.TimeoutSeconds) + "s",
|
||||
})
|
||||
}
|
||||
|
||||
if len(cfg.Headers) > 0 {
|
||||
if len(t.Headers) > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Headers",
|
||||
Value: fmt.Sprintf(
|
||||
"%d configured", len(cfg.Headers),
|
||||
"%d configured", len(t.Headers),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return append(fields, retryFields(t)...)
|
||||
}
|
||||
|
||||
// retryFields describes a target's retry settings, which live
|
||||
// on the target row rather than in its configuration blob.
|
||||
func retryFields(t *database.Target) []ConfigField {
|
||||
retries := strconv.Itoa(t.MaxRetries)
|
||||
if t.MaxRetries == 0 {
|
||||
retries += " (fire-and-forget)"
|
||||
}
|
||||
|
||||
fields := []ConfigField{{
|
||||
Label: "Max Retries",
|
||||
Value: retries,
|
||||
}}
|
||||
|
||||
if t.MaxQueueSize > 0 {
|
||||
if t.ForwardQuery {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Max Queue Size",
|
||||
Value: strconv.Itoa(t.MaxQueueSize),
|
||||
Label: "Query string",
|
||||
Value: "passed on to this target",
|
||||
})
|
||||
}
|
||||
|
||||
fields = append(fields, maxAttemptsField(t))
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// databaseConfigFields describes an archive target. Its
|
||||
// configuration is optional, and an absent or empty expiry
|
||||
// means 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 := archiveExpiryNever
|
||||
// maxAttemptsField describes a target's number of delivery
|
||||
// attempts.
|
||||
func maxAttemptsField(t *database.Target) ConfigField {
|
||||
return ConfigField{
|
||||
Label: "Delivery attempts",
|
||||
Value: strconv.Itoa(t.MaxAttempts),
|
||||
}
|
||||
}
|
||||
|
||||
if configJSON != "" {
|
||||
var cfg databaseTargetConfig
|
||||
|
||||
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
if cfg.Expiry != "" {
|
||||
if ValidateArchiveExpiry(cfg.Expiry) != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
expiry = cfg.Expiry
|
||||
}
|
||||
// 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.
|
||||
func databaseConfigFields(t *database.Target) []ConfigField {
|
||||
value := archive.ExpiryNever
|
||||
if t.ArchiveExpiry > 0 {
|
||||
value = plainDuration(t.ArchiveExpiry)
|
||||
}
|
||||
|
||||
return []ConfigField{{
|
||||
Label: "Archive Expiry",
|
||||
Value: expiry,
|
||||
Label: "Archive expiry",
|
||||
Value: value,
|
||||
}, {
|
||||
Label: "Archive rotation",
|
||||
Value: t.ArchiveRotation,
|
||||
}}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user