From b3568f03e4103dd46db2ccc680fbab0a6454d604 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 13:56:10 -0400 Subject: fix: telnet buffer reuse, bank stack handling, transport casino gate, and admin cache invalidation --- internal/admin/api_items.go | 11 +++++ internal/admin/api_items_undo_test.go | 82 +++++++++++++++++++++++++++++++++++ internal/admin/api_players.go | 41 +++++++++++++++++- internal/admin/server.go | 44 ++++++++++++------- 4 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 internal/admin/api_items_undo_test.go (limited to 'internal/admin') diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go index 617908c..728997a 100644 --- a/internal/admin/api_items.go +++ b/internal/admin/api_items.go @@ -46,6 +46,14 @@ func (s *AdminServer) handleItemByID(w http.ResponseWriter, r *http.Request) { } } +// 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 { @@ -103,6 +111,7 @@ func (s *AdminServer) updateItem(w http.ResponseWriter, r *http.Request, id stri OldContent: oldContent, NewContent: newContent, }) + s.invalidateItemCache() writeJSON(w, m) } @@ -127,6 +136,7 @@ func (s *AdminServer) deleteItem(w http.ResponseWriter, r *http.Request, id stri OldContent: oldContent, IsDelete: true, }) + s.invalidateItemCache() writeJSON(w, map[string]any{"deleted": id}) } @@ -162,6 +172,7 @@ func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) { 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 new file mode 100644 index 0000000..3ccbe10 --- /dev/null +++ b/internal/admin/api_items_undo_test.go @@ -0,0 +1,82 @@ +package admin + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func newTestWorld(dir string) *world.World { + return world.New(dir) +} + +// Regression: _raw must not leak the password hash even when it is a +// multiline YAML block scalar, which line-based masking misses. +func TestAccountAPIRedactsMultilinePasswordHash(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "players", "accounts"), 0o755); err != nil { + t.Fatal(err) + } + body := "name: alice\npassword_hash: |-\n sensitive-hash-sentinel\n" + if err := os.WriteFile(filepath.Join(dir, "players", "accounts", "alice.yaml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + s := &AdminServer{accountStore: player.NewAccountStore(dir)} + + rr := httptest.NewRecorder() + s.getAccount(rr, httptest.NewRequest("GET", "/api/players/account/alice", nil), "alice") + + if strings.Contains(rr.Body.String(), "sensitive-hash-sentinel") { + t.Fatalf("password hash leaked in response: %s", rr.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("response is not valid json: %v", err) + } +} + +// Regression: undo/redo of an item edit must invalidate the runtime item +// cache (and rebuild dependent indexes), not leave gameplay on the stale +// definition. +func TestUndoRefreshesRuntimeItemCache(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "items"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "items", "coin.yaml"), []byte("name: Coin\nvalue: 1\n"), 0o644); err != nil { + t.Fatal(err) + } + s := &AdminServer{ + dataDir: dir, + itemStore: item.NewItemStore(dir), + world: newTestWorld(dir), + undoStack: NewUndoStack(dir), + } + + // Warm the cache with the original definition. + 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") + 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) + } + + s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil)) + + restored, err := s.itemStore.Load("coin") + if err != nil || restored.Value != 1 { + t.Fatalf("undo left stale runtime state: %v value=%+v", err, restored) + } +} diff --git a/internal/admin/api_players.go b/internal/admin/api_players.go index f7a0b87..f0bbe31 100644 --- a/internal/admin/api_players.go +++ b/internal/admin/api_players.go @@ -1,6 +1,7 @@ package admin import ( + "bytes" "net/http" "os" "path/filepath" @@ -157,6 +158,44 @@ func (s *AdminServer) getAccount(w http.ResponseWriter, r *http.Request, name st delete(m, "password_hash") m["_path"] = accPath - m["_raw"] = string(data) + // _raw must not leak the password hash back over the API. Re-encode the + // parsed document with the password_hash value replaced so multiline and + // quoted values are redacted too — substring masking only covers the key's + // own line. + m["_raw"] = redactYAMLValue(data, "password_hash") writeJSON(w, m) } + +// redactYAMLValue returns data re-encoded with every top-level value for the +// named key replaced by a fixed placeholder. Empty output falls back to the +// original data so a parse failure never silently hides the document. +func redactYAMLValue(data []byte, key string) string { + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil || len(node.Content) == 0 { + return string(data) + } + root := node.Content[0] + if root.Kind != yaml.MappingNode { + return string(data) + } + changed := false + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == key { + root.Content[i+1].Value = "[REDACTED]" + root.Content[i+1].Tag = "!!str" + root.Content[i+1].Style = 0 + changed = true + } + } + if !changed { + return string(data) + } + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&node); err != nil { + return "[REDACTED]" + } + enc.Close() + return buf.String() +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 842f640..ccad6a2 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -58,9 +58,13 @@ type AdminServer struct { // configPath is the on-disk config.yaml path, used by the color themer // to persist edited default_colors back to the file the game loaded. configPath string + // rebuildCraftIndex rebuilds the in-game craft index after item files + // change (create/update/delete/rename/move/undo/redo). Wired from the + // Game so the web editor's item edits stay consistent with gameplay. + rebuildCraftIndex func() } -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), configPath string) (*AdminServer, error) { +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), rebuildCraftIndex func(), configPath string) (*AdminServer, error) { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("cookie secret: %w", err) @@ -92,20 +96,21 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor } s := &AdminServer{ - cfg: cfg, - useTLS: useTLS, - accountStore: accountStore, - world: w, - itemStore: is, - objectStore: os, - mobStore: ms, - dataDir: dataDir, - undoStack: NewUndoStack(dataDir), - tmpl: tmpl, - cookieSecret: secret, - reloadCourses: reloadCourses, - rewriteRoomIDs: rewriteRoomIDs, - configPath: configPath, + cfg: cfg, + useTLS: useTLS, + accountStore: accountStore, + world: w, + itemStore: is, + objectStore: os, + mobStore: ms, + dataDir: dataDir, + undoStack: NewUndoStack(dataDir), + tmpl: tmpl, + cookieSecret: secret, + reloadCourses: reloadCourses, + rewriteRoomIDs: rewriteRoomIDs, + rebuildCraftIndex: rebuildCraftIndex, + configPath: configPath, } mux := http.NewServeMux() @@ -657,11 +662,18 @@ func (s *AdminServer) invalidateEntityCaches(entityType string) { s.mobStore.ReloadDefs(s.dataDir) case "drops": behavior.ClearDropIndex() + case "items": + if s.itemStore != nil { + s.itemStore.Reload(s.dataDir) + } + if s.rebuildCraftIndex != nil { + s.rebuildCraftIndex() + } } } func (s *AdminServer) invalidateEntityCacheForPath(path string) { - for _, subdir := range []string{"mobs", "drops"} { + for _, subdir := range []string{"mobs", "drops", "items"} { rel, err := filepath.Rel(filepath.Join(s.dataDir, subdir), path) if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { s.invalidateEntityCaches(subdir) -- cgit v1.2.3