diff --git a/README.md b/README.md index 6430ebe..8913c3a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ nukumizu-backend/ │ └── variables.go # Software build metadata (name/version/developer) ├── handler/ │ ├── user.go # /api/user/login, /api/user/register -│ ├── server.go # /api/server/list, getStatus, exec +│ ├── server.go # /api/server/list, getInfo, getStatus, exec +│ ├── settings.go # /api/settings/get, set │ └── health.go # /health ├── database/ │ └── user.go # user.db (SQLite) user store @@ -276,8 +277,11 @@ Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any aut | `/api/user/login` | POST | None | Log in. Body `{username, password}`. Returns `{token, userID, username, level, registerDate}`. | | `/api/user/register` | POST | None | Register the first user. Body `{username, password}`. Only allowed while no user exists; otherwise `403`. Returns `{token, userID, username, level}`. | | `/api/server/list` | GET | bot / admin | List all monitored servers. | -| `/api/server/getStatus` | GET | bot / admin | Recent live status for a server. Query `?uuid=`. Returns `{uuid, report}` or `404`. | +| `/api/server/getInfo` | GET | admin | Static server info (mirrors the Bot's `/info`). Query `?uuid=` (or `all`). Returns `{success, : {uuid, name, info}}` — one entry per requested server. `404` for an unknown single uuid. | +| `/api/server/getStatus` | GET | admin | Live server status (mirrors the Bot's `/status`). Query `?uuid=` (or `all`). Returns `{success, : {uuid, name, online, report}}`; `report` is `null` when the node has not reported yet. `404` for an unknown single uuid. | | `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `{taskID, results}`. | +| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` | Returns `{success, 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. | | `/health` | GET | None | Health check. Returns `{status, database}`. | | `/api/system/getLogs` | WebSocket | None | Streams logs. Sends the last 100 buffered entries, then live `{level, content, timestamp}` events. | diff --git a/config/settings_test.go b/config/settings_test.go new file mode 100644 index 0000000..028d4d4 --- /dev/null +++ b/config/settings_test.go @@ -0,0 +1,178 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "nukumizu-backend/global" +) + +// writeTempConfig writes content to a fresh temp file and points the matching +// global.ConfigPath field at it, returning a cleanup that restores the original. +func writeTempConfig(t *testing.T, field *string, content string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write temp config: %v", err) + } + original := *field + *field = path + t.Cleanup(func() { *field = original }) +} + +func TestUpdateSettingsDeepMerge(t *testing.T) { + writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{ + "qq(napcat)": { + "admins": { + "3526453517": { + "event_status_notify": true, + "event_bot_started": true + } + }, + "trustedGroups": { + "740724778": { + "event_status_notify": true, + "event_bot_started": false + } + } + } +}`) + + patch := map[string]interface{}{ + "qq(napcat)": map[string]interface{}{ + "admins": map[string]interface{}{ + "3526453517": map[string]interface{}{ + "event_status_notify": false, // toggle an existing nested flag + "event_reply": true, // add a key that is not in the file + }, + }, + "trustedGroups": map[string]interface{}{ + "12345": map[string]interface{}{ // add a whole new member + "event_bot_started": true, + }, + }, + }, + } + if err := UpdateSettings(SettingBotUserConfig, patch); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + data, err := GetSettings(SettingBotUserConfig) + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + got := string(data) + + for _, want := range []string{ + `"event_status_notify": false`, + `"event_reply": true`, + `"event_bot_started": true`, + `"event_status_notify": true`, // sibling under trustedGroups 740724778 kept + `"12345"`, + } { + if !strings.Contains(got, want) { + t.Errorf("merged config missing %q:\n%s", want, got) + } + } + + // The in-memory singleton must reflect the merged file too. + if C_botUserConfig == nil { + t.Fatal("C_botUserConfig not reloaded") + } + if C_botUserConfig.QQ.Admins["3526453517"].EventStatusNotify { + t.Error("expected reloaded admin event_status_notify = false") + } + if !C_botUserConfig.QQ.Admins["3526453517"].EventReply { + t.Error("expected reloaded admin event_reply = true") + } + if !C_botUserConfig.QQ.TrustedGroups["12345"].EventBotStarted { + t.Error("expected new trusted group event_bot_started = true") + } +} + +func TestUpdateSettingsReplacesArraysAndKeepsNumbers(t *testing.T) { + writeTempConfig(t, &global.ConfigPath.Global, `{ + "system": { + "debugMode": true, + "listenPort": "8080" + }, + "controllerMethod": { + "email": { + "enabled": false, + "smtpHost": "smtp.example.com", + "smtpPort": 587, + "to": ["old@example.com"] + } + } +}`) + + patch := map[string]interface{}{ + "system": map[string]interface{}{ + "debugMode": false, // partial: listenPort must survive + }, + "controllerMethod": map[string]interface{}{ + "email": map[string]interface{}{ + "enabled": true, + "smtpPort": json.Number("465"), + "to": []interface{}{"new@example.com"}, // arrays replace, not merge + }, + }, + } + if err := UpdateSettings(SettingGlobal, patch); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + data, err := GetSettings(SettingGlobal) + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + got := string(data) + + for _, want := range []string{ + `"debugMode": false`, + `"listenPort": "8080"`, // sibling untouched + `"smtpHost": "smtp.example.com"`, // sibling untouched + `"smtpPort": 465`, // number kept verbatim, not 465.0 + `"new@example.com"`, + } { + if !strings.Contains(got, want) { + t.Errorf("merged config missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "old@example.com") { + t.Errorf("array was merged instead of replaced:\n%s", got) + } +} + +func TestSettingsTypeValidation(t *testing.T) { + for _, valid := range []string{SettingGlobal, SettingBotUserConfig, SettingBotNodeConfig} { + if !IsValidSettingsType(valid) { + t.Errorf("expected %q to be a valid settings type", valid) + } + } + for _, invalid := range []string{"", "system", "node", "bot"} { + if IsValidSettingsType(invalid) { + t.Errorf("expected %q to be an invalid settings type", invalid) + } + if _, err := GetSettings(invalid); err != ErrUnsupportedSettingsType { + t.Errorf("GetSettings(%q) error = %v, want ErrUnsupportedSettingsType", invalid, err) + } + } +} + +func TestGetSettingsBotNodeMissingFile(t *testing.T) { + // Point at a temp path that does not exist yet. + writeTempConfig(t, &global.ConfigPath.BotNodeConfig, "") + os.Remove(global.ConfigPath.BotNodeConfig) + + data, err := GetSettings(SettingBotNodeConfig) + if err != nil { + t.Fatalf("GetSettings on missing bot_node_config: %v", err) + } + if string(data) != "{}" { + t.Errorf("expected empty object for missing bot_node_config, got %s", data) + } +} diff --git a/handler/server.go b/handler/server.go index 9ca4ae8..4efd1b6 100644 --- a/handler/server.go +++ b/handler/server.go @@ -18,6 +18,38 @@ type ServerExecRequest struct { Command string `json:"command"` } +// ServerInfoValue is the per-server payload returned by GET /api/server/getInfo, +// mirroring the static server info the Bot prints for /info. +type ServerInfoValue struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Info *node.Info `json:"info"` +} + +// ServerStatusValue is the per-server payload returned by GET /api/server/getStatus, +// mirroring the live status the Bot prints for /status. Report is null when the +// node is known but has not delivered a status report yet. +type ServerStatusValue struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Online bool `json:"online"` + Report *node.Report `json:"report"` +} + +// collectTargetNodes resolves the uuid query parameter into the list of nodes +// the caller asked for. uuid "all" selects every tracked node; any other uuid +// selects that single node. A boolean reports whether the uuid was found. +func collectTargetNodes(tracker *node.Tracker, uuid string) ([]*node.Node, bool) { + if uuid == "all" { + return tracker.GetAllNodes(), true + } + n, exists := tracker.GetNode(uuid) + if !exists { + return nil, false + } + return []*node.Node{n}, true +} + // ServerListHandler handles GET /api/server/list. func ServerListHandler(w http.ResponseWriter, r *http.Request) { if !utils.Auth(w, r, "GET", "bot") { @@ -38,9 +70,11 @@ func ServerListHandler(w http.ResponseWriter, r *http.Request) { }) } -// ServerGetStatusHandler handles GET /api/server/getStatus?uuid=xxx. -func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) { - if !utils.Auth(w, r, "GET", "bot") { +// ServerGetInfoHandler handles GET /api/server/getInfo?uuid=xxx|all. +// Returns the static info of the requested server(s) as a uuid-keyed object, +// mirroring the data the Bot uses for /info. +func ServerGetInfoHandler(w http.ResponseWriter, r *http.Request) { + if !utils.Auth(w, r, "GET", "admin") { return } @@ -50,19 +84,56 @@ func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) { return } - // First try to get data from the local tracker. tracker := node.GetTracker() - if tracker != nil { - if n, exists := tracker.GetNode(uuid); exists && n.LatestReport != nil { - utils.SendSuccessResponse(w, "", map[string]interface{}{ - "uuid": uuid, - "report": n.LatestReport, - }) - return - } + if tracker == nil { + utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized") + return } - utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("no recent data for uuid: %s", uuid)) + nodes, found := collectTargetNodes(tracker, uuid) + if !found { + utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("server with uuid %s not found", uuid)) + return + } + + result := make(map[string]interface{}, len(nodes)) + for _, n := range nodes { + result[n.UUID] = ServerInfoValue{UUID: n.UUID, Name: n.Name, Info: n.Info} + } + utils.SendSuccessResponse(w, "", result) +} + +// ServerGetStatusHandler handles GET /api/server/getStatus?uuid=xxx|all. +// Returns the live status of the requested server(s) as a uuid-keyed object, +// mirroring the data the Bot uses for /status. +func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) { + if !utils.Auth(w, r, "GET", "admin") { + return + } + + uuid := r.URL.Query().Get("uuid") + if uuid == "" { + utils.SendErrorResponse(w, http.StatusBadRequest, "missing uuid parameter") + return + } + + tracker := node.GetTracker() + if tracker == nil { + utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized") + return + } + + nodes, found := collectTargetNodes(tracker, uuid) + if !found { + utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("server with uuid %s not found", uuid)) + return + } + + result := make(map[string]interface{}, len(nodes)) + for _, n := range nodes { + result[n.UUID] = ServerStatusValue{UUID: n.UUID, Name: n.Name, Online: n.Online, Report: n.LatestReport} + } + utils.SendSuccessResponse(w, "", result) } // ServerExecHandler handles POST /api/server/exec. diff --git a/handler/server_test.go b/handler/server_test.go new file mode 100644 index 0000000..e5480ec --- /dev/null +++ b/handler/server_test.go @@ -0,0 +1,158 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "nukumizu-backend/internal/node" + "nukumizu-backend/utils" +) + +// adminGet builds an authenticated GET request for the given path using an +// admin token and a fresh X-Timestamp. +func adminGet(t *testing.T, path string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-Token", "test-admin-token") + req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + return req +} + +// seedTracker initializes the global node tracker with two nodes, one online +// with a status report and one offline that has not reported yet. +func seedTracker(t *testing.T) { + t.Helper() + node.InitTracker() + + tracker := node.GetTracker() + tracker.UpdateNodeList(map[string]node.NodeListEntry{ + "u1": { + Name: "alpha", + Info: &node.Info{}, + }, + "u2": { + Name: "beta", + Info: &node.Info{}, + }, + }) + + report := node.Report{} + report.CPU.Usage = 12.5 + report.RAM.Total = 1024 + report.RAM.Used = 512 + tracker.UpdateStatus([]string{"u1"}, map[string]node.Report{"u1": report}) +} + +func setupAdminToken() { + utils.AddToken("test-admin-token", 1, "admin", "tester") +} + +func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]json.RawMessage { + t.Helper() + var body map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v; body=%s", err, w.Body.String()) + } + return body +} + +func TestServerGetInfoAll(t *testing.T) { + setupAdminToken() + seedTracker(t) + + w := httptest.NewRecorder() + ServerGetInfoHandler(w, adminGet(t, "/api/server/getInfo?uuid=all")) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + body := decodeResponse(t, w) + for _, uuid := range []string{"u1", "u2"} { + if _, ok := body[uuid]; !ok { + t.Errorf("response missing uuid %q: %s", uuid, w.Body.String()) + } + } + + var one struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Info *node.Info `json:"info"` + } + if err := json.Unmarshal(body["u1"], &one); err != nil { + t.Fatalf("decode u1: %v", err) + } + if one.UUID != "u1" || one.Name != "alpha" { + t.Errorf("u1 = %+v", one) + } + if one.Info == nil { + t.Error("expected static info present for u1") + } +} + +func TestServerGetInfoSingleAndMissing(t *testing.T) { + setupAdminToken() + seedTracker(t) + + // Single existing uuid: response is keyed by that uuid (uniform shape). + w := httptest.NewRecorder() + ServerGetInfoHandler(w, adminGet(t, "/api/server/getInfo?uuid=u1")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + body := decodeResponse(t, w) + if _, ok := body["u1"]; !ok { + t.Errorf("single-uuid response missing key u1: %s", w.Body.String()) + } + + // Unknown uuid yields 404. + w2 := httptest.NewRecorder() + ServerGetInfoHandler(w2, adminGet(t, "/api/server/getInfo?uuid=ghost")) + if w2.Code != http.StatusNotFound { + t.Errorf("missing uuid status = %d, want 404", w2.Code) + } +} + +func TestServerGetStatusAll(t *testing.T) { + setupAdminToken() + seedTracker(t) + + w := httptest.NewRecorder() + ServerGetStatusHandler(w, adminGet(t, "/api/server/getStatus?uuid=all")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + body := decodeResponse(t, w) + + var online struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Online bool `json:"online"` + Report *node.Report `json:"report"` + } + if err := json.Unmarshal(body["u1"], &online); err != nil { + t.Fatalf("decode u1: %v", err) + } + if !online.Online || online.Report == nil { + t.Errorf("u1 should be online with a report: %+v", online) + } + + var offline struct { + Online bool `json:"online"` + Report *node.Report `json:"report"` + } + if err := json.Unmarshal(body["u2"], &offline); err != nil { + t.Fatalf("decode u2: %v", err) + } + if offline.Online { + t.Error("u2 should be offline") + } + if offline.Report != nil { + t.Errorf("u2 report should be null, got %+v", offline.Report) + } +} diff --git a/router.go b/router.go index c015d44..56b46e8 100644 --- a/router.go +++ b/router.go @@ -20,6 +20,7 @@ func SetupRouter() *http.ServeMux { // Server endpoints (authenticated). mux.HandleFunc("/api/server/list", handler.ServerListHandler) + mux.HandleFunc("/api/server/getInfo", handler.ServerGetInfoHandler) mux.HandleFunc("/api/server/getStatus", handler.ServerGetStatusHandler) mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)