diff options
| author | historia <[not public]> | 2026-09-18 01:50:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 01:50:17 -0400 |
| commit | 87d08f0151e643951769e323995f1c5ed89ee200 (patch) | |
| tree | dc42a0b30ebb5a00dd7ba505cc4eaa44f4f308bc /internal/admin/api_files.go | |
| parent | 32a3c59cebf2a16277e54d715a67adfdbe00b456 (diff) | |
| download | thehouseoficarus-87d08f0151e643951769e323995f1c5ed89ee200.tar.gz | |
fix: harden economy recovery, shutdown concurrency, world validation, and inventory-loss paths
Diffstat (limited to 'internal/admin/api_files.go')
| -rw-r--r-- | internal/admin/api_files.go | 79 |
1 files changed, 77 insertions, 2 deletions
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index d32bda4..2b59526 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strconv" "strings" + + "thehouseoficarus/internal/persistence" ) // resolveDataPath resolves a request-supplied path inside the admin data @@ -93,7 +95,7 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { return } - if err := os.WriteFile(newAbs, oldContent, 0644); err != nil { + if err := writeFilePreservingMode(newAbs, oldContent); err != nil { failJSON(w, "write error: "+err.Error()) return } @@ -174,6 +176,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { failJSON(w, "read error: "+err.Error()) return } + data = redactAccountContent(relPath, data) writeJSON(w, map[string]any{ "name": info.Name(), "content": string(data), @@ -190,6 +193,9 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { 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. + body = restoreAccountSecret(relPath, fullPath, body) var oldContent []byte if existing, err := snapshotFile(fullPath); err == nil { @@ -202,7 +208,7 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { return } - if err := os.WriteFile(fullPath, body, 0644); err != nil { + if err := writeFilePreservingMode(fullPath, body); err != nil { failJSON(w, "write error: "+err.Error()) return } @@ -244,3 +250,72 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) { 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 0644 for a new file). +func writeFilePreservingMode(path string, data []byte) error { + return persistence.WriteFile(path, data, 0644) +} + +// 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. +func restoreAccountSecret(relPath, fullPath string, body []byte) []byte { + if !isAccountFilePath(relPath) { + return body + } + existing, err := os.ReadFile(fullPath) + if err != nil { + return body + } + em, err := yamlToMap(existing) + if err != nil { + return body + } + hash, ok := em["password_hash"] + if !ok { + return body + } + bm, err := yamlToMap(body) + if err != nil { + return body + } + bm["password_hash"] = hash + out, err := marshalMapYAML(bm) + if err != nil { + return body + } + return out +} |
