aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/thoi/main.go34
-rw-r--r--internal/admin/api_drops.go176
-rw-r--r--internal/admin/api_entity.go343
-rw-r--r--internal/admin/api_entity_path_test.go16
-rw-r--r--internal/admin/api_entity_test.go147
-rw-r--r--internal/admin/api_hazards.go174
-rw-r--r--internal/admin/api_items.go186
-rw-r--r--internal/admin/api_items_undo_test.go4
-rw-r--r--internal/admin/api_mobs.go172
-rw-r--r--internal/admin/api_modules.go147
-rw-r--r--internal/admin/api_objects.go175
-rw-r--r--internal/admin/api_players_path_test.go8
-rw-r--r--internal/admin/api_techs.go167
-rw-r--r--internal/admin/hardening_security_test.go10
-rw-r--r--internal/admin/live_owner_test.go8
-rw-r--r--internal/admin/owner_routes_test.go11
-rw-r--r--internal/admin/server.go97
17 files changed, 619 insertions, 1256 deletions
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go
index cf31177..915d7ca 100644
--- a/cmd/thoi/main.go
+++ b/cmd/thoi/main.go
@@ -80,7 +80,22 @@ func main() {
// Both admin transports share one UndoStack over the shared history file.
var sharedUndo *admin.UndoStack
if cfg.AdminHTTP.Enabled {
- adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, adminReloadCourses, adminRewriteRoomIDs, adminEvictRoom, adminRebuildCraftIndex, *configPath, g.RunAdminOnOwner)
+ adminSrv, err := admin.NewServer(admin.ServerDeps{
+ Config: cfg,
+ UseTLS: false,
+ Accounts: g.AccountStore,
+ World: g.World,
+ Items: g.ItemStore,
+ Objects: g.ObjectStore,
+ Mobs: g.MobStore,
+ DataDir: dataDir,
+ ReloadCourses: adminReloadCourses,
+ RewriteRoomIDs: adminRewriteRoomIDs,
+ EvictRoom: adminEvictRoom,
+ RebuildCraftIndex: adminRebuildCraftIndex,
+ ConfigPath: *configPath,
+ RunOnOwner: g.RunAdminOnOwner,
+ })
if err != nil {
log.Fatalf("failed to start admin HTTP server: %v", err)
}
@@ -97,7 +112,22 @@ func main() {
}
if cfg.AdminHTTPS.Enabled {
- adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, adminReloadCourses, adminRewriteRoomIDs, adminEvictRoom, adminRebuildCraftIndex, *configPath, g.RunAdminOnOwner)
+ adminSrv, err := admin.NewServer(admin.ServerDeps{
+ Config: cfg,
+ UseTLS: true,
+ Accounts: g.AccountStore,
+ World: g.World,
+ Items: g.ItemStore,
+ Objects: g.ObjectStore,
+ Mobs: g.MobStore,
+ DataDir: dataDir,
+ ReloadCourses: adminReloadCourses,
+ RewriteRoomIDs: adminRewriteRoomIDs,
+ EvictRoom: adminEvictRoom,
+ RebuildCraftIndex: adminRebuildCraftIndex,
+ ConfigPath: *configPath,
+ RunOnOwner: g.RunAdminOnOwner,
+ })
if err != nil {
log.Fatalf("failed to start admin HTTPS server: %v", err)
}
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,