aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_helpers.go
blob: d5e23c3346d89c7eb99abd2fd9126148f8381bf1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
package admin

import (
	"bytes"
	"fmt"
	"path/filepath"
	"strings"

	"gopkg.in/yaml.v3"

	"thehouseoficarus/internal/persistence"
	"thehouseoficarus/internal/world"
)

// validEntityID reports whether an entity ID can be safely joined into a
// file path under its data directory. IDs come from URL path segments and
// JSON bodies, so an unchecked join could otherwise escape the data
// directory ("../") or touch dot files.
func validEntityID(id string) bool {
	if id == "" || id == "." || id == ".." || strings.HasPrefix(id, ".") {
		return false
	}
	if strings.ContainsAny(id, `/\`) || strings.ContainsRune(id, 0) {
		return false
	}
	for _, r := range id {
		if r < 32 || r == 127 {
			return false
		}
	}
	return true
}

// resolveRoomSubdir validates a client-supplied room subdirectory and returns
// the cleaned path relative to <dataDir>/rooms. Absolute paths, ".." escapes,
// drive-like prefixes, and backslashes are rejected; the empty string is the
// rooms root. Room-directory API handlers must route body.Dir/patch.Dir through
// this before joining, mirroring validEntityID/resolveDataPath elsewhere.
func (s *AdminServer) resolveRoomSubdir(dir string) (string, error) {
	dir = strings.TrimSpace(dir)
	if dir == "" {
		return "", nil
	}
	if filepath.IsAbs(dir) || strings.ContainsRune(dir, '\\') || strings.ContainsRune(dir, 0) {
		return "", fmt.Errorf("invalid directory")
	}
	clean := filepath.Clean(dir)
	if clean == "." {
		return "", nil
	}
	if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("directory escapes the rooms tree")
	}
	base := filepath.Join(s.dataDir, "rooms")
	rel, err := filepath.Rel(base, filepath.Join(base, clean))
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("directory escapes the rooms tree")
	}
	return clean, nil
}

// validateRoomSchema decodes a rendered room YAML into world.Room before it is
// written, so the editor cannot persist a room the runtime loader would later
// reject (for example a non-numeric exit target). KnownFields(true) also
// catches keys the runtime would silently ignore.
func validateRoomSchema(content []byte) error {
	dec := yaml.NewDecoder(bytes.NewReader(content))
	dec.KnownFields(true)
	var room world.Room
	return dec.Decode(&room)
}

func yamlToMap(data []byte) (map[string]any, error) {
	var v any
	if err := yaml.Unmarshal(data, &v); err != nil {
		return nil, err
	}
	m, ok := stringifyMap(v).(map[string]any)
	if !ok {
		return nil, fmt.Errorf("yaml root is not a map")
	}
	return m, nil
}

func stringifyMap(v any) any {
	switch val := v.(type) {
	case map[interface{}]interface{}:
		out := make(map[string]any, len(val))
		for k, v := range val {
			out[fmt.Sprint(k)] = stringifyMap(v)
		}
		return out
	case map[string]interface{}:
		out := make(map[string]any, len(val))
		for k, v := range val {
			out[k] = stringifyMap(v)
		}
		return out
	case []interface{}:
		out := make([]any, len(val))
		for i, e := range val {
			out[i] = stringifyMap(e)
		}
		return out
	default:
		return v
	}
}

func writeMapAsYAML(path string, m map[string]any) ([]byte, error) {
	content, err := marshalMapYAML(m)
	if err != nil {
		return nil, err
	}
	if err := persistence.WriteFile(path, content, 0644); err != nil {
		return nil, err
	}
	return content, nil
}

// marshalMapYAML serializes a generic map without writing: callers that
// only need the after-image (mutation-plan preflight) use this.
func marshalMapYAML(m map[string]any) ([]byte, error) {
	var buf bytes.Buffer
	enc := yaml.NewEncoder(&buf)
	enc.SetIndent(2)
	if err := enc.Encode(m); err != nil {
		return nil, err
	}
	if err := enc.Close(); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}