From e7848ea79a9a57fdf391d30c48ce649f3d2f6180 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 21:44:49 -0400 Subject: refactor: dedup admin entity handlers and server construction --- internal/admin/api_drops.go | 176 --------------- internal/admin/api_entity.go | 343 ++++++++++++++++++++++++++++++ internal/admin/api_entity_path_test.go | 16 +- internal/admin/api_entity_test.go | 147 +++++++++++++ internal/admin/api_hazards.go | 174 --------------- internal/admin/api_items.go | 186 ---------------- internal/admin/api_items_undo_test.go | 4 +- internal/admin/api_mobs.go | 172 --------------- internal/admin/api_modules.go | 147 ------------- internal/admin/api_objects.go | 175 --------------- internal/admin/api_players_path_test.go | 8 +- internal/admin/api_techs.go | 167 --------------- internal/admin/hardening_security_test.go | 10 +- internal/admin/live_owner_test.go | 8 +- internal/admin/owner_routes_test.go | 11 +- internal/admin/server.go | 97 +++++---- 16 files changed, 587 insertions(+), 1254 deletions(-) delete mode 100644 internal/admin/api_drops.go create mode 100644 internal/admin/api_entity.go create mode 100644 internal/admin/api_entity_test.go delete mode 100644 internal/admin/api_hazards.go delete mode 100644 internal/admin/api_items.go delete mode 100644 internal/admin/api_mobs.go delete mode 100644 internal/admin/api_modules.go delete mode 100644 internal/admin/api_objects.go delete mode 100644 internal/admin/api_techs.go (limited to 'internal') diff --git a/internal/admin/api_drops.go b/internal/admin/api_drops.go deleted file mode 100644 index 4cb0ae9..0000000 --- a/internal/admin/api_drops.go +++ /dev/null @@ -1,176 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" - - "thehouseoficarus/internal/behavior" -) - -func (s *AdminServer) handleDrops(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - tree, err := listYAMLTree(s.dataDir, "drops") - if err != nil { - failJSON(w, err.Error()) - return - } - if tree == nil { - tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} - } - writeJSON(w, tree) - - case http.MethodPost: - s.createDrop(w, r) - - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) handleDropByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/drops/") - if id == "" { - http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid drop id"}`, http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - s.getDrop(w, id) - case http.MethodPut: - s.updateDrop(w, r, id) - case http.MethodDelete: - s.deleteDrop(w, id) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) getDrop(w http.ResponseWriter, id string) { - data, path, err := readYAMLFile(s.dataDir, "drops", id) - if err != nil { - http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound) - return - } - m, err := yamlToMap(data) - if err != nil { - http.Error(w, `{"error":"failed to parse drop yaml"}`, http.StatusInternalServerError) - return - } - m["_path"] = path - m["_raw"] = string(data) - writeJSON(w, m) -} - -func (s *AdminServer) updateDrop(w http.ResponseWriter, r *http.Request, id string) { - _, path, err := readYAMLFile(s.dataDir, "drops", id) - if err != nil { - http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound) - return - } - - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - m["id"] = id - - oldContent, err := snapshotFile(path) - if err != nil { - http.Error(w, `{"error":"failed to read existing drop"}`, http.StatusInternalServerError) - return - } - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write drop"}`, http.StatusInternalServerError) - return - } - - behavior.ClearDropIndex() - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Update drop %s", id), - FilePath: path, - OldContent: oldContent, - NewContent: newContent, - }) - - writeJSON(w, m) -} - -func (s *AdminServer) deleteDrop(w http.ResponseWriter, id string) { - _, path, err := readYAMLFile(s.dataDir, "drops", id) - if err != nil { - http.Error(w, `{"error":"drop not found"}`, http.StatusNotFound) - return - } - - oldContent := backupFile(path) - - if err := os.Remove(path); err != nil { - http.Error(w, `{"error":"failed to delete drop"}`, http.StatusInternalServerError) - return - } - - behavior.ClearDropIndex() - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Delete drop %s", id), - FilePath: path, - OldContent: oldContent, - IsDelete: true, - }) - - writeJSON(w, map[string]any{"deleted": id}) -} - -func (s *AdminServer) createDrop(w http.ResponseWriter, r *http.Request) { - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - - id, ok := m["id"].(string) - if !ok || strings.TrimSpace(id) == "" { - http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid drop id"}`, http.StatusBadRequest) - return - } - - path := filepath.Join(s.dataDir, "drops", id+".yaml") - if _, err := os.Stat(path); err == nil { - http.Error(w, `{"error":"drop already exists"}`, http.StatusConflict) - return - } - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write drop"}`, http.StatusInternalServerError) - return - } - - behavior.ClearDropIndex() - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Create drop %s", id), - FilePath: path, - NewContent: newContent, - IsCreate: true, - }) - - writeJSON(w, m) -} diff --git a/internal/admin/api_entity.go b/internal/admin/api_entity.go new file mode 100644 index 0000000..b32ab89 --- /dev/null +++ b/internal/admin/api_entity.go @@ -0,0 +1,343 @@ +package admin + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// The editor exposes the same list/get/create/update/delete contract for +// several YAML families. entityCRUD captures everything that actually +// differed between the hand-written copies; the handlers below are shared by +// every family. +// +// Two wire styles predate this file and must be preserved (see failJSON's +// contract comment): +// +// - status style (items, drops, mobs, objects, hazards): errors are HTTP +// error statuses with a JSON body; a successful write echoes the entity +// map (including _raw). +// - body style (techs, modules): errors are a 200 JSON body read as +// res.error; writes reply {"ok":true,"id":id}. These editors also upsert +// (POST/PUT by id creates an absent file). +type entityCRUD struct { + route string // URL segment and data subdirectory, e.g. "items" + noun string // singular for error text and undo descriptions + searchSubdirs bool // look the entity up in nested directories too + upsert bool // by-ID POST/PUT creates an absent file + bodyErr bool // write errors as a 200 JSON body (failJSON) + okResponse bool // reply {"ok":true,"id":id} and "Created/Updated" + refresh func(*AdminServer) +} + +var ( + itemSpec = entityCRUD{route: "items", noun: "item", searchSubdirs: true, + refresh: func(s *AdminServer) { s.invalidateEntityCaches("items") }} + dropSpec = entityCRUD{route: "drops", noun: "drop", + refresh: func(s *AdminServer) { s.invalidateEntityCaches("drops") }} + mobSpec = entityCRUD{route: "mobs", noun: "mob", searchSubdirs: true, + refresh: func(s *AdminServer) { s.invalidateEntityCaches("mobs") }} + objectSpec = entityCRUD{route: "objects", noun: "object", searchSubdirs: true} + hazardSpec = entityCRUD{route: "hazards", noun: "hazard", + refresh: func(s *AdminServer) { s.world.ClearHazardCache() }} + techSpec = entityCRUD{route: "techs", noun: "tech", searchSubdirs: true, upsert: true, bodyErr: true, okResponse: true} + moduleSpec = entityCRUD{route: "modules", noun: "module", searchSubdirs: true, upsert: true, bodyErr: true, okResponse: true} +) + +func (c entityCRUD) find(s *AdminServer, id string) ([]byte, string, error) { + if c.searchSubdirs { + return findYAMLFileInSubdirs(s.dataDir, c.route, id) + } + return readYAMLFile(s.dataDir, c.route, id) +} + +// fail writes an entity error in the family's wire style. status is ignored +// by the body style, whose clients read the error out of a 200. +func (c entityCRUD) fail(w http.ResponseWriter, status int, msg string) { + if c.bodyErr { + failJSON(w, msg) + return + } + writeJSONError(w, msg, status) +} + +func (c entityCRUD) invalidate(s *AdminServer) { + if c.refresh != nil { + c.refresh(s) + } +} + +func (c entityCRUD) invalidID() string { + if c.bodyErr { + return "invalid id" + } + return "invalid " + c.noun + " id" +} + +func (c entityCRUD) missingID() string { + if c.bodyErr { + return "missing id" + } + return "missing " + c.noun + " id" +} + +func (c entityCRUD) notFound(id string) string { + if c.bodyErr { + return "not found: " + id + } + return c.noun + " not found" +} + +func (c entityCRUD) jsonErr(err error) string { + if c.bodyErr { + return "invalid JSON: " + err.Error() + } + return "invalid json body" +} + +func (c entityCRUD) parseErr(err error) string { + if c.bodyErr { + return "parse error: " + err.Error() + } + return "failed to parse " + c.noun + " yaml" +} + +func (c entityCRUD) writeErr(err error) string { + if c.bodyErr { + return "write error: " + err.Error() + } + return "failed to write " + c.noun +} + +func (c entityCRUD) readErr(err error) string { + if c.bodyErr { + return "read existing file: " + err.Error() + } + return "failed to read existing " + c.noun +} + +func (c entityCRUD) removeErr(err error) string { + if c.bodyErr { + return "delete error: " + err.Error() + } + return "failed to delete " + c.noun +} + +func (c entityCRUD) listErr(err error) string { + if c.bodyErr { + return err.Error() + } + return "failed to list " + c.route +} + +// desc names an undo entry. The body style uses past-tense verbs ("Created +// tech x"); the status style uses imperative ones ("Create item x"). +func (c entityCRUD) desc(verb, okVerb, id string) string { + if c.okResponse { + return okVerb + " " + c.noun + " " + id + } + return verb + " " + c.noun + " " + id +} + +// handleEntityList serves GET (tree) and POST (create) for one family. +func (s *AdminServer) handleEntityList(c entityCRUD) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + tree, err := listYAMLTree(s.dataDir, c.route) + if err != nil { + c.fail(w, http.StatusInternalServerError, c.listErr(err)) + return + } + if tree == nil { + tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} + } + writeJSON(w, tree) + case http.MethodPost: + s.entityCreate(w, r, c) + default: + methodNotAllowed(w) + } + } +} + +// handleEntityByID serves GET/PUT/POST(upsert)/DELETE for one family. +func (s *AdminServer) handleEntityByID(c entityCRUD) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/"+c.route+"/") + if id == "" { + c.fail(w, http.StatusBadRequest, c.missingID()) + return + } + if !validEntityID(id) { + c.fail(w, http.StatusBadRequest, c.invalidID()) + return + } + switch r.Method { + case http.MethodGet: + s.entityGet(w, id, c) + case http.MethodPut: + s.entityUpdate(w, r, id, c) + case http.MethodPost: + if !c.upsert { + methodNotAllowed(w) + return + } + s.entityUpdate(w, r, id, c) + case http.MethodDelete: + s.entityDelete(w, id, c) + default: + methodNotAllowed(w) + } + } +} + +func (s *AdminServer) entityGet(w http.ResponseWriter, id string, c entityCRUD) { + data, path, err := c.find(s, id) + if err != nil { + c.fail(w, http.StatusNotFound, c.notFound(id)) + return + } + m, err := yamlToMap(data) + if err != nil { + c.fail(w, http.StatusInternalServerError, c.parseErr(err)) + return + } + if c.okResponse { + // The body style strips the id before writing, so echo it from the + // filename; the status style already writes it into the file. + m["id"] = id + } + m["_path"] = path + m["_raw"] = string(data) + writeJSON(w, m) +} + +func (s *AdminServer) entityCreate(w http.ResponseWriter, r *http.Request, c entityCRUD) { + var m map[string]any + if err := readJSON(r, &m); err != nil { + c.fail(w, http.StatusBadRequest, c.jsonErr(err)) + return + } + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { + c.fail(w, http.StatusBadRequest, c.missingID()) + return + } + if !validEntityID(id) { + c.fail(w, http.StatusBadRequest, c.invalidID()) + return + } + if _, _, err := c.find(s, id); err == nil { + c.fail(w, http.StatusConflict, c.noun+" already exists") + return + } + if c.okResponse { + delete(m, "id") + } + path := filepath.Join(s.dataDir, c.route, id+".yaml") + newContent, err := writeMapAsYAML(path, m) + if err != nil { + c.fail(w, http.StatusInternalServerError, c.writeErr(err)) + return + } + s.undoStack.Push(ChangeDesc{ + Description: c.desc("Create", "Created", id), + FilePath: path, + NewContent: newContent, + IsCreate: true, + }) + c.invalidate(s) + if c.okResponse { + writeJSON(w, map[string]any{"ok": true, "id": id}) + return + } + writeJSON(w, m) +} + +func (s *AdminServer) entityUpdate(w http.ResponseWriter, r *http.Request, id string, c entityCRUD) { + var m map[string]any + if err := readJSON(r, &m); err != nil { + c.fail(w, http.StatusBadRequest, c.jsonErr(err)) + return + } + _, path, findErr := c.find(s, id) + isCreate := findErr != nil + if isCreate && !c.upsert { + c.fail(w, http.StatusNotFound, c.notFound(id)) + return + } + if isCreate { + path = filepath.Join(s.dataDir, c.route, id+".yaml") + } + var oldContent []byte + if !isCreate { + var err error + oldContent, err = snapshotFile(path) + if err != nil { + c.fail(w, http.StatusInternalServerError, c.readErr(err)) + return + } + } + // _path/_raw are editor-only metadata and never belong in the file; the + // status style also stores the id, while the body style derives it from + // the filename. + delete(m, "_path") + delete(m, "_raw") + if c.okResponse { + delete(m, "id") + } else { + m["id"] = id + } + newContent, err := writeMapAsYAML(path, m) + if err != nil { + c.fail(w, http.StatusInternalServerError, c.writeErr(err)) + return + } + s.undoStack.Push(ChangeDesc{ + Description: c.desc("Update", "Updated", id), + FilePath: path, + OldContent: oldContent, + NewContent: newContent, + IsCreate: isCreate, + }) + c.invalidate(s) + if c.okResponse { + writeJSON(w, map[string]any{"ok": true, "id": id}) + return + } + m["_raw"] = string(newContent) + writeJSON(w, m) +} + +func (s *AdminServer) entityDelete(w http.ResponseWriter, id string, c entityCRUD) { + _, path, err := c.find(s, id) + if err != nil { + c.fail(w, http.StatusNotFound, c.notFound(id)) + return + } + oldContent := backupFile(path) + if err := os.Remove(path); err != nil { + c.fail(w, http.StatusInternalServerError, c.removeErr(err)) + return + } + s.undoStack.Push(ChangeDesc{ + Description: c.desc("Delete", "Deleted", id), + FilePath: path, + OldContent: oldContent, + IsDelete: true, + }) + c.invalidate(s) + if c.okResponse { + writeJSON(w, map[string]any{"ok": true, "id": id}) + return + } + writeJSON(w, map[string]any{"deleted": id}) +} + +// methodNotAllowed writes the shared 405 body. The admin JS throws on any +// non-200, so the JSON form is safe for both wire styles. +func methodNotAllowed(w http.ResponseWriter) { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) +} diff --git a/internal/admin/api_entity_path_test.go b/internal/admin/api_entity_path_test.go index 3a0f87b..a9cafd0 100644 --- a/internal/admin/api_entity_path_test.go +++ b/internal/admin/api_entity_path_test.go @@ -28,9 +28,11 @@ func TestEntityAPICannotEscapeData(t *testing.T) { if err := accounts.SaveAccount(&player.Account{Name: "auditor", Admin: true}); err != nil { t.Fatal(err) } - s, err := NewServer(config.Default(), false, accounts, nil, nil, nil, nil, dir, - nil, nil, nil, nil, filepath.Join(dir, "config.yaml"), - func(fn func()) bool { fn(); return true }) + s, err := NewServer(ServerDeps{ + Config: config.Default(), Accounts: accounts, DataDir: dir, + ConfigPath: filepath.Join(dir, "config.yaml"), + RunOnOwner: func(fn func()) bool { fn(); return true }, + }) if err != nil { t.Fatal(err) } @@ -98,9 +100,11 @@ func TestTechModuleCreateRejectsTraversalID(t *testing.T) { if err := accounts.SaveAccount(&player.Account{Name: "auditor", Admin: true}); err != nil { t.Fatal(err) } - s, err := NewServer(config.Default(), false, accounts, nil, nil, nil, nil, dir, - nil, nil, nil, nil, filepath.Join(dir, "config.yaml"), - func(fn func()) bool { fn(); return true }) + s, err := NewServer(ServerDeps{ + Config: config.Default(), Accounts: accounts, DataDir: dir, + ConfigPath: filepath.Join(dir, "config.yaml"), + RunOnOwner: func(fn func()) bool { fn(); return true }, + }) if err != nil { t.Fatal(err) } diff --git a/internal/admin/api_entity_test.go b/internal/admin/api_entity_test.go new file mode 100644 index 0000000..b3d83a7 --- /dev/null +++ b/internal/admin/api_entity_test.go @@ -0,0 +1,147 @@ +package admin + +// Regression coverage for the shared entity CRUD handlers (api_entity.go): +// both wire styles must keep their exact contract after the per-entity +// handlers were folded into one implementation. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func newEntityHarness(t *testing.T) *AdminServer { + t.Helper() + dir := t.TempDir() + for _, sub := range []string{"items", "techs", "modules"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + } + return &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)} +} + +func callHandler(h http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// Status style (items): validation errors are HTTP statuses, writes echo the +// entity map including _raw. +func TestEntityCRUDStatusStyle(t *testing.T) { + s := newEntityHarness(t) + list := s.handleEntityList(itemSpec) + byID := s.handleEntityByID(itemSpec) + + if rec := callHandler(list, http.MethodPost, "/api/items", `{"id":"coin","name":"Coin","value":1}`); rec.Code != http.StatusOK { + t.Fatalf("create status = %d body %s", rec.Code, rec.Body.String()) + } + // The id is written into the file for the status style. + data, err := os.ReadFile(filepath.Join(s.dataDir, "items", "coin.yaml")) + if err != nil || !strings.Contains(string(data), "id: coin") { + t.Fatalf("created file = %q err=%v", data, err) + } + + rec := callHandler(byID, http.MethodGet, "/api/items/coin", "") + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("get not json: %v (%s)", err, rec.Body.String()) + } + if _, ok := got["_raw"]; !ok { + t.Fatalf("status-style get did not include _raw: %s", rec.Body.String()) + } + + rec = callHandler(byID, http.MethodPut, "/api/items/coin", `{"name":"Coin","value":9}`) + if rec.Code != http.StatusOK { + t.Fatalf("update status = %d body %s", rec.Code, rec.Body.String()) + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["_raw"] == nil { + t.Fatalf("status-style update did not echo _raw: %v (%s)", err, rec.Body.String()) + } + + // Traversal ids are rejected with a 400 (not a 200 body error). + rec = callHandler(list, http.MethodPost, "/api/items", `{"id":"../escape"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("traversal id status = %d, want 400 (body %s)", rec.Code, rec.Body.String()) + } + + if rec := callHandler(byID, http.MethodDelete, "/api/items/coin", ""); rec.Code != http.StatusOK { + t.Fatalf("delete status = %d body %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(s.dataDir, "items", "coin.yaml")); !os.IsNotExist(err) { + t.Fatalf("delete left the file behind: %v", err) + } +} + +// Body style (techs): errors are a 200 JSON body, writes reply {"ok":true}, +// and PUT upserts an absent file. +func TestEntityCRUDUpsertStyle(t *testing.T) { + s := newEntityHarness(t) + list := s.handleEntityList(techSpec) + byID := s.handleEntityByID(techSpec) + + rec := callHandler(byID, http.MethodPut, "/api/techs/laser", `{"name":"Laser"}`) + if rec.Code != http.StatusOK { + t.Fatalf("upsert status = %d body %s", rec.Code, rec.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp["ok"] != true { + t.Fatalf("upsert response = %s (err %v)", rec.Body.String(), err) + } + if _, err := os.Stat(filepath.Join(s.dataDir, "techs", "laser.yaml")); err != nil { + t.Fatalf("upsert did not create the file: %v", err) + } + + // The body style strips the id from the file (derived from the filename). + data, err := os.ReadFile(filepath.Join(s.dataDir, "techs", "laser.yaml")) + if err != nil || strings.Contains(string(data), "id:") { + t.Fatalf("body-style file should not store id: %q err=%v", data, err) + } + + // Traversal ids are a 200 body error, not a 400 (the newer editors read + // res.error out of a 200). + rec = callHandler(list, http.MethodPost, "/api/techs", `{"id":"../escape"}`) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "invalid id") { + t.Fatalf("body-style traversal id = %d %s; want 200 with 'invalid id'", rec.Code, rec.Body.String()) + } + + // GET echoes the id from the filename. + rec = callHandler(byID, http.MethodGet, "/api/techs/laser", "") + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["id"] != "laser" { + t.Fatalf("body-style get id = %v (err %v, body %s)", got["id"], err, rec.Body.String()) + } +} + +// A by-id write must update a file that lives in a subdirectory, not create a +// shadowing copy at the root. The old module handler resolved updates only at +// the root while GET/DELETE searched subdirs. +func TestEntityUpsertUpdatesFileInSubdir(t *testing.T) { + s := newEntityHarness(t) + sub := filepath.Join(s.dataDir, "modules", "sub") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + subPath := filepath.Join(sub, "m1.yaml") + if err := os.WriteFile(subPath, []byte("name: Original\n"), 0o644); err != nil { + t.Fatal(err) + } + + rec := callHandler(s.handleEntityByID(moduleSpec), http.MethodPut, "/api/modules/m1", `{"name":"Updated"}`) + if rec.Code != http.StatusOK { + t.Fatalf("subdir update status = %d body %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(filepath.Join(s.dataDir, "modules", "m1.yaml")); !os.IsNotExist(err) { + t.Fatal("subdir update created a shadowing root copy") + } + data, err := os.ReadFile(subPath) + if err != nil || !strings.Contains(string(data), "Updated") { + t.Fatalf("subdir file not updated: %q err=%v", data, err) + } +} diff --git a/internal/admin/api_hazards.go b/internal/admin/api_hazards.go deleted file mode 100644 index 89618e7..0000000 --- a/internal/admin/api_hazards.go +++ /dev/null @@ -1,174 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" -) - -func (s *AdminServer) handleHazards(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - tree, err := listYAMLTree(s.dataDir, "hazards") - if err != nil { - failJSON(w, err.Error()) - return - } - if tree == nil { - tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} - } - writeJSON(w, tree) - - case http.MethodPost: - s.createHazard(w, r) - - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) handleHazardByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/hazards/") - if id == "" { - http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid hazard id"}`, http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - s.getHazard(w, id) - case http.MethodPut: - s.updateHazard(w, r, id) - case http.MethodDelete: - s.deleteHazard(w, id) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) getHazard(w http.ResponseWriter, id string) { - data, path, err := readYAMLFile(s.dataDir, "hazards", id) - if err != nil { - http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound) - return - } - m, err := yamlToMap(data) - if err != nil { - http.Error(w, `{"error":"failed to parse hazard yaml"}`, http.StatusInternalServerError) - return - } - m["_path"] = path - m["_raw"] = string(data) - writeJSON(w, m) -} - -func (s *AdminServer) updateHazard(w http.ResponseWriter, r *http.Request, id string) { - _, path, err := readYAMLFile(s.dataDir, "hazards", id) - if err != nil { - http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound) - return - } - - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - m["id"] = id - - oldContent, err := snapshotFile(path) - if err != nil { - http.Error(w, `{"error":"failed to read existing hazard"}`, http.StatusInternalServerError) - return - } - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write hazard"}`, http.StatusInternalServerError) - return - } - - s.world.ClearHazardCache() - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Update hazard %s", id), - FilePath: path, - OldContent: oldContent, - NewContent: newContent, - }) - - writeJSON(w, m) -} - -func (s *AdminServer) deleteHazard(w http.ResponseWriter, id string) { - _, path, err := readYAMLFile(s.dataDir, "hazards", id) - if err != nil { - http.Error(w, `{"error":"hazard not found"}`, http.StatusNotFound) - return - } - - oldContent := backupFile(path) - - if err := os.Remove(path); err != nil { - http.Error(w, `{"error":"failed to delete hazard"}`, http.StatusInternalServerError) - return - } - - s.world.ClearHazardCache() - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Delete hazard %s", id), - FilePath: path, - OldContent: oldContent, - IsDelete: true, - }) - - writeJSON(w, map[string]any{"deleted": id}) -} - -func (s *AdminServer) createHazard(w http.ResponseWriter, r *http.Request) { - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - - id, ok := m["id"].(string) - if !ok || strings.TrimSpace(id) == "" { - http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid hazard id"}`, http.StatusBadRequest) - return - } - - path := filepath.Join(s.dataDir, "hazards", id+".yaml") - if _, err := os.Stat(path); err == nil { - http.Error(w, `{"error":"hazard already exists"}`, http.StatusConflict) - return - } - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write hazard"}`, http.StatusInternalServerError) - return - } - - s.world.ClearHazardCache() - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Create hazard %s", id), - FilePath: path, - NewContent: newContent, - IsCreate: true, - }) - - writeJSON(w, m) -} diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go deleted file mode 100644 index 8798543..0000000 --- a/internal/admin/api_items.go +++ /dev/null @@ -1,186 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" -) - -func (s *AdminServer) handleItems(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - tree, err := listYAMLTree(s.dataDir, "items") - if err != nil { - http.Error(w, `{"error":"failed to list items"}`, http.StatusInternalServerError) - return - } - if tree == nil { - tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} - } - writeJSON(w, tree) - case http.MethodPost: - s.createItem(w, r) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) handleItemByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/items/") - if id == "" { - http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid item id"}`, http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - s.getItem(w, id) - case http.MethodPut: - s.updateItem(w, r, id) - case http.MethodDelete: - s.deleteItem(w, id) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -// invalidateItemCache drops the in-game item cache after an editor write so -// gameplay picks up the change without a manual reload, and rebuilds the -// craft index. Also refreshes the path index in case the file was created -// or removed. -func (s *AdminServer) invalidateItemCache() { - s.invalidateEntityCaches("items") -} - -func (s *AdminServer) findItemFile(id string) ([]byte, string, error) { - data, path, err := readYAMLFile(s.dataDir, "items", id) - if err == nil { - return data, path, nil - } - return findYAMLFileInSubdirs(s.dataDir, "items", id) -} - -func (s *AdminServer) getItem(w http.ResponseWriter, id string) { - data, path, err := s.findItemFile(id) - if err != nil { - http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) - return - } - m, err := yamlToMap(data) - if err != nil { - http.Error(w, `{"error":"failed to parse item yaml"}`, http.StatusInternalServerError) - return - } - m["_path"] = path - m["_raw"] = string(data) - writeJSON(w, m) -} - -func (s *AdminServer) updateItem(w http.ResponseWriter, r *http.Request, id string) { - _, path, err := s.findItemFile(id) - if err != nil { - http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) - return - } - - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - m["id"] = id - - oldContent, err := snapshotFile(path) - if err != nil { - http.Error(w, `{"error":"failed to read existing item"}`, http.StatusInternalServerError) - return - } - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write item"}`, http.StatusInternalServerError) - return - } - m["_raw"] = string(newContent) - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Update item %s", id), - FilePath: path, - OldContent: oldContent, - NewContent: newContent, - }) - s.invalidateItemCache() - - writeJSON(w, m) -} - -func (s *AdminServer) deleteItem(w http.ResponseWriter, id string) { - _, path, err := s.findItemFile(id) - if err != nil { - http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) - return - } - - oldContent := backupFile(path) - - if err := os.Remove(path); err != nil { - http.Error(w, `{"error":"failed to delete item"}`, http.StatusInternalServerError) - return - } - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Delete item %s", id), - FilePath: path, - OldContent: oldContent, - IsDelete: true, - }) - s.invalidateItemCache() - - writeJSON(w, map[string]any{"deleted": id}) -} - -func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) { - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - id, ok := m["id"].(string) - if !ok || strings.TrimSpace(id) == "" { - http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid item id"}`, http.StatusBadRequest) - return - } - - if _, _, err := s.findItemFile(id); err == nil { - http.Error(w, `{"error":"item already exists"}`, http.StatusConflict) - return - } - - path := filepath.Join(s.dataDir, "items", id+".yaml") - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write item"}`, http.StatusInternalServerError) - return - } - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Create item %s", id), - FilePath: path, - NewContent: newContent, - IsCreate: true, - }) - s.invalidateItemCache() - - writeJSON(w, m) -} diff --git a/internal/admin/api_items_undo_test.go b/internal/admin/api_items_undo_test.go index d554bc6..b6610fb 100644 --- a/internal/admin/api_items_undo_test.go +++ b/internal/admin/api_items_undo_test.go @@ -67,8 +67,8 @@ func TestUndoRefreshesRuntimeItemCache(t *testing.T) { if _, err := s.itemStore.Load("coin"); err != nil { t.Fatal(err) } - s.updateItem(httptest.NewRecorder(), httptest.NewRequest("PUT", "/api/items/coin", - strings.NewReader(`{"name":"Coin","value":9}`)), "coin") + s.handleEntityByID(itemSpec)(httptest.NewRecorder(), httptest.NewRequest("PUT", "/api/items/coin", + strings.NewReader(`{"name":"Coin","value":9}`))) if updated, err := s.itemStore.Load("coin"); err != nil || updated.Value != 9 { t.Fatalf("item edit did not reach the runtime cache: %v %+v", err, updated) } diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go deleted file mode 100644 index c3e47bb..0000000 --- a/internal/admin/api_mobs.go +++ /dev/null @@ -1,172 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" -) - -func (s *AdminServer) handleMobs(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - tree, err := listYAMLTree(s.dataDir, "mobs") - if err != nil { - failJSON(w, err.Error()) - return - } - if tree == nil { - tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} - } - writeJSON(w, tree) - - case http.MethodPost: - s.createMob(w, r) - - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) handleMobByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/mobs/") - if id == "" { - http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid mob id"}`, http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - s.getMob(w, id) - case http.MethodPut: - s.updateMob(w, r, id) - case http.MethodDelete: - s.deleteMob(w, id) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) findMobFile(id string) ([]byte, string, error) { - data, path, err := readYAMLFile(s.dataDir, "mobs", id) - if err == nil { - return data, path, nil - } - return findYAMLFileInSubdirs(s.dataDir, "mobs", id) -} - -func (s *AdminServer) getMob(w http.ResponseWriter, id string) { - data, path, err := s.findMobFile(id) - if err != nil { - http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound) - return - } - m, err := yamlToMap(data) - if err != nil { - http.Error(w, `{"error":"failed to parse mob yaml"}`, http.StatusInternalServerError) - return - } - m["_path"] = path - m["_raw"] = string(data) - writeJSON(w, m) -} - -func (s *AdminServer) updateMob(w http.ResponseWriter, r *http.Request, id string) { - _, path, err := s.findMobFile(id) - if err != nil { - http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound) - return - } - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - m["id"] = id - - oldContent, err := snapshotFile(path) - if err != nil { - http.Error(w, `{"error":"failed to read existing mob"}`, http.StatusInternalServerError) - return - } - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write mob"}`, http.StatusInternalServerError) - return - } - m["_raw"] = string(newContent) - s.mobStore.ReloadDefs(s.dataDir) - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Update mob %s", id), - FilePath: path, - OldContent: oldContent, - NewContent: newContent, - }) - writeJSON(w, m) -} - -func (s *AdminServer) deleteMob(w http.ResponseWriter, id string) { - _, path, err := s.findMobFile(id) - if err != nil { - http.Error(w, `{"error":"mob not found"}`, http.StatusNotFound) - return - } - - oldContent := backupFile(path) - - if err := os.Remove(path); err != nil { - http.Error(w, `{"error":"failed to delete mob"}`, http.StatusInternalServerError) - return - } - - s.mobStore.ReloadDefs(s.dataDir) - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Delete mob %s", id), - FilePath: path, - OldContent: oldContent, - IsDelete: true, - }) - - writeJSON(w, map[string]any{"deleted": id}) -} - -func (s *AdminServer) createMob(w http.ResponseWriter, r *http.Request) { - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - id, ok := m["id"].(string) - if !ok || strings.TrimSpace(id) == "" { - http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid mob id"}`, http.StatusBadRequest) - return - } - if _, _, err := s.findMobFile(id); err == nil { - http.Error(w, `{"error":"mob already exists"}`, http.StatusConflict) - return - } - path := filepath.Join(s.dataDir, "mobs", id+".yaml") - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write mob"}`, http.StatusInternalServerError) - return - } - s.mobStore.ReloadDefs(s.dataDir) - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Create mob %s", id), - FilePath: path, - NewContent: newContent, - IsCreate: true, - }) - writeJSON(w, m) -} diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go deleted file mode 100644 index ea547c5..0000000 --- a/internal/admin/api_modules.go +++ /dev/null @@ -1,147 +0,0 @@ -package admin - -import ( - "net/http" - "os" - "path/filepath" - "strings" -) - -func (s *AdminServer) handleModules(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - tree, err := listYAMLTree(s.dataDir, "modules") - if err != nil { - failJSON(w, err.Error()) - return - } - if tree == nil { - tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} - } - writeJSON(w, tree) - case http.MethodPost: - var m map[string]any - if err := readJSON(r, &m); err != nil { - failJSON(w, "invalid json") - return - } - id, ok := m["id"].(string) - if !ok || strings.TrimSpace(id) == "" { - failJSON(w, "missing id") - return - } - if !validEntityID(id) { - failJSON(w, "invalid id") - return - } - delete(m, "id") - path := filepath.Join(s.dataDir, "modules", id+".yaml") - if _, err := os.Stat(path); err == nil { - failJSON(w, "module already exists") - return - } - newContent, err := writeMapAsYAML(path, m) - if err != nil { - failJSON(w, "write error: "+err.Error()) - return - } - s.undoStack.Push(ChangeDesc{ - Description: "Created module " + id, - FilePath: path, - NewContent: newContent, - IsCreate: true, - }) - writeJSON(w, map[string]any{"ok": true, "id": id}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/modules/") - if id == "" { - http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - data, path, err := findYAMLFileInSubdirs(s.dataDir, "modules", id) - if err != nil { - failJSON(w, "not found: "+id) - return - } - raw, err := yamlToMap(data) - if err != nil { - failJSON(w, "parse error: "+err.Error()) - return - } - raw["id"] = id - raw["_path"] = path - raw["_raw"] = string(data) - writeJSON(w, raw) - - case http.MethodPost, http.MethodPut: - var body map[string]any - if err := readJSON(r, &body); err != nil { - failJSON(w, "invalid JSON: "+err.Error()) - return - } - delete(body, "id") - delete(body, "_path") - delete(body, "_raw") - - path := filepath.Join(s.dataDir, "modules", id+".yaml") - oldContent, existed, err := snapshotBefore(path) - if err != nil { - failJSON(w, "read existing file: "+err.Error()) - return - } - - newContent, err := writeMapAsYAML(path, body) - if err != nil { - failJSON(w, "write error: "+err.Error()) - return - } - - isCreate := !existed - desc := "Updated module " + id - if isCreate { - desc = "Created module " + id - } - s.undoStack.Push(ChangeDesc{ - Description: desc, - FilePath: path, - OldContent: oldContent, - NewContent: newContent, - IsCreate: isCreate, - }) - writeJSON(w, map[string]any{"ok": true, "id": id}) - - case http.MethodDelete: - _, path, err := findYAMLFileInSubdirs(s.dataDir, "modules", id) - if err != nil { - failJSON(w, "not found: "+id) - return - } - oldContent, _ := snapshotFile(path) - if err := os.Remove(path); err != nil { - failJSON(w, "delete error: "+err.Error()) - return - } - s.undoStack.Push(ChangeDesc{ - Description: "Deleted module " + id, - FilePath: path, - OldContent: oldContent, - IsDelete: true, - }) - writeJSON(w, map[string]any{"ok": true, "id": id}) - - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go deleted file mode 100644 index 4e1e621..0000000 --- a/internal/admin/api_objects.go +++ /dev/null @@ -1,175 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "strings" -) - -func (s *AdminServer) handleObjects(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - tree, err := listYAMLTree(s.dataDir, "objects") - if err != nil { - http.Error(w, `{"error":"failed to list objects"}`, http.StatusInternalServerError) - return - } - if tree == nil { - tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} - } - writeJSON(w, tree) - case http.MethodPost: - s.createObject(w, r) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) handleObjectByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/objects/") - if id == "" { - http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid object id"}`, http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - s.getObject(w, id) - case http.MethodPut: - s.updateObject(w, r, id) - case http.MethodDelete: - s.deleteObject(w, id) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) findObjectFile(id string) ([]byte, string, error) { - data, path, err := readYAMLFile(s.dataDir, "objects", id) - if err == nil { - return data, path, nil - } - return findYAMLFileInSubdirs(s.dataDir, "objects", id) -} - -func (s *AdminServer) getObject(w http.ResponseWriter, id string) { - data, path, err := s.findObjectFile(id) - if err != nil { - http.Error(w, `{"error":"object not found"}`, http.StatusNotFound) - return - } - m, err := yamlToMap(data) - if err != nil { - http.Error(w, `{"error":"failed to parse object yaml"}`, http.StatusInternalServerError) - return - } - m["_path"] = path - m["_raw"] = string(data) - writeJSON(w, m) -} - -func (s *AdminServer) updateObject(w http.ResponseWriter, r *http.Request, id string) { - _, path, err := s.findObjectFile(id) - if err != nil { - http.Error(w, `{"error":"object not found"}`, http.StatusNotFound) - return - } - - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - m["id"] = id - - oldContent, err := snapshotFile(path) - if err != nil { - http.Error(w, `{"error":"failed to read existing object"}`, http.StatusInternalServerError) - return - } - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write object"}`, http.StatusInternalServerError) - return - } - m["_raw"] = string(newContent) - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Update object %s", id), - FilePath: path, - OldContent: oldContent, - NewContent: newContent, - }) - - writeJSON(w, m) -} - -func (s *AdminServer) deleteObject(w http.ResponseWriter, id string) { - _, path, err := s.findObjectFile(id) - if err != nil { - http.Error(w, `{"error":"object not found"}`, http.StatusNotFound) - return - } - - oldContent := backupFile(path) - - if err := os.Remove(path); err != nil { - http.Error(w, `{"error":"failed to delete object"}`, http.StatusInternalServerError) - return - } - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Delete object %s", id), - FilePath: path, - OldContent: oldContent, - IsDelete: true, - }) - - writeJSON(w, map[string]any{"deleted": id}) -} - -func (s *AdminServer) createObject(w http.ResponseWriter, r *http.Request) { - var m map[string]any - if err := readJSON(r, &m); err != nil { - http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) - return - } - id, ok := m["id"].(string) - if !ok || strings.TrimSpace(id) == "" { - http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid object id"}`, http.StatusBadRequest) - return - } - - if _, _, err := s.findObjectFile(id); err == nil { - http.Error(w, `{"error":"object already exists"}`, http.StatusConflict) - return - } - - path := filepath.Join(s.dataDir, "objects", id+".yaml") - - newContent, err := writeMapAsYAML(path, m) - if err != nil { - http.Error(w, `{"error":"failed to write object"}`, http.StatusInternalServerError) - return - } - - s.undoStack.Push(ChangeDesc{ - Description: fmt.Sprintf("Create object %s", id), - FilePath: path, - NewContent: newContent, - IsCreate: true, - }) - - writeJSON(w, m) -} diff --git a/internal/admin/api_players_path_test.go b/internal/admin/api_players_path_test.go index dca156e..657ff74 100644 --- a/internal/admin/api_players_path_test.go +++ b/internal/admin/api_players_path_test.go @@ -43,9 +43,11 @@ func TestPlayerAPICannotEscapeData(t *testing.T) { t.Fatal(err) } - s, err := NewServer(config.Default(), false, accounts, nil, nil, nil, nil, dir, - nil, nil, nil, nil, filepath.Join(dir, "config.yaml"), - func(fn func()) bool { fn(); return true }) + s, err := NewServer(ServerDeps{ + Config: config.Default(), Accounts: accounts, DataDir: dir, + ConfigPath: filepath.Join(dir, "config.yaml"), + RunOnOwner: func(fn func()) bool { fn(); return true }, + }) if err != nil { t.Fatal(err) } diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go deleted file mode 100644 index d2c5f65..0000000 --- a/internal/admin/api_techs.go +++ /dev/null @@ -1,167 +0,0 @@ -package admin - -import ( - "net/http" - "os" - "path/filepath" - "strings" -) - -func (s *AdminServer) findTechFile(id string) ([]byte, string, error) { - data, path, err := readYAMLFile(s.dataDir, "techs", id) - if err == nil { - return data, path, nil - } - return findYAMLFileInSubdirs(s.dataDir, "techs", id) -} - -func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - tree, err := listYAMLTree(s.dataDir, "techs") - if err != nil { - failJSON(w, err.Error()) - return - } - if tree == nil { - tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} - } - writeJSON(w, tree) - case http.MethodPost: - var m map[string]any - if err := readJSON(r, &m); err != nil { - failJSON(w, "invalid json") - return - } - id, ok := m["id"].(string) - if !ok || strings.TrimSpace(id) == "" { - failJSON(w, "missing id") - return - } - if !validEntityID(id) { - failJSON(w, "invalid id") - return - } - delete(m, "id") - if _, _, err := s.findTechFile(id); err == nil { - failJSON(w, "tech already exists") - return - } - path := filepath.Join(s.dataDir, "techs", id+".yaml") - newContent, err := writeMapAsYAML(path, m) - if err != nil { - failJSON(w, "write error: "+err.Error()) - return - } - s.undoStack.Push(ChangeDesc{ - Description: "Created tech " + id, - FilePath: path, - NewContent: newContent, - IsCreate: true, - }) - writeJSON(w, map[string]any{"ok": true, "id": id}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/techs/") - if id == "" { - http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) - return - } - if !validEntityID(id) { - http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) - return - } - - switch r.Method { - case http.MethodGet: - data, path, err := s.findTechFile(id) - if err != nil { - failJSON(w, "not found: "+id) - return - } - raw, err := yamlToMap(data) - if err != nil { - failJSON(w, "parse error: "+err.Error()) - return - } - raw["id"] = id - raw["_path"] = path - raw["_raw"] = string(data) - writeJSON(w, raw) - - case http.MethodPost, http.MethodPut: - var body map[string]any - if err := readJSON(r, &body); err != nil { - failJSON(w, "invalid JSON: "+err.Error()) - return - } - delete(body, "id") - delete(body, "_path") - delete(body, "_raw") - - _, path, findErr := s.findTechFile(id) - isCreate := findErr != nil - if isCreate { - path = filepath.Join(s.dataDir, "techs", id+".yaml") - } - - var oldContent []byte - if !isCreate { - existing, existed, err := snapshotBefore(path) - if err != nil { - failJSON(w, "read existing file: "+err.Error()) - return - } - if !existed { - failJSON(w, "not found: "+id) - return - } - oldContent = existing - } - - newContent, err := writeMapAsYAML(path, body) - if err != nil { - failJSON(w, "write error: "+err.Error()) - return - } - - desc := "Updated tech " + id - if isCreate { - desc = "Created tech " + id - } - s.undoStack.Push(ChangeDesc{ - Description: desc, - FilePath: path, - OldContent: oldContent, - NewContent: newContent, - IsCreate: isCreate, - }) - writeJSON(w, map[string]any{"ok": true, "id": id}) - - case http.MethodDelete: - _, path, err := s.findTechFile(id) - if err != nil { - failJSON(w, "not found: "+id) - return - } - oldContent, _ := snapshotFile(path) - if err := os.Remove(path); err != nil { - failJSON(w, "delete error: "+err.Error()) - return - } - s.undoStack.Push(ChangeDesc{ - Description: "Deleted tech " + id, - FilePath: path, - OldContent: oldContent, - IsDelete: true, - }) - writeJSON(w, map[string]any{"ok": true, "id": id}) - - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} diff --git a/internal/admin/hardening_security_test.go b/internal/admin/hardening_security_test.go index a077983..ba6b3dd 100644 --- a/internal/admin/hardening_security_test.go +++ b/internal/admin/hardening_security_test.go @@ -153,9 +153,13 @@ func TestStaticRequiresAuthAndDoesNotList(t *testing.T) { func newSecurityTestServer(t *testing.T) *AdminServer { t.Helper() h, _ := newEditorHarness(t) - s, err := NewServer(config.Default(), false, h.g.AccountStore, h.g.World, h.g.ItemStore, h.g.ObjectStore, - h.g.MobStore, h.dir, h.g.CourseStore.ReloadAll, h.g.MigrateRoomIDsOnOwner, h.g.EvictCasinoRoom, - h.g.RebuildCraftIndex, filepath.Join(h.dir, "config.yaml"), h.g.RunAdminOnOwner) + s, err := NewServer(ServerDeps{ + Config: config.Default(), Accounts: h.g.AccountStore, World: h.g.World, Items: h.g.ItemStore, + Objects: h.g.ObjectStore, Mobs: h.g.MobStore, DataDir: h.dir, + ReloadCourses: h.g.CourseStore.ReloadAll, RewriteRoomIDs: h.g.MigrateRoomIDsOnOwner, + EvictRoom: h.g.EvictCasinoRoom, RebuildCraftIndex: h.g.RebuildCraftIndex, + ConfigPath: filepath.Join(h.dir, "config.yaml"), RunOnOwner: h.g.RunAdminOnOwner, + }) if err != nil { t.Fatal(err) } diff --git a/internal/admin/live_owner_test.go b/internal/admin/live_owner_test.go index e975681..ae53606 100644 --- a/internal/admin/live_owner_test.go +++ b/internal/admin/live_owner_test.go @@ -47,8 +47,12 @@ func TestAdminEditorAtomicWithGameplay(t *testing.T) { } h.g.MigrateRoomIDsOnOwner(ids) } - s, err := NewServer(config.Default(), false, h.g.AccountStore, h.g.World, h.g.ItemStore, h.g.ObjectStore, h.g.MobStore, h.dir, - h.g.CourseStore.ReloadAll, migrate, h.g.EvictCasinoRoom, h.g.RebuildCraftIndex, filepath.Join(h.dir, "config.yaml"), h.g.RunAdminOnOwner) + s, err := NewServer(ServerDeps{ + Config: config.Default(), Accounts: h.g.AccountStore, World: h.g.World, + Items: h.g.ItemStore, Objects: h.g.ObjectStore, Mobs: h.g.MobStore, DataDir: h.dir, + ReloadCourses: h.g.CourseStore.ReloadAll, RewriteRoomIDs: migrate, EvictRoom: h.g.EvictCasinoRoom, + RebuildCraftIndex: h.g.RebuildCraftIndex, ConfigPath: filepath.Join(h.dir, "config.yaml"), RunOnOwner: h.g.RunAdminOnOwner, + }) if err != nil { t.Fatal(err) } diff --git a/internal/admin/owner_routes_test.go b/internal/admin/owner_routes_test.go index a061b0e..84f48c8 100644 --- a/internal/admin/owner_routes_test.go +++ b/internal/admin/owner_routes_test.go @@ -16,9 +16,14 @@ import ( func TestAPIRoutesRequireOwnerAdmission(t *testing.T) { h, _ := newEditorHarness(t) tasks := 0 - s, err := NewServer(config.Default(), false, h.g.AccountStore, h.g.World, h.g.ItemStore, h.g.ObjectStore, h.g.MobStore, h.dir, - h.g.CourseStore.ReloadAll, h.g.MigrateRoomIDsOnOwner, h.g.EvictCasinoRoom, h.g.RebuildCraftIndex, filepath.Join(h.dir, "config.yaml"), - func(func()) bool { tasks++; return false }) + s, err := NewServer(ServerDeps{ + Config: config.Default(), Accounts: h.g.AccountStore, World: h.g.World, Items: h.g.ItemStore, + Objects: h.g.ObjectStore, Mobs: h.g.MobStore, DataDir: h.dir, + ReloadCourses: h.g.CourseStore.ReloadAll, RewriteRoomIDs: h.g.MigrateRoomIDsOnOwner, + EvictRoom: h.g.EvictCasinoRoom, RebuildCraftIndex: h.g.RebuildCraftIndex, + ConfigPath: filepath.Join(h.dir, "config.yaml"), + RunOnOwner: func(func()) bool { tasks++; return false }, + }) if err != nil { t.Fatal(err) } diff --git a/internal/admin/server.go b/internal/admin/server.go index f35be84..4cceadd 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -91,8 +91,29 @@ type AdminServer struct { loginLimiter *loginLimiter } -func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), evictRoom func(roomID int), rebuildCraftIndex func(), configPath string, runOnOwner func(func()) bool) (*AdminServer, error) { - if runOnOwner == nil { +// ServerDeps are the long-lived stores and wired callbacks the admin server +// uses. It replaces a 15-argument positional constructor in which every +// callback had the same func() shape, so a mis-ordered call compiled silently +// and only failed at runtime. +type ServerDeps struct { + Config *config.Config + UseTLS bool + Accounts *player.AccountStore + World *world.World + Items *item.ItemStore + Objects *object.ObjectStore + Mobs *world.MobStore + DataDir string + ReloadCourses func() + RewriteRoomIDs func(map[int]int) + EvictRoom func(roomID int) + RebuildCraftIndex func() + ConfigPath string + RunOnOwner func(func()) bool +} + +func NewServer(deps ServerDeps) (*AdminServer, error) { + if deps.RunOnOwner == nil { return nil, fmt.Errorf("admin server requires an owner dispatcher") } secret := make([]byte, 32) @@ -111,42 +132,42 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor } var port int - if useTLS { - port = cfg.AdminHTTPS.Port + if deps.UseTLS { + port = deps.Config.AdminHTTPS.Port } else { - port = cfg.AdminHTTP.Port + port = deps.Config.AdminHTTP.Port } var cert tls.Certificate - if useTLS { - cert, err = loadOrGenerateCert(cfg) + if deps.UseTLS { + cert, err = loadOrGenerateCert(deps.Config) if err != nil { return nil, fmt.Errorf("admin cert: %w", err) } } s := &AdminServer{ - cfg: cfg, - useTLS: useTLS, - accountStore: accountStore, - world: w, - itemStore: is, - objectStore: os, - mobStore: ms, - dataDir: dataDir, - undoStack: NewUndoStack(dataDir), + cfg: deps.Config, + useTLS: deps.UseTLS, + accountStore: deps.Accounts, + world: deps.World, + itemStore: deps.Items, + objectStore: deps.Objects, + mobStore: deps.Mobs, + dataDir: deps.DataDir, + undoStack: NewUndoStack(deps.DataDir), tmpl: tmpl, cookieSecret: secret, - reloadCourses: reloadCourses, - rewriteRoomIDs: rewriteRoomIDs, - evictRoom: evictRoom, - rebuildCraftIndex: rebuildCraftIndex, - configPath: configPath, - runOnOwner: runOnOwner, + reloadCourses: deps.ReloadCourses, + rewriteRoomIDs: deps.RewriteRoomIDs, + evictRoom: deps.EvictRoom, + rebuildCraftIndex: deps.RebuildCraftIndex, + configPath: deps.ConfigPath, + runOnOwner: deps.RunOnOwner, } // Undo/redo of room-ID changes applies the (inverse) runtime migration // through the same wired callback the rename uses. - s.undoStack.SetMigrateRoomIDs(rewriteRoomIDs) + s.undoStack.SetMigrateRoomIDs(deps.RewriteRoomIDs) mux := http.NewServeMux() @@ -194,38 +215,38 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor apiMux.HandleFunc("/api/objects/rename", s.handleEntityRename("objects")) apiMux.HandleFunc("/api/objects/move", s.handleEntityMove("objects")) apiMux.HandleFunc("/api/objects/dirs", s.handleEntityDirs("objects")) - apiMux.HandleFunc("/api/objects", s.handleObjects) - apiMux.HandleFunc("/api/objects/", s.handleObjectByID) + apiMux.HandleFunc("/api/objects", s.handleEntityList(objectSpec)) + apiMux.HandleFunc("/api/objects/", s.handleEntityByID(objectSpec)) apiMux.HandleFunc("/api/items/rename", s.handleEntityRename("items")) apiMux.HandleFunc("/api/items/move", s.handleEntityMove("items")) apiMux.HandleFunc("/api/items/dirs", s.handleEntityDirs("items")) - apiMux.HandleFunc("/api/items", s.handleItems) - apiMux.HandleFunc("/api/items/", s.handleItemByID) + apiMux.HandleFunc("/api/items", s.handleEntityList(itemSpec)) + apiMux.HandleFunc("/api/items/", s.handleEntityByID(itemSpec)) apiMux.HandleFunc("/api/mobs/rename", s.handleEntityRename("mobs")) apiMux.HandleFunc("/api/mobs/move", s.handleEntityMove("mobs")) apiMux.HandleFunc("/api/mobs/dirs", s.handleEntityDirs("mobs")) - apiMux.HandleFunc("/api/mobs", s.handleMobs) - apiMux.HandleFunc("/api/mobs/", s.handleMobByID) + apiMux.HandleFunc("/api/mobs", s.handleEntityList(mobSpec)) + apiMux.HandleFunc("/api/mobs/", s.handleEntityByID(mobSpec)) apiMux.HandleFunc("/api/drops/rename", s.handleEntityRename("drops")) apiMux.HandleFunc("/api/drops/move", s.handleEntityMove("drops")) apiMux.HandleFunc("/api/drops/dirs", s.handleEntityDirs("drops")) - apiMux.HandleFunc("/api/drops", s.handleDrops) - apiMux.HandleFunc("/api/drops/", s.handleDropByID) + apiMux.HandleFunc("/api/drops", s.handleEntityList(dropSpec)) + apiMux.HandleFunc("/api/drops/", s.handleEntityByID(dropSpec)) apiMux.HandleFunc("/api/hazards/rename", s.handleEntityRename("hazards")) apiMux.HandleFunc("/api/hazards/move", s.handleEntityMove("hazards")) apiMux.HandleFunc("/api/hazards/dirs", s.handleEntityDirs("hazards")) - apiMux.HandleFunc("/api/hazards", s.handleHazards) - apiMux.HandleFunc("/api/hazards/", s.handleHazardByID) + apiMux.HandleFunc("/api/hazards", s.handleEntityList(hazardSpec)) + apiMux.HandleFunc("/api/hazards/", s.handleEntityByID(hazardSpec)) apiMux.HandleFunc("/api/techs/rename", s.handleEntityRename("techs")) apiMux.HandleFunc("/api/techs/move", s.handleEntityMove("techs")) apiMux.HandleFunc("/api/techs/dirs", s.handleEntityDirs("techs")) - apiMux.HandleFunc("/api/techs", s.handleTechs) - apiMux.HandleFunc("/api/techs/", s.handleTechByID) + apiMux.HandleFunc("/api/techs", s.handleEntityList(techSpec)) + apiMux.HandleFunc("/api/techs/", s.handleEntityByID(techSpec)) apiMux.HandleFunc("/api/modules/rename", s.handleEntityRename("modules")) apiMux.HandleFunc("/api/modules/move", s.handleEntityMove("modules")) apiMux.HandleFunc("/api/modules/dirs", s.handleEntityDirs("modules")) - apiMux.HandleFunc("/api/modules", s.handleModules) - apiMux.HandleFunc("/api/modules/", s.handleModuleByID) + apiMux.HandleFunc("/api/modules", s.handleEntityList(moduleSpec)) + apiMux.HandleFunc("/api/modules/", s.handleEntityByID(moduleSpec)) apiMux.HandleFunc("/api/courses", s.handleCourses) apiMux.HandleFunc("/api/courses/", s.handleCourseByID) apiMux.HandleFunc("/api/players", s.handlePlayers) @@ -313,7 +334,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second, } - if useTLS { + if deps.UseTLS { s.httpServer.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, -- cgit v1.2.3