package admin import ( "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" ) // 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 } // 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 := strings.TrimPrefix(body.Path, "/") relPath = strings.TrimPrefix(relPath, "data/") oldAbs, err := resolveDataPath(s.dataDir, relPath) if err != nil { failJSON(w, err.Error()) 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 := os.WriteFile(newAbs, oldContent, 0644); err != nil { failJSON(w, "write error: "+err.Error()) return } if err := os.Remove(oldAbs); err != nil { failJSON(w, "remove error: "+err.Error()) return } 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}) } 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/") 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 } fullPath := filepath.Join(s.dataDir, relPath) 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 } 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 } body, err := io.ReadAll(r.Body) if err != nil { failJSON(w, "read body: "+err.Error()) return } var oldContent []byte if existing, err := snapshotFile(fullPath); err == nil { oldContent = existing } dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { failJSON(w, "mkdir: "+err.Error()) return } if err := os.WriteFile(fullPath, body, 0644); err != nil { failJSON(w, "write error: "+err.Error()) return } isCreate := oldContent == nil 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 } 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) } }