feat: add /api/settings/get, /api/settings/set to modify config in frontend
This commit is contained in:
@@ -166,6 +166,11 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
|
|||||||
// deterministic across writes. The path is supplied by the caller (typically
|
// deterministic across writes. The path is supplied by the caller (typically
|
||||||
// global.ConfigPath.BotNodeConfig).
|
// global.ConfigPath.BotNodeConfig).
|
||||||
func SaveBotNodeConfig(configPath string, uuids []string) error {
|
func SaveBotNodeConfig(configPath string, uuids []string) error {
|
||||||
|
// Serialize against admin edits of the same file via UpdateSettings so the
|
||||||
|
// two read-modify-write paths cannot drop each other's changes.
|
||||||
|
settingsLock.Lock()
|
||||||
|
defer settingsLock.Unlock()
|
||||||
|
|
||||||
// Start from whatever is already on disk so nothing is dropped. An empty or
|
// Start from whatever is already on disk so nothing is dropped. An empty or
|
||||||
// missing file is treated as an empty map.
|
// missing file is treated as an empty map.
|
||||||
members := make(BotNodeMembers)
|
members := make(BotNodeMembers)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"nukumizu-backend/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings types accepted by the /api/settings/get and /api/settings/set
|
||||||
|
// endpoints. Each maps 1:1 to a JSON configuration file on disk.
|
||||||
|
const (
|
||||||
|
SettingGlobal = "global"
|
||||||
|
SettingBotUserConfig = "bot_user_config"
|
||||||
|
SettingBotNodeConfig = "bot_node_config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrUnsupportedSettingsType is returned when a settings type is not one of the
|
||||||
|
// accepted constants above.
|
||||||
|
var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
|
||||||
|
|
||||||
|
// settingsLock serializes read-modify-write access to the on-disk configuration
|
||||||
|
// files so concurrent admin edits (UpdateSettings) and the node tracker's
|
||||||
|
// background save (SaveBotNodeConfig) cannot lose each other's updates.
|
||||||
|
var settingsLock sync.RWMutex
|
||||||
|
|
||||||
|
// settingsPath resolves a settings type to its JSON configuration file path.
|
||||||
|
func settingsPath(settingsType string) (string, error) {
|
||||||
|
switch settingsType {
|
||||||
|
case SettingGlobal:
|
||||||
|
return global.ConfigPath.Global, nil
|
||||||
|
case SettingBotUserConfig:
|
||||||
|
return global.ConfigPath.BotUserConfig, nil
|
||||||
|
case SettingBotNodeConfig:
|
||||||
|
return global.ConfigPath.BotNodeConfig, nil
|
||||||
|
default:
|
||||||
|
return "", ErrUnsupportedSettingsType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidSettingsType reports whether the given string is one of the accepted
|
||||||
|
// settings types.
|
||||||
|
func IsValidSettingsType(settingsType string) bool {
|
||||||
|
_, err := settingsPath(settingsType)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings returns the raw JSON of the file backing the given settings type,
|
||||||
|
// byte-for-byte the same content as the source file. bot_node_config.json is
|
||||||
|
// optional and auto-generated, so a missing or empty file yields an empty
|
||||||
|
// object.
|
||||||
|
func GetSettings(settingsType string) ([]byte, error) {
|
||||||
|
path, err := settingsPath(settingsType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsLock.RLock()
|
||||||
|
defer settingsLock.RUnlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) && settingsType == SettingBotNodeConfig {
|
||||||
|
return []byte("{}"), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read %s settings file: %w", settingsType, err)
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(data)) == 0 {
|
||||||
|
return []byte("{}"), nil
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings merges the given partial update into the JSON file backing the
|
||||||
|
// given settings type and persists the result back to disk. Because the merge is
|
||||||
|
// recursive, a payload such as {"system":{"debugMode":true}} only touches the
|
||||||
|
// nested keys it names and leaves every sibling key untouched. After the file is
|
||||||
|
// written the matching in-memory singleton is reloaded so runtime code observes
|
||||||
|
// the new values.
|
||||||
|
func UpdateSettings(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{}{}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
if len(bytes.TrimSpace(data)) > 0 {
|
||||||
|
if err := json.Unmarshal(data, ¤t); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deepMergeSettings(current, patch)
|
||||||
|
|
||||||
|
data, err = json.MarshalIndent(current, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return reloadSettings(settingsType, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepMergeSettings recursively overlays src onto dst. Object values merge
|
||||||
|
// key-by-key so partial updates keep sibling keys untouched; arrays and scalars
|
||||||
|
// always replace the destination value.
|
||||||
|
func deepMergeSettings(dst, src map[string]interface{}) {
|
||||||
|
for key, srcVal := range src {
|
||||||
|
srcObj, srcIsObj := srcVal.(map[string]interface{})
|
||||||
|
if srcIsObj {
|
||||||
|
if dstObj, ok := dst[key].(map[string]interface{}); ok {
|
||||||
|
deepMergeSettings(dstObj, srcObj)
|
||||||
|
} else {
|
||||||
|
dst[key] = srcVal
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dst[key] = srcVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadSettings refreshes the in-memory singleton for the given settings type
|
||||||
|
// so the running program observes the values just persisted to disk.
|
||||||
|
func reloadSettings(settingsType, path string) error {
|
||||||
|
switch settingsType {
|
||||||
|
case SettingGlobal:
|
||||||
|
_, err := LoadGlobalConfig(path)
|
||||||
|
return err
|
||||||
|
case SettingBotUserConfig:
|
||||||
|
_, err := LoadBotUserConfig(path)
|
||||||
|
return err
|
||||||
|
case SettingBotNodeConfig:
|
||||||
|
return LoadBotNodeConfig(path)
|
||||||
|
default:
|
||||||
|
return ErrUnsupportedSettingsType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/postLog"
|
||||||
|
"nukumizu-backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsGetHandler handles GET /api/settings/get?type=xxx.
|
||||||
|
// type selects which config file to return and may be one of
|
||||||
|
// "global", "bot_user_config" or "bot_node_config"; the returned "config"
|
||||||
|
// object has the same layout as the source JSON file.
|
||||||
|
func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !utils.Auth(w, r, "GET", "admin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsType := r.URL.Query().Get("type")
|
||||||
|
if settingsType == "" {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "missing type parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !config.IsValidSettingsType(settingsType) {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid type parameter: "+settingsType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := config.GetSettings(settingsType)
|
||||||
|
if err != nil {
|
||||||
|
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||||
|
"config": json.RawMessage(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsSetHandler handles POST /api/settings/set?type=xxx.
|
||||||
|
// type selects which config file to update and may be one of "global",
|
||||||
|
// "bot_user_config" or "bot_node_config". The JSON body is a partial config
|
||||||
|
// object whose keys map directly to config entries, e.g.
|
||||||
|
//
|
||||||
|
// {"system": {"debugMode": true}}
|
||||||
|
//
|
||||||
|
// Multiple entries may be given at once; only the provided keys are changed.
|
||||||
|
func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !utils.Auth(w, r, "POST", "admin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsType := r.URL.Query().Get("type")
|
||||||
|
if settingsType == "" {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "missing type parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !config.IsValidSettingsType(settingsType) {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid type parameter: "+settingsType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.UseNumber() // Keep numeric values verbatim (e.g. QQ IDs) instead of float64.
|
||||||
|
var patch map[string]interface{}
|
||||||
|
if err := dec.Decode(&patch); err != nil {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if patch == nil {
|
||||||
|
utils.SendErrorResponse(w, http.StatusBadRequest, "request body must be a JSON object")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.UpdateSettings(settingsType, patch); err != nil {
|
||||||
|
postLog.Error("Failed to update " + settingsType + " settings: " + err.Error())
|
||||||
|
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{
|
||||||
|
"type": settingsType,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ func SetupRouter() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/server/getStatus", handler.ServerGetStatusHandler)
|
mux.HandleFunc("/api/server/getStatus", handler.ServerGetStatusHandler)
|
||||||
mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)
|
mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)
|
||||||
|
|
||||||
|
// Settings endpoints (admin only).
|
||||||
|
mux.HandleFunc("/api/settings/get", handler.SettingsGetHandler)
|
||||||
|
mux.HandleFunc("/api/settings/set", handler.SettingsSetHandler)
|
||||||
|
|
||||||
// Health check endpoint.
|
// Health check endpoint.
|
||||||
mux.HandleFunc("/health", handler.HealthHandler)
|
mux.HandleFunc("/health", handler.HealthHandler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user