aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_items.go11
-rw-r--r--internal/admin/api_items_undo_test.go82
-rw-r--r--internal/admin/api_players.go41
-rw-r--r--internal/admin/server.go44
4 files changed, 161 insertions, 17 deletions
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)