feat(webhook): implement management API for incoming webhook endpoints
Build / windows-latest (push) Failing after 2m40s
Build / ubuntu-latest (push) Failing after 3m23s

This commit is contained in:
2026-09-24 12:32:22 +08:00
parent f5ec232e5d
commit 0a570b7e7c
5 changed files with 355 additions and 5 deletions
+198
View File
@@ -0,0 +1,198 @@
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
}