199 lines
6.6 KiB
Go
199 lines
6.6 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Errors reported by the incoming webhook endpoint helpers. The HTTP layer maps
|
|
// them onto statuses: exists -> 409, not found -> 404, invalid -> 400.
|
|
var (
|
|
// ErrWebhookEndpointExists is returned by AddWebhookEndpoint when the name
|
|
// is already configured.
|
|
ErrWebhookEndpointExists = errors.New("webhook endpoint already exists")
|
|
|
|
// ErrWebhookEndpointNotFound is returned when the named endpoint is not
|
|
// configured.
|
|
ErrWebhookEndpointNotFound = errors.New("webhook endpoint not found")
|
|
|
|
// ErrWebhookEndpointInvalid is returned when a name or field supplied for an
|
|
// endpoint cannot be stored.
|
|
ErrWebhookEndpointInvalid = errors.New("invalid webhook endpoint")
|
|
)
|
|
|
|
// webhookEndpointFields are the endpoint keys a client may set. A field that is
|
|
// absent from an update is left untouched; a field that is present but not
|
|
// listed here is rejected rather than written, so a typo cannot leave an
|
|
// endpoint silently ignoring a setting.
|
|
var webhookEndpointFields = map[string]func(interface{}) bool{
|
|
"enabled": isJSONBool,
|
|
"token": isJSONString,
|
|
"notifyPipes": isJSONStringArray,
|
|
}
|
|
|
|
// WebhookEndpoints returns the configured incoming webhook endpoints keyed by
|
|
// name, as a copy: changing the result does not change the loaded
|
|
// configuration.
|
|
func WebhookEndpoints() map[string]WebhookEndpointConfig {
|
|
endpoints := map[string]WebhookEndpointConfig{}
|
|
if C_globalConfig == nil {
|
|
return endpoints
|
|
}
|
|
for name, endpoint := range C_globalConfig.Webhook.Endpoints {
|
|
endpoints[name] = endpoint
|
|
}
|
|
return endpoints
|
|
}
|
|
|
|
// AddWebhookEndpoint registers a new incoming webhook endpoint under name. Only
|
|
// the fields present in fields are set, so an endpoint can be created with
|
|
// default values and completed later by ModifyWebhookEndpoint. Unlike
|
|
// ModifyWebhookEndpoint it refuses to touch an endpoint that already exists.
|
|
func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
|
|
if err := validateWebhookEndpointName(name); err != nil {
|
|
return err
|
|
}
|
|
patch, err := webhookEndpointPatch(fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if _, exists := webhookEndpoint(name); exists {
|
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
|
|
}
|
|
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
|
}
|
|
|
|
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
|
|
// fields present in fields are changed; every other field keeps its configured
|
|
// value.
|
|
func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
|
|
if err := validateWebhookEndpointName(name); err != nil {
|
|
return err
|
|
}
|
|
patch, err := webhookEndpointPatch(fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(patch) == 0 {
|
|
return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid)
|
|
}
|
|
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if _, exists := webhookEndpoint(name); !exists {
|
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
|
}
|
|
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
|
}
|
|
|
|
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
|
|
// name. The endpoint stops accepting requests as soon as the configuration is
|
|
// reloaded.
|
|
func DeleteWebhookEndpoint(name string) error {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if _, exists := webhookEndpoint(name); !exists {
|
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
|
}
|
|
|
|
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
|
|
}
|
|
|
|
// webhookEndpoint returns the named endpoint held by the loaded configuration.
|
|
// No lock is needed to read it: a reload replaces the whole configuration
|
|
// rather than mutating it in place, and the value is read from whichever
|
|
// version is current.
|
|
func webhookEndpoint(name string) (WebhookEndpointConfig, bool) {
|
|
if C_globalConfig == nil {
|
|
return WebhookEndpointConfig{}, false
|
|
}
|
|
endpoint, exists := C_globalConfig.Webhook.Endpoints[name]
|
|
return endpoint, exists
|
|
}
|
|
|
|
// webhookEndpointsPatch wraps the fields of one endpoint into the nested patch
|
|
// the settings merge expects for webhook.endpoints.<name>.
|
|
func webhookEndpointsPatch(name string, fields map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"webhook": map[string]interface{}{
|
|
"endpoints": map[string]interface{}{name: fields},
|
|
},
|
|
}
|
|
}
|
|
|
|
// webhookEndpointDeletePatch is the patch that removes an endpoint. The value is
|
|
// a null, which the settings merge reads as "delete this key". It must be an
|
|
// untyped nil: a nil map of type map[string]interface{} would be merged as an
|
|
// empty object instead, leaving the endpoint in the configuration.
|
|
func webhookEndpointDeletePatch(name string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"webhook": map[string]interface{}{
|
|
"endpoints": map[string]interface{}{name: nil},
|
|
},
|
|
}
|
|
}
|
|
|
|
// webhookEndpointPatch validates the fields of one endpoint and returns them as
|
|
// the value to merge. Fields not accepted for an endpoint are rejected instead
|
|
// of being written to the configuration file.
|
|
func webhookEndpointPatch(fields map[string]interface{}) (map[string]interface{}, error) {
|
|
patch := make(map[string]interface{}, len(fields))
|
|
for key, value := range fields {
|
|
accepts, known := webhookEndpointFields[key]
|
|
if !known {
|
|
return nil, fmt.Errorf("%w: unknown field %q", ErrWebhookEndpointInvalid, key)
|
|
}
|
|
if !accepts(value) {
|
|
return nil, fmt.Errorf("%w: field %q has the wrong type", ErrWebhookEndpointInvalid, key)
|
|
}
|
|
patch[key] = value
|
|
}
|
|
return patch, nil
|
|
}
|
|
|
|
// validateWebhookEndpointName checks that a name can address an endpoint. The
|
|
// name is the last segment of the endpoint URL, so a name containing a slash
|
|
// could never be reached.
|
|
func validateWebhookEndpointName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("%w: name must not be empty", ErrWebhookEndpointInvalid)
|
|
}
|
|
if strings.Contains(name, "/") {
|
|
return fmt.Errorf("%w: name must not contain %q", ErrWebhookEndpointInvalid, "/")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The predicates below accept the decoded JSON types a field may carry. Numbers
|
|
// decoded with UseNumber stay json.Number, so a JSON true/false is the only
|
|
// value accepted for a boolean field.
|
|
func isJSONBool(value interface{}) bool {
|
|
_, ok := value.(bool)
|
|
return ok
|
|
}
|
|
|
|
func isJSONString(value interface{}) bool {
|
|
_, ok := value.(string)
|
|
return ok
|
|
}
|
|
|
|
func isJSONStringArray(value interface{}) bool {
|
|
items, ok := value.([]interface{})
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, item := range items {
|
|
if _, ok := item.(string); !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|