package delivery import ( "encoding/json" "fmt" "strconv" "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 } // 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 Type database.TargetType Active bool Config []ConfigField } // NewTargetViews projects targets for rendering, replacing // each stored configuration blob with named, display-safe // fields. 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, 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.Config) 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. 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() } return []ConfigField{{ Label: "Webhook URL", Value: cfg.MaskedWebhookURL(), }} } // 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), ), }) } 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 { fields = append(fields, ConfigField{ Label: "Max Queue Size", Value: strconv.Itoa(t.MaxQueueSize), }) } 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 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 } } return []ConfigField{{ Label: "Archive Expiry", Value: expiry, }} } // 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) }