aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_colors.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/admin/api_colors.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/admin/api_colors.go')
-rw-r--r--internal/admin/api_colors.go143
1 files changed, 138 insertions, 5 deletions
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go
index b57ad82..7989e35 100644
--- a/internal/admin/api_colors.go
+++ b/internal/admin/api_colors.go
@@ -1,6 +1,8 @@
package admin
import (
+ "bytes"
+ "fmt"
"net/http"
"os"
"strings"
@@ -575,14 +577,145 @@ func (s *AdminServer) handleColorsReset(w http.ResponseWriter, r *http.Request)
writeJSON(w, map[string]any{"ok": true, "colors": live, "constant_colors": liveConst})
}
-// writeConfigFile marshals the entire live *config.Config back to config.yaml,
-// overwriting whatever was on disk. A full re-marshal drops the file's manual
-// comments but produces a valid, reloadable file with the edited colors.
+// writeConfigFile persists the two edited color maps back to config.yaml
+// without overwriting unrelated operator edits made after startup. The file
+// is re-read and only the default_colors / constant_colors mappings are
+// patched (comments and every other key are preserved), after writing a
+// backup. A missing/unreadable config falls back to a full marshal so a fresh
+// install still gets a valid file.
func (s *AdminServer) writeConfigFile() error {
- _ = backupFile(s.configPath) // best-effort snapshot for safety
- out, err := yaml.Marshal(s.cfg)
+ if err := s.backupConfigFile(); err != nil {
+ return err
+ }
+ data, err := os.ReadFile(s.configPath)
+ if err != nil {
+ // No config on disk yet (fresh install or removed): write the live
+ // config whole so the file exists and reloads.
+ out, merr := yaml.Marshal(s.cfg)
+ if merr != nil {
+ return merr
+ }
+ return os.WriteFile(s.configPath, out, 0644)
+ }
+ out, err := patchColorMaps(data, s.cfg.DefaultColors, s.cfg.ConstantColors)
if err != nil {
return err
}
return os.WriteFile(s.configPath, out, 0644)
}
+
+// backupConfigFile copies the current config.yaml next to itself before an
+// edit, so an operator can recover from a bad color save. A fresh install
+// (no file yet) has nothing to back up.
+func (s *AdminServer) backupConfigFile() error {
+ data, err := os.ReadFile(s.configPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ return os.WriteFile(s.configPath+".bak", data, 0600)
+}
+
+// patchColorMaps updates the default_colors and constant_colors mappings in a
+// config.yaml document, preserving every other key and all comments. Only the
+// keys present in the live maps are written: keys an operator added inside
+// those sections, and every comment around them, survive the save.
+func patchColorMaps(data []byte, defaults, constants map[string]string) ([]byte, error) {
+ var doc yaml.Node
+ if err := yaml.Unmarshal(data, &doc); err != nil {
+ return nil, fmt.Errorf("parse config: %w", err)
+ }
+ if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
+ return nil, fmt.Errorf("config is not a mapping")
+ }
+ root := doc.Content[0]
+ for _, patch := range []struct {
+ key string
+ m map[string]string
+ }{
+ {"default_colors", defaults},
+ {"constant_colors", constants},
+ } {
+ if err := patchMapping(root, patch.key, patch.m); err != nil {
+ return nil, err
+ }
+ }
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(&doc); err != nil {
+ return nil, err
+ }
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// patchMapping writes the entries of m into the mapping at root[key], creating
+// the mapping (and the key) when absent. Entries already on disk are updated
+// in place so their comments survive; entries on disk that m does not name are
+// left exactly as the operator wrote them.
+func patchMapping(root *yaml.Node, key string, m map[string]string) error {
+ fresh, err := colorMapNode(m)
+ if err != nil {
+ return err
+ }
+ for i := 0; i+1 < len(root.Content); i += 2 {
+ if root.Content[i].Value != key {
+ continue
+ }
+ if existing := root.Content[i+1]; existing.Kind == yaml.MappingNode {
+ mergeMapNode(existing, fresh)
+ return nil
+ }
+ root.Content[i+1] = fresh
+ return nil
+ }
+ root.Content = append(root.Content,
+ &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, fresh)
+ return nil
+}
+
+// mergeMapNode copies src's entries into dst: a key dst already has is updated
+// in place (keeping the operator's comments on it), a key it lacks is appended
+// with the node from src.
+func mergeMapNode(dst, src *yaml.Node) {
+ for i := 0; i+1 < len(src.Content); i += 2 {
+ key, value := src.Content[i], src.Content[i+1]
+ merged := false
+ for j := 0; j+1 < len(dst.Content); j += 2 {
+ if dst.Content[j].Value != key.Value {
+ continue
+ }
+ if dst.Content[j+1].Kind == yaml.ScalarNode && value.Kind == yaml.ScalarNode {
+ dst.Content[j+1].Value = value.Value
+ dst.Content[j+1].Tag = value.Tag
+ } else {
+ dst.Content[j+1] = value
+ }
+ merged = true
+ break
+ }
+ if !merged {
+ dst.Content = append(dst.Content, key, value)
+ }
+ }
+}
+
+func colorMapNode(m map[string]string) (*yaml.Node, error) {
+ b, err := yaml.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ var n yaml.Node
+ if err := yaml.Unmarshal(b, &n); err != nil {
+ return nil, err
+ }
+ if len(n.Content) == 0 {
+ return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, nil
+ }
+ return n.Content[0], nil
+}