diff --git a/config/config.go b/config/config.go index e25d073..78049a7 100644 --- a/config/config.go +++ b/config/config.go @@ -166,6 +166,11 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) { // deterministic across writes. The path is supplied by the caller (typically // global.ConfigPath.BotNodeConfig). 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 // missing file is treated as an empty map. members := make(BotNodeMembers) diff --git a/config/settings.go b/config/settings.go new file mode 100644 index 0000000..4295607 --- /dev/null +++ b/config/settings.go @@ -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 + } +} diff --git a/handler/settings.go b/handler/settings.go new file mode 100644 index 0000000..d48a9fe --- /dev/null +++ b/handler/settings.go @@ -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, + }) +} diff --git a/router.go b/router.go index f0ad92c..c015d44 100644 --- a/router.go +++ b/router.go @@ -23,6 +23,10 @@ func SetupRouter() *http.ServeMux { mux.HandleFunc("/api/server/getStatus", handler.ServerGetStatusHandler) 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. mux.HandleFunc("/health", handler.HealthHandler)