aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_files.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/api_files.go')
-rw-r--r--internal/admin/api_files.go61
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