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.go79
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
+}