diff options
Diffstat (limited to 'internal/admin/api_files.go')
| -rw-r--r-- | internal/admin/api_files.go | 61 |
1 files changed, 59 insertions, 2 deletions
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index 2b59526..96b5e81 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -82,6 +82,10 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { 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 { @@ -118,6 +122,12 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { 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 := r.URL.Query().Get("path") relPath = strings.TrimPrefix(relPath, "/") @@ -188,6 +198,10 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { 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()) @@ -232,6 +246,10 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { 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()) @@ -253,9 +271,48 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { // writeFilePreservingMode writes data atomically without widening an existing // file's permissions (persistence.WriteFile preserves the current mode and -// uses 0644 for a new file). +// 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 { - return persistence.WriteFile(path, data, 0644) + 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/<name>.yaml and .../players/characters/<name>.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 |
