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. 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)
}
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/
/.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
}