From 0a570b7e7cc4fdb1dd669b4c0878fffdf717ff75 Mon Sep 17 00:00:00 2001 From: NanamiAdmin Date: Thu, 24 Sep 2026 12:32:22 +0800 Subject: [PATCH] feat(webhook): implement management API for incoming webhook endpoints --- README.md | 8 +- config/settings.go | 13 ++- config/webhook.go | 198 +++++++++++++++++++++++++++++++++++ handler/webhook_endpoints.go | 132 +++++++++++++++++++++++ router.go | 9 +- 5 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 config/webhook.go create mode 100644 handler/webhook_endpoints.go diff --git a/README.md b/README.md index d41590e..b66f0fa 100644 --- a/README.md +++ b/README.md @@ -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: [...], 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=` + 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/` | 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: ``` diff --git a/config/settings.go b/config/settings.go index 46ed373..5cc91e8 100644 --- a/config/settings.go +++ b/config/settings.go @@ -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{}{} diff --git a/config/webhook.go b/config/webhook.go new file mode 100644 index 0000000..37feedc --- /dev/null +++ b/config/webhook.go @@ -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.. +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 +} diff --git a/handler/webhook_endpoints.go b/handler/webhook_endpoints.go new file mode 100644 index 0000000..726195d --- /dev/null +++ b/handler/webhook_endpoints.go @@ -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(), + }) +} diff --git a/router.go b/router.go index c6cf2bb..5ccb805 100644 --- a/router.go +++ b/router.go @@ -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")