feat(webhook): implement management API for incoming webhook endpoints

This commit is contained in:
2026-09-24 12:32:22 +08:00
parent 3ce42b3cca
commit 3877c6d128
5 changed files with 355 additions and 5 deletions
+7 -1
View File
@@ -322,6 +322,10 @@ Browser WebSocket handshakes cannot carry custom headers, so `/api/system/getLog
| `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `data: {taskID, results}`. |
| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` | Returns `data: {config}`, where `config` is the selected config file's content (same layout as the JSON file). |
| `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. |
| `/api/webhook/add` | POST | admin | Add an incoming webhook endpoint. Body `{name, enabled?, token?, notifyPipes?}` — only the fields given are stored, the rest start at their defaults. `409` when the name is already configured. |
| `/api/webhook/modify` | POST | admin | Change an existing endpoint. Body `{name, ...}` — the fields given are the fields that change (same partial-update rule as `/api/settings/set`, but scoped to one endpoint). `404` for an unknown name, `400` when no other field is given. |
| `/api/webhook/delete` | POST | admin | Remove an endpoint. Body `{name}`. `404` for an unknown name. |
| `/api/webhook/list` | GET | admin | Every configured incoming webhook endpoint, keyed by name, under `data.endpoints`. |
| `/health` | GET | None | Health check. Returns `data: {status, database}`. |
| `/api/system/getLogs` | WebSocket | admin | Streams logs. Sends the last 100 buffered entries, then live `{level, content, timestamp}` events. Credentials via `X-Token`/`X-Timestamp` headers or `?token=`/`?timestamp=` query parameters; a failed check answers with the JSON error and no upgrade. |
@@ -340,7 +344,7 @@ A listener of its own, so external applications can be pointed at it without bei
|---|---|---|---|
| `/api/webhook/<name>` | POST | Endpoint token | Relay an alert to the channels the endpoint lists in `notifyPipes`. Body `{token, subject, content}`. Returns `data: {endpoint, channels}`. |
Every entry under `webhook.endpoints` is one endpoint, addressed by its key as the last path segment: the key `example` is served at `POST /api/webhook/example`. An endpoint holds:
Every entry under `webhook.endpoints` is one endpoint, addressed by its key as the last path segment: the key `example` is served at `POST /api/webhook/example`. Endpoints are managed over the admin API (`/api/webhook/add`, `modify`, `delete` and `list` — see [Endpoints](#endpoints)), which writes the same `webhook.endpoints` section of `config.json`; a newly added endpoint accepts requests as soon as the configuration is reloaded, without a restart. An endpoint holds:
| Field | Meaning |
|---|---|
@@ -348,6 +352,8 @@ Every entry under `webhook.endpoints` is one endpoint, addressed by its key as t
| `token` | Shared secret the caller sends as the `token` body field; compared in constant time. An endpoint with an empty token answers `500` instead of accepting requests from anyone. |
| `notifyPipes` | The channels the alert is delivered to, named as in `controllerMethod`: `qq(napcat)`, `telegram`, `email`, `ntfy`, `webhook`. A channel that is unknown or disabled is skipped and reported. |
The management API accepts exactly these three fields. A request naming any other field, or giving one of them the wrong type (`enabled` must be a boolean, `token` a string, `notifyPipes` an array of strings), is refused with `400` instead of being written to `config.json` — a field the program does not understand must not end up in the file. A `name` must be non-empty and free of `/`, since it becomes the last segment of the endpoint URL.
The alert is rendered per channel as:
```
+10 -3
View File
@@ -82,14 +82,21 @@ func GetSettings(settingsType string) ([]byte, error) {
// written the matching in-memory singleton is reloaded so runtime code observes
// the new values.
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
settingsLock.Lock()
defer settingsLock.Unlock()
return updateSettingsLocked(settingsType, patch)
}
// updateSettingsLocked is UpdateSettings without the locking, for callers that
// need to inspect the loaded configuration and write in one critical section
// (see the incoming webhook endpoint helpers). Callers must hold settingsLock.
func updateSettingsLocked(settingsType string, patch map[string]interface{}) error {
path, err := settingsPath(settingsType)
if err != nil {
return err
}
settingsLock.Lock()
defer settingsLock.Unlock()
// Start from whatever is already on disk so nothing is dropped. A missing or
// empty file is treated as an empty object.
current := map[string]interface{}{}
+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
}
+132
View File
@@ -0,0 +1,132 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"nukumizu-backend/config"
"nukumizu-backend/utils"
)
// The incoming webhook endpoints are managed from the admin API below. They
// live in the same listener as the rest of the admin API — unlike the endpoints
// they configure, which are served on the webhook listener (see webhook.go).
//
// Every handler takes a JSON object naming the endpoint, arranged the same way
// as /api/settings/set: whatever fields the request carries are the fields that
// change, and everything else keeps its configured value. Only the fields an
// endpoint actually has are accepted, so a misspelled field is reported instead
// of being written to the configuration file.
// decodeWebhookEndpointRequest authenticates an admin request, decodes its JSON
// object body, and splits it into the endpoint name and the remaining fields.
// It answers the request itself and reports ok == false when anything is wrong.
func decodeWebhookEndpointRequest(w http.ResponseWriter, r *http.Request) (name string, fields map[string]interface{}, ok bool) {
if !utils.Auth(w, r, "POST", "admin") {
return "", nil, false
}
dec := json.NewDecoder(r.Body)
dec.UseNumber() // Keep values verbatim, as /api/settings/set does.
var body map[string]interface{}
if err := dec.Decode(&body); err != nil {
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object")
return "", nil, false
}
if body == nil {
utils.SendErrorResponse(w, http.StatusBadRequest, "request body must be a JSON object")
return "", nil, false
}
rawName, present := body["name"]
if !present {
utils.SendErrorResponse(w, http.StatusBadRequest, "missing required parameter: name")
return "", nil, false
}
name, isString := rawName.(string)
if !isString {
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid parameter: name must be a string")
return "", nil, false
}
delete(body, "name")
return name, body, true
}
// sendWebhookEndpointError maps the errors of the endpoint helpers onto the
// matching HTTP responses.
func sendWebhookEndpointError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, config.ErrWebhookEndpointExists):
utils.SendErrorResponse(w, http.StatusConflict, err.Error())
case errors.Is(err, config.ErrWebhookEndpointNotFound):
utils.SendErrorResponse(w, http.StatusNotFound, err.Error())
case errors.Is(err, config.ErrWebhookEndpointInvalid):
utils.SendErrorResponse(w, http.StatusBadRequest, err.Error())
default:
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to update webhook endpoints: "+err.Error())
}
}
// WebhookAddHandler handles POST /api/webhook/add.
// Body: {name, ...fields}. The endpoint must not exist yet; the fields given are
// stored and any field left out starts at its default (disabled, no token, no
// notify pipes).
func WebhookAddHandler(w http.ResponseWriter, r *http.Request) {
name, fields, ok := decodeWebhookEndpointRequest(w, r)
if !ok {
return
}
if err := config.AddWebhookEndpoint(name, fields); err != nil {
sendWebhookEndpointError(w, err)
return
}
utils.SendSuccessResponse(w, "webhook endpoint added", map[string]interface{}{"name": name})
}
// WebhookModifyHandler handles POST /api/webhook/modify.
// Body: {name, ...fields}. Only the fields given are changed.
func WebhookModifyHandler(w http.ResponseWriter, r *http.Request) {
name, fields, ok := decodeWebhookEndpointRequest(w, r)
if !ok {
return
}
if err := config.ModifyWebhookEndpoint(name, fields); err != nil {
sendWebhookEndpointError(w, err)
return
}
utils.SendSuccessResponse(w, "webhook endpoint updated", map[string]interface{}{"name": name})
}
// WebhookDeleteHandler handles POST /api/webhook/delete.
// Body: {name}.
func WebhookDeleteHandler(w http.ResponseWriter, r *http.Request) {
name, _, ok := decodeWebhookEndpointRequest(w, r)
if !ok {
return
}
if err := config.DeleteWebhookEndpoint(name); err != nil {
sendWebhookEndpointError(w, err)
return
}
utils.SendSuccessResponse(w, "webhook endpoint deleted", map[string]interface{}{"name": name})
}
// WebhookListHandler handles GET /api/webhook/list.
// Returns every configured incoming webhook endpoint, keyed by name.
func WebhookListHandler(w http.ResponseWriter, r *http.Request) {
if !utils.Auth(w, r, "GET", "admin") {
return
}
utils.SendSuccessResponse(w, "", map[string]interface{}{
"endpoints": config.WebhookEndpoints(),
})
}
+8 -1
View File
@@ -30,6 +30,13 @@ func SetupRouter() *http.ServeMux {
mux.HandleFunc("/api/settings/get", handler.SettingsGetHandler)
mux.HandleFunc("/api/settings/set", handler.SettingsSetHandler)
// Incoming webhook endpoint management (admin only). These configure the
// endpoints served by SetupWebhookRouter, which runs on its own listener.
mux.HandleFunc("/api/webhook/add", handler.WebhookAddHandler)
mux.HandleFunc("/api/webhook/modify", handler.WebhookModifyHandler)
mux.HandleFunc("/api/webhook/delete", handler.WebhookDeleteHandler)
mux.HandleFunc("/api/webhook/list", handler.WebhookListHandler)
// Health check endpoint.
mux.HandleFunc("/health", handler.HealthHandler)
@@ -62,7 +69,7 @@ func SetupWebhookRouter() *http.ServeMux {
// The wildcard segment selects the endpoint; requests for a name that is not
// configured fall through to the handler, which answers with a JSON 404.
mux.HandleFunc("/api/webhook/{name}", handler.WebhookHandler)
mux.HandleFunc("/api/webhook/post/{name}", handler.WebhookHandler)
mux.HandleFunc("/", NotFoundHandler)
postLog.Info("Webhook router setup completed")