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 == "" {
writeJSON(w, map[string]any{"error": "missing path or new_name"})
return
}
newName := strings.TrimSpace(body.NewName)
if newName == "" || newName == "." || newName == ".." || strings.ContainsAny(newName, "/\\") {
writeJSON(w, map[string]any{"error": "invalid file name"})
return
}
for _, c := range newName {
if c < 32 || c == 127 {
writeJSON(w, map[string]any{"error": "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 {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
oldContent, err := snapshotFile(oldAbs)
if err != nil {
writeJSON(w, map[string]any{"error": "file not found: " + relPath})
return
}
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
if _, err := os.Stat(newAbs); err == nil {
writeJSON(w, map[string]any{"error": "a file named " + newName + " already exists"})
return
}
if err := os.WriteFile(newAbs, oldContent, 0644); err != nil {
writeJSON(w, map[string]any{"error": "write error: " + err.Error()})
return
}
if err := os.Remove(oldAbs); err != nil {
writeJSON(w, map[string]any{"error": "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 {
writeJSON(w, map[string]any{"error": pathErr.Error()})
return
}
switch r.Method {
case http.MethodGet:
if relPath == "" {
entries, err := os.ReadDir(fullPath)
if err != nil {
writeJSON(w, map[string]any{"error": 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 {
writeJSON(w, map[string]any{"error": "not found: " + relPath})
return
}
if info.IsDir() {
entries, err := os.ReadDir(fullPath)
if err != nil {
writeJSON(w, map[string]any{"error": 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 {
writeJSON(w, map[string]any{"error": "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 == "" {
writeJSON(w, map[string]any{"error": "missing path"})
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeJSON(w, map[string]any{"error": "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 {
writeJSON(w, map[string]any{"error": "mkdir: " + err.Error()})
return
}
if err := os.WriteFile(fullPath, body, 0644); err != nil {
writeJSON(w, map[string]any{"error": "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 == "" {
writeJSON(w, map[string]any{"error": "missing path"})
return
}
oldContent, _ := snapshotFile(fullPath)
if err := os.Remove(fullPath); err != nil {
writeJSON(w, map[string]any{"error": "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)
}
}