package admin import ( "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "thehouseoficarus/internal/persistence" ) // resolveDataPath resolves a request-supplied path inside the admin data // directory, rejecting paths that would escape it via .. segments or a symlink. // The returned path is always filepath.Join(dataDir, cleaned relative path). func resolveDataPath(dataDir, relPath string) (string, error) { clean := filepath.Clean(strings.TrimPrefix(strings.TrimPrefix(relPath, "/"), "data/")) if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path escapes the data directory: %s", relPath) } full := filepath.Join(dataDir, clean) // Defence in depth: a symlink inside data/ could redirect a read or write // outside it, so resolve the longest existing prefix and re-check // containment. The root is resolved too, since the data dir itself may be // a symlink (e.g. a mounted volume). resolved, err := evalExisting(full) if err != nil { return "", fmt.Errorf("resolve path %s: %w", relPath, err) } root := dataDir if r, err := filepath.EvalSymlinks(dataDir); err == nil { root = r } if !withinDir(root, resolved) { return "", fmt.Errorf("path escapes the data directory via a symlink: %s", relPath) } return full, nil } // evalExisting resolves symlinks on the longest existing prefix of path and // re-appends the not-yet-existing suffix, so a create target is still checked. func evalExisting(path string) (string, error) { p := path var suffix []string for { resolved, err := filepath.EvalSymlinks(p) if err == nil { for i := len(suffix) - 1; i >= 0; i-- { resolved = filepath.Join(resolved, suffix[i]) } return resolved, nil } if !os.IsNotExist(err) { return "", err } parent := filepath.Dir(p) if parent == p { return path, nil } suffix = append(suffix, filepath.Base(p)) p = parent } } // withinDir reports whether path lies inside dir. func withinDir(dir, path string) bool { rel, err := filepath.Rel(dir, path) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // 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//.yaml). // Best-effort: only numeric .yaml stems under rooms/ count as room files. func (s *AdminServer) evictDeletedRoomFile(relPath string) { dir, base := filepath.Split(relPath) idStr, ok := strings.CutSuffix(base, ".yaml") if !ok || idStr == "" { return } dir = strings.TrimSuffix(dir, "/") if dir == "rooms" || strings.HasPrefix(dir, "rooms"+string(filepath.Separator)) { id, err := strconv.Atoi(idStr) if err != nil { return } s.world.RebuildRoomIndex(s.dataDir) s.world.ClearRoomState(id) if s.mobStore != nil { s.mobStore.RemoveMobsInRoom(id) } if s.evictRoom != nil { s.evictRoom(id) } } } func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Path string `json:"path"` NewName string `json:"new_name"` } if err := readJSON(r, &body); err != nil || body.Path == "" || body.NewName == "" { failJSON(w, "missing path or new_name") return } newName := strings.TrimSpace(body.NewName) if newName == "" || newName == "." || newName == ".." || strings.ContainsAny(newName, "/\\") { failJSON(w, "invalid file name") return } for _, c := range newName { if c < 32 || c == 127 { failJSON(w, "file name contains invalid characters") return } } relPath := canonicalDataRelPath(body.Path) oldAbs, err := resolveDataPath(s.dataDir, relPath) if err != nil { failJSON(w, err.Error()) return } if isPlayerDataFilePath(relPath) && s.playerFileOnline(relPath) { failJSON(w, "cannot rename a player/account file while it is online; log the character out first") return } oldContent, err := snapshotFile(oldAbs) if err != nil { failJSON(w, "file not found: "+relPath) return } newAbs := filepath.Join(filepath.Dir(oldAbs), newName) if _, err := os.Stat(newAbs); err == nil { failJSON(w, "a file named "+newName+" already exists") return } if err := writeFilePreservingMode(newAbs, oldContent); err != nil { failJSON(w, "write error: "+err.Error()) return } if err := os.Remove(oldAbs); err != nil { failJSON(w, "remove error: "+err.Error()) return } // A raw rename changes the ID-bearing filename. Rebuild the room index // and drop the old room's cached state (mirroring the DELETE path), and // refresh item/mob/drop caches, or the old ID resolves to a deleted file // and the new one resolves to nothing until a restart. s.evictDeletedRoomFile(relPath) s.invalidateEntityCacheForPath(newAbs) // Hazards and courses have their own runtime caches that a raw rename // must refresh (the undo path does the same). Techs and triggers keep the // documented in-game `reload` requirement. if pathUnderDir(s.dataDir, "hazards", newAbs) { s.world.ClearHazardCache() } if pathUnderDir(s.dataDir, "courses", newAbs) { s.reloadCoursesIfWired() } newRelPath := filepath.Join(filepath.Dir(relPath), newName) if filepath.Dir(relPath) == "." { newRelPath = newName } s.undoStack.Push(ChangeDesc{ Description: "Renamed file " + relPath + " to " + newRelPath, FilePath: oldAbs, NewFilePath: newAbs, OldContent: oldContent, NewContent: oldContent, }) writeJSON(w, map[string]any{"ok": true, "path": newRelPath}) } // handleFiles is only reachable through ownerMiddleware (see server.go), so // every player/account mutation below executes on the game owner and cannot // interleave with a live save. This is the same single-owner guarantee the // lifecycle transactions rely on. Additionally, a file whose character/account // 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 := canonicalDataRelPath(r.URL.Query().Get("path")) fullPath, pathErr := resolveDataPath(s.dataDir, relPath) if pathErr != nil { failJSON(w, pathErr.Error()) return } switch r.Method { case http.MethodGet: if relPath == "" { entries, err := os.ReadDir(fullPath) if err != nil { failJSON(w, err.Error()) return } var files []map[string]any for _, e := range entries { files = append(files, map[string]any{ "name": e.Name(), "dir": e.IsDir(), }) } writeJSON(w, files) return } info, err := os.Stat(fullPath) if err != nil { failJSON(w, "not found: "+relPath) return } if info.IsDir() { entries, err := os.ReadDir(fullPath) if err != nil { failJSON(w, err.Error()) return } var files []map[string]any for _, e := range entries { files = append(files, map[string]any{ "name": e.Name(), "dir": e.IsDir(), }) } writeJSON(w, files) return } data, err := os.ReadFile(fullPath) if err != nil { failJSON(w, "read error: "+err.Error()) return } data = redactAccountContent(relPath, data) writeJSON(w, map[string]any{ "name": info.Name(), "content": string(data), "path": relPath, }) case http.MethodPut, http.MethodPost: if relPath == "" { failJSON(w, "missing path") return } if isPlayerDataFilePath(relPath) && s.playerFileOnline(relPath) { failJSON(w, "cannot edit a player/account file while it is online; log the character out first") return } body, err := io.ReadAll(r.Body) if err != nil { failJSON(w, "read body: "+err.Error()) return } // A redacted account hash is restored from the file on disk before // writing, so the raw editor can never disclose or overwrite it. A // non-mapping body is refused rather than blanking the hash. body, err = restoreAccountSecret(relPath, fullPath, body) if err != nil { failJSON(w, err.Error()) return } oldContent, existed, err := snapshotBefore(fullPath) if err != nil { failJSON(w, "read existing file: "+err.Error()) return } dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { failJSON(w, "mkdir: "+err.Error()) return } if err := writeFilePreservingMode(fullPath, body); err != nil { failJSON(w, "write error: "+err.Error()) return } isCreate := !existed desc := "Updated file " + relPath if isCreate { desc = "Created file " + relPath } s.undoStack.Push(ChangeDesc{ Description: desc, FilePath: fullPath, OldContent: oldContent, NewContent: body, IsCreate: isCreate, }) writeJSON(w, map[string]any{"ok": true, "path": relPath}) case http.MethodDelete: if relPath == "" { failJSON(w, "missing path") return } if isPlayerDataFilePath(relPath) && s.playerFileOnline(relPath) { failJSON(w, "cannot delete a player/account file while it is online; log the character out first") return } oldContent, _ := snapshotFile(fullPath) if err := os.Remove(fullPath); err != nil { failJSON(w, "delete error: "+err.Error()) return } s.evictDeletedRoomFile(relPath) s.undoStack.Push(ChangeDesc{ Description: "Deleted file " + relPath, FilePath: fullPath, OldContent: oldContent, IsDelete: true, }) writeJSON(w, map[string]any{"ok": true, "path": relPath}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // writeFilePreservingMode writes data atomically without widening an existing // file's permissions (persistence.WriteFile preserves the current mode and // uses the caller's perm for a new file). Player/account files are treated as // owner-only: they carry password hashes and live player state, so a newly // created file is 0600 and an existing wider mode is tightened. func writeFilePreservingMode(path string, data []byte) error { perm := os.FileMode(0644) if isPlayerDataAbsPath(path) { perm = 0600 } if err := persistence.WriteFile(path, data, perm); err != nil { return err } if perm == 0600 { return os.Chmod(path, 0600) } return nil } // isPlayerDataFilePath reports whether a data-relative path is a player or // account YAML file (password hash / live character state). func isPlayerDataFilePath(relPath string) bool { clean := filepath.ToSlash(filepath.Clean(relPath)) return strings.HasPrefix(clean, "players/accounts/") || strings.HasPrefix(clean, "players/characters/") } // isPlayerDataAbsPath is the absolute-path form of isPlayerDataFilePath: it // matches .../players/accounts/.yaml and .../players/characters/.yaml. func isPlayerDataAbsPath(path string) bool { parent := filepath.Base(filepath.Dir(path)) if parent != "accounts" && parent != "characters" { return false } return filepath.Base(filepath.Dir(filepath.Dir(path))) == "players" } // playerFileOnline reports whether the character/account addressed by a // data-relative player file path has a live session. The game wires this via // SetPlayerFileOnlineCheck; nil (unwired) disables the refusal. func (s *AdminServer) playerFileOnline(relPath string) bool { if s.playerFileOnlineCheck == nil { return false } return s.playerFileOnlineCheck(relPath) } // isAccountFilePath reports whether a data-relative path is an account file // whose password hash must not be exposed or overwritten through the raw file // editor. func isAccountFilePath(relPath string) bool { clean := filepath.ToSlash(filepath.Clean(relPath)) return strings.HasPrefix(clean, "players/accounts/") } const redactedAccountHash = "[redacted]" // redactAccountContent replaces the password hash with a placeholder for GET // responses. The placeholder round-trips through restoreAccountSecret on save. func redactAccountContent(relPath string, data []byte) []byte { if !isAccountFilePath(relPath) { return data } m, err := yamlToMap(data) if err != nil { return data } if _, ok := m["password_hash"]; !ok { return data } m["password_hash"] = redactedAccountHash out, err := marshalMapYAML(m) if err != nil { return data } return out } // restoreAccountSecret copies the on-disk password hash back into a submitted // account file, so a redacted value (or any client-supplied value) can never // change or blank an existing hash through the raw file editor. It returns an // error when the existing hash cannot be preserved (for example a non-mapping // body), so the caller refuses the write instead of blanking the hash. func restoreAccountSecret(relPath, fullPath string, body []byte) ([]byte, error) { if !isAccountFilePath(relPath) { return body, nil } existing, err := os.ReadFile(fullPath) if err != nil { // A missing account file is a create; nothing to preserve. return body, nil } em, err := yamlToMap(existing) if err != nil { return nil, fmt.Errorf("existing account file is not a YAML mapping: %w", err) } hash, ok := em["password_hash"] if !ok { return body, nil } bm, err := yamlToMap(body) if err != nil { return nil, fmt.Errorf("submitted account file is not a YAML mapping; refusing to blank the password hash") } bm["password_hash"] = hash out, err := marshalMapYAML(bm) if err != nil { return nil, fmt.Errorf("marshal account file: %w", err) } return out, nil }