diff options
Diffstat (limited to 'internal/admin')
| -rw-r--r-- | internal/admin/api_entity.go | 3 | ||||
| -rw-r--r-- | internal/admin/api_files.go | 22 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 26 | ||||
| -rw-r--r-- | internal/admin/correct_audit_fixes_test.go | 22 | ||||
| -rw-r--r-- | internal/admin/server.go | 12 | ||||
| -rw-r--r-- | internal/admin/undo.go | 17 |
6 files changed, 92 insertions, 10 deletions
diff --git a/internal/admin/api_entity.go b/internal/admin/api_entity.go index b32ab89..204c59e 100644 --- a/internal/admin/api_entity.go +++ b/internal/admin/api_entity.go @@ -38,7 +38,8 @@ var ( 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} + objectSpec = entityCRUD{route: "objects", noun: "object", searchSubdirs: true, + refresh: func(s *AdminServer) { s.invalidateEntityCaches("objects") }} 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} diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index b9f54c5..a19cb3b 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -23,6 +23,20 @@ func resolveDataPath(dataDir, relPath string) (string, error) { return filepath.Join(dataDir, clean), nil } +// canonicalDataRelPath normalizes a client-supplied data-relative path so that +// repeated leading "/" or "data/" prefixes cannot make the security +// classifiers (isPlayerDataFilePath / isAccountFilePath) disagree with the +// path resolveDataPath actually reads. Without this, "//players/..." resolved +// to the real player file while the classifiers saw a non-player path. +func canonicalDataRelPath(relPath string) string { + clean := filepath.ToSlash(filepath.Clean("/" + strings.TrimPrefix(relPath, "/"))) + clean = strings.TrimPrefix(clean, "/") + for strings.HasPrefix(clean, "data/") { + clean = strings.TrimPrefix(clean, "data/") + } + return clean +} + // evictDeletedRoomFile drops cached room state and any cached in-room games // when the raw file manager deletes a room YAML (rooms/<dir>/<id>.yaml). // Best-effort: only numeric .yaml stems under rooms/ count as room files. @@ -75,8 +89,7 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { } } - relPath := strings.TrimPrefix(body.Path, "/") - relPath = strings.TrimPrefix(relPath, "data/") + relPath := canonicalDataRelPath(body.Path) oldAbs, err := resolveDataPath(s.dataDir, relPath) if err != nil { failJSON(w, err.Error()) @@ -145,9 +158,7 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { // is currently online is refused: overwriting it would clobber the in-memory // player the game will later save. func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { - relPath := r.URL.Query().Get("path") - relPath = strings.TrimPrefix(relPath, "/") - relPath = strings.TrimPrefix(relPath, "data/") + relPath := canonicalDataRelPath(r.URL.Query().Get("path")) fullPath, pathErr := resolveDataPath(s.dataDir, relPath) if pathErr != nil { failJSON(w, pathErr.Error()) @@ -173,7 +184,6 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { return } - fullPath := filepath.Join(s.dataDir, relPath) info, err := os.Stat(fullPath) if err != nil { failJSON(w, "not found: "+relPath) diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index efc466c..586bfff 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -159,6 +159,16 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) { // source room's new exit first, so a failed destination write left // a dangling exit pointing at a room that never existed, with no // undo entry to repair it. + // Validate before writing, exactly like the PUT and bulk-edit paths: + // otherwise a create can persist a room the loader/validator reject. + if content, marshalErr := marshalMapYAML(m); marshalErr != nil { + failJSON(w, fmt.Sprintf("marshal room: %v", marshalErr)) + return + } else if err := validateRoomSchema(content); err != nil { + writeJSONError(w, "room failed schema validation: "+err.Error(), http.StatusBadRequest) + return + } + path := filepath.Join(subdir, strconv.Itoa(id)+".yaml") newContent, writeErr := writeMapAsYAML(path, m) if writeErr != nil { @@ -1244,6 +1254,14 @@ func (s *AdminServer) handleRoomSwap(w http.ResponseWriter, r *http.Request) { return } + // The files now declare each other's content; refresh both rooms' live + // runtime state or each keeps its old objects/mobs alongside the new ones + // (seeding is add-only). + if s.reconcileRoom != nil { + s.reconcileRoom(aID) + s.reconcileRoom(bID) + } + s.undoStack.Push(ChangeDesc{ Description: fmt.Sprintf("swap room %d with %d (%s)", aID, bID, dir), FilePath: aPath, @@ -1433,6 +1451,14 @@ func (s *AdminServer) handleCreateEmptyRoom(w http.ResponseWriter, r *http.Reque "name": roomName, "description": []map[string]any{{"text": "An empty room."}}, } + // Validate before writing, exactly like the main create path. + if content, marshalErr := marshalMapYAML(m); marshalErr != nil { + failJSON(w, "marshal room: "+marshalErr.Error()) + return + } else if err := validateRoomSchema(content); err != nil { + writeJSONError(w, "room failed schema validation: "+err.Error(), http.StatusBadRequest) + return + } newContent, writeErr := writeMapAsYAML(path, m) if writeErr != nil { failJSON(w, "write room: "+writeErr.Error()) diff --git a/internal/admin/correct_audit_fixes_test.go b/internal/admin/correct_audit_fixes_test.go new file mode 100644 index 0000000..381fb5c --- /dev/null +++ b/internal/admin/correct_audit_fixes_test.go @@ -0,0 +1,22 @@ +package admin + +import "testing" + +// Regression: the raw file API must canonicalize repeated "/" and "data/" +// prefixes, or "//players/..." reaches the real file while the classifiers +// see a non-player path and skip the redaction / online refusal. +func TestCanonicalDataRelPathStripsRepeatedPrefixes(t *testing.T) { + cases := map[string]string{ + "players/accounts/x.yaml": "players/accounts/x.yaml", + "/players/accounts/x.yaml": "players/accounts/x.yaml", + "//players/accounts/x.yaml": "players/accounts/x.yaml", + "data/players/accounts/x.yaml": "players/accounts/x.yaml", + "data/data/players/accounts/x.yaml": "players/accounts/x.yaml", + "../etc/passwd": "etc/passwd", + } + for in, want := range cases { + if got := canonicalDataRelPath(in); got != want { + t.Errorf("canonicalDataRelPath(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 96b5981..8fcba77 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -1092,6 +1092,12 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { if s.evictRoom != nil { s.evictRoom(id) } + } else if s.reconcileRoom != nil { + // The file still exists but its content changed: refresh the + // live room so a removed spawn/object stops lingering and a + // restored one appears. Without this, undo of a content edit + // is not reflected until restart. + s.reconcileRoom(id) } } } @@ -1126,11 +1132,15 @@ func (s *AdminServer) invalidateEntityCaches(entityType string) { if s.rebuildCraftIndex != nil { s.rebuildCraftIndex() } + case "objects": + if s.objectStore != nil { + s.objectStore.Reload(s.dataDir) + } } } func (s *AdminServer) invalidateEntityCacheForPath(path string) { - for _, subdir := range []string{"mobs", "drops", "items"} { + for _, subdir := range []string{"mobs", "drops", "items", "objects"} { rel, err := filepath.Rel(filepath.Join(s.dataDir, subdir), path) if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { s.invalidateEntityCaches(subdir) diff --git a/internal/admin/undo.go b/internal/admin/undo.go index 3001c8b..5683f13 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -754,12 +754,21 @@ func (us *UndoStack) load() { toChanges := func(entries []undoEntry) []ChangeDesc { out := make([]ChangeDesc, 0, len(entries)) for _, e := range entries { + // A before-image recorded as nil is persisted as "" and would + // reload as a non-nil empty slice, defeating requireSnapshot and + // letting undo truncate the file to zero bytes. Map it back to nil + // so the guard fires. An intentionally empty before-image is also + // refused, which is safer than zeroing a live file. + oldContent := []byte(e.OldContent) + if e.OldContent == "" { + oldContent = nil + } c := ChangeDesc{ Time: e.Time, Description: e.Description, FilePath: e.FilePath, NewFilePath: e.NewFilePath, - OldContent: []byte(e.OldContent), + OldContent: oldContent, NewContent: []byte(e.NewContent), IsDelete: e.IsDelete, IsCreate: e.IsCreate, @@ -767,10 +776,14 @@ func (us *UndoStack) load() { IDMap: e.IDMap, } for _, ef := range e.ExtraFiles { + oldExtra := []byte(ef.OldContent) + if ef.OldContent == "" { + oldExtra = nil + } c.ExtraFiles = append(c.ExtraFiles, ExtraFile{ FilePath: ef.FilePath, NewFilePath: ef.NewFilePath, - OldContent: []byte(ef.OldContent), + OldContent: oldExtra, NewContent: []byte(ef.NewContent), IsDelete: ef.IsDelete, IsCreate: ef.IsCreate, |
