feat(webhook): implement management API for incoming webhook endpoints
This commit is contained in:
@@ -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
@@ -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{}{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user