aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_files.go79
-rw-r--r--internal/admin/api_helpers.go46
-rw-r--r--internal/admin/api_rooms.go36
-rw-r--r--internal/admin/api_rooms_bulk.go11
-rw-r--r--internal/admin/api_rooms_bulk_delete.go12
-rw-r--r--internal/admin/fixes_regression_test.go49
-rw-r--r--internal/admin/server.go29
-rw-r--r--internal/admin/undo.go95
-rw-r--r--internal/admin/yaml_util.go5
9 files changed, 335 insertions, 27 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
+}
diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go
index 11ad958..37cc4ae 100644
--- a/internal/admin/api_helpers.go
+++ b/internal/admin/api_helpers.go
@@ -3,10 +3,13 @@ package admin
import (
"bytes"
"fmt"
- "os"
+ "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
@@ -23,6 +26,45 @@ func validEntityID(id string) bool {
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 {
@@ -65,7 +107,7 @@ func writeMapAsYAML(path string, m map[string]any) ([]byte, error) {
if err != nil {
return nil, err
}
- if err := os.WriteFile(path, content, 0644); err != nil {
+ if err := persistence.WriteFile(path, content, 0644); err != nil {
return nil, err
}
return content, nil
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index f9508f6..d84449b 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -298,9 +298,17 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
return
}
- newContent, writeErr := writeMapAsYAML(path, m)
- if writeErr != nil {
- failJSON(w, fmt.Sprintf("write room: %v", writeErr))
+ newContent, marshalErr := marshalMapYAML(m)
+ if marshalErr != nil {
+ failJSON(w, fmt.Sprintf("marshal room: %v", marshalErr))
+ return
+ }
+ if err := validateRoomSchema(newContent); err != nil {
+ writeJSONError(w, "room failed schema validation: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := writeFilePreservingMode(path, newContent); err != nil {
+ failJSON(w, fmt.Sprintf("write room: %v", err))
return
}
@@ -455,14 +463,19 @@ func (s *AdminServer) handleRenameRoomDir(w http.ResponseWriter, r *http.Request
failJSON(w, "cannot rename root")
return
}
+ dir, err := s.resolveRoomSubdir(body.Dir)
+ if err != nil || dir == "" {
+ failJSON(w, "invalid directory")
+ return
+ }
newName := strings.TrimSpace(body.NewName)
if newName == "" || strings.ContainsAny(newName, "/\\") || newName == "." || newName == ".." {
failJSON(w, "invalid directory name")
return
}
base := filepath.Join(s.dataDir, "rooms")
- parent := filepath.Dir(body.Dir)
- oldPath := filepath.Join(base, body.Dir)
+ parent := filepath.Dir(dir)
+ oldPath := filepath.Join(base, dir)
newRel := filepath.Join(parent, newName)
newPath := filepath.Join(base, newRel)
if _, err := os.Stat(oldPath); os.IsNotExist(err) {
@@ -825,7 +838,11 @@ func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) {
failJSON(w, "room not found")
return
}
- dir := strings.TrimSpace(body.Dir)
+ dir, err := s.resolveRoomSubdir(body.Dir)
+ if err != nil {
+ failJSON(w, err.Error())
+ return
+ }
destDir := filepath.Join(s.dataDir, "rooms", dir)
if _, err := os.Stat(destDir); os.IsNotExist(err) {
failJSON(w, "directory does not exist: "+dir)
@@ -1279,8 +1296,11 @@ func (s *AdminServer) handleCreateEmptyRoom(w http.ResponseWriter, r *http.Reque
}
base := filepath.Join(s.dataDir, "rooms")
targetDir := base
- if body.Dir != "" {
- targetDir = filepath.Join(base, body.Dir)
+ if dir, err := s.resolveRoomSubdir(body.Dir); err != nil {
+ failJSON(w, err.Error())
+ return
+ } else if dir != "" {
+ targetDir = filepath.Join(base, dir)
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
failJSON(w, "directory not found")
return
diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go
index d10e0e3..b9cbb4a 100644
--- a/internal/admin/api_rooms_bulk.go
+++ b/internal/admin/api_rooms_bulk.go
@@ -108,7 +108,12 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) {
newPath := path
if patch.Dir != nil && *patch.Dir != "" {
- destDir := filepath.Join(s.dataDir, "rooms", *patch.Dir)
+ dir, dirErr := s.resolveRoomSubdir(*patch.Dir)
+ if dirErr != nil {
+ failJSON(w, dirErr.Error())
+ return
+ }
+ destDir := filepath.Join(s.dataDir, "rooms", dir)
if _, statErr := os.Stat(destDir); os.IsNotExist(statErr) {
failJSON(w, fmt.Sprintf("directory does not exist: %s", *patch.Dir))
return
@@ -130,6 +135,10 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) {
failJSON(w, fmt.Sprintf("marshal room %d: %v", id, marshalErr))
return
}
+ if err := validateRoomSchema(newContent); err != nil {
+ failJSON(w, fmt.Sprintf("room %d failed schema validation: %v", id, err))
+ return
+ }
writes = append(writes, roomWrite{id: id, oldPath: path, newPath: newPath, oldContent: oldContent, newContent: newContent})
}
diff --git a/internal/admin/api_rooms_bulk_delete.go b/internal/admin/api_rooms_bulk_delete.go
index 5b525f6..6017eed 100644
--- a/internal/admin/api_rooms_bulk_delete.go
+++ b/internal/admin/api_rooms_bulk_delete.go
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
+ "thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/world"
)
@@ -74,13 +75,14 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
deletedSet := make(map[int]bool, len(req.IDs))
for _, snap := range snapshots {
- s.detachRoomFromCourse(snap.id)
if err := os.Remove(snap.path); err != nil {
// All-or-nothing: restore every room deleted so far so a failed
- // bulk delete never strands rooms without an undo record.
+ // bulk delete never strands rooms without an undo record. Course
+ // detaches are deferred until every file is gone, so a failed
+ // delete cannot leave course references already rewritten.
for _, done := range snapshots {
if deletedSet[done.id] {
- if werr := os.WriteFile(done.path, done.oldContent, 0644); werr != nil {
+ if werr := persistence.WriteFile(done.path, done.oldContent, 0644); werr != nil {
log.Printf("bulk-delete rollback of room %d: %v", done.id, werr)
}
}
@@ -90,6 +92,10 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
}
deletedSet[snap.id] = true
}
+ // Room files are gone; now detach the deleted rooms from any courses.
+ for _, snap := range snapshots {
+ s.detachRoomFromCourse(snap.id)
+ }
s.world.RebuildRoomIndex(s.dataDir)
for _, snap := range snapshots {
diff --git a/internal/admin/fixes_regression_test.go b/internal/admin/fixes_regression_test.go
new file mode 100644
index 0000000..3d7d19f
--- /dev/null
+++ b/internal/admin/fixes_regression_test.go
@@ -0,0 +1,49 @@
+package admin
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// TestResolveRoomSubdirRejectsEscape covers the traversal guard used by the
+// room-directory API handlers (rename dir, move room, create empty room, bulk
+// edit). Every escaping form must be rejected.
+func TestResolveRoomSubdirRejectsEscape(t *testing.T) {
+ s := &AdminServer{dataDir: t.TempDir()}
+ for _, bad := range []string{"..", "../items", "../../etc", "/etc", "a/../../b", `..\items`, "town/../../x"} {
+ if _, err := s.resolveRoomSubdir(bad); err == nil {
+ t.Fatalf("accepted escaping dir %q", bad)
+ }
+ }
+ for _, ok := range []string{"", ".", "town", "town/sub"} {
+ got, err := s.resolveRoomSubdir(ok)
+ if err != nil {
+ t.Fatalf("rejected valid dir %q: %v", ok, err)
+ }
+ if ok != "" && ok != "." && got != filepath.FromSlash(ok) {
+ t.Fatalf("dir %q resolved to %q", ok, got)
+ }
+ }
+}
+
+// TestUndoHistorySurvivesReload: the editor documents that undo history is
+// persisted across sessions; NewUndoStack must load what save wrote.
+func TestUndoHistorySurvivesReload(t *testing.T) {
+ dir := t.TempDir()
+ us := NewUndoStack(dir)
+ us.Push(ChangeDesc{
+ Description: "edit x",
+ FilePath: filepath.Join(dir, "x.yaml"),
+ OldContent: []byte("old"),
+ NewContent: []byte("new"),
+ })
+
+ reloaded := NewUndoStack(dir)
+ info := reloaded.Info()
+ if !info.CanUndo || info.StackSize != 1 {
+ t.Fatalf("history did not survive reload: %+v", info)
+ }
+ if info.UndoDesc == "" {
+ t.Fatal("undo description empty after reload")
+ }
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 12d090d..d9a0386 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -324,19 +324,27 @@ func (s *AdminServer) checkAuth(r *http.Request) bool {
if err != nil {
return false
}
- parts := strings.SplitN(cookie.Value, ":", 2)
- if len(parts) != 2 {
+ parts := strings.SplitN(cookie.Value, ":", 3)
+ if len(parts) != 3 {
return false
}
account := parts[0]
- sig := parts[1]
+ issued, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil {
+ return false
+ }
+ issuedAt := time.Unix(issued, 0)
+ if time.Since(issuedAt) > adminSessionTTL || time.Until(issuedAt) > time.Minute {
+ return false
+ }
+ sig := parts[2]
if !s.isAdminAccount(account) {
return false
}
mac := hmac.New(sha256.New, s.cookieSecret)
- mac.Write([]byte(account))
+ mac.Write([]byte(account + ":" + parts[1]))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
@@ -349,18 +357,24 @@ func (s *AdminServer) isAdminAccount(name string) bool {
return acc.Admin
}
+// adminSessionTTL bounds how long a signed admin cookie is accepted. The
+// signature covers the issue time, so a leaked cookie cannot authenticate
+// indefinitely and the server can age it out without server-side state.
+const adminSessionTTL = 24 * time.Hour
+
func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) {
+ issued := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, s.cookieSecret)
- mac.Write([]byte(account))
+ mac.Write([]byte(account + ":" + issued))
sig := hex.EncodeToString(mac.Sum(nil))
http.SetCookie(w, &http.Cookie{
Name: "admin_session",
- Value: account + ":" + sig,
+ Value: account + ":" + issued + ":" + sig,
Path: "/",
HttpOnly: true,
Secure: s.useTLS,
SameSite: http.SameSiteStrictMode,
- MaxAge: 86400,
+ MaxAge: int(adminSessionTTL / time.Second),
})
}
@@ -409,7 +423,6 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
log.Printf("admin login: password rehash save for %s: %v", account, err)
}
}
- s.undoStack.Clear()
}) {
writeJSONError(w, "game is shutting down", http.StatusServiceUnavailable)
return
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 51610f8..1786295 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -17,6 +17,7 @@ type ExtraFile struct {
NewFilePath string `json:"new_file_path,omitempty"`
IsDelete bool `json:"is_delete,omitempty"`
IsCreate bool `json:"is_create,omitempty"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
}
type ChangeDesc struct {
@@ -28,6 +29,7 @@ type ChangeDesc struct {
NewContent []byte `json:"new_content"`
IsDelete bool `json:"is_delete"`
IsCreate bool `json:"is_create"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
ExtraFiles []ExtraFile `json:"extra_files,omitempty"`
// IDMap records a room-ID change (oldID -> newID) included in the
// change. Undo applies its inverse, redo the forward map, through the
@@ -78,11 +80,13 @@ type UndoStack struct {
}
func NewUndoStack(dataDir string) *UndoStack {
- return &UndoStack{
+ us := &UndoStack{
dataDir: dataDir,
maxSize: 100,
filePath: filepath.Join(dataDir, ".admin_history.json"),
}
+ us.load()
+ return us
}
// SetMigrateRoomIDs wires the runtime room-ID migration used by undo/redo
@@ -174,11 +178,14 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
}
}
if c.IsDelete {
- if c.OldContent == nil {
+ if c.IsDirDelete {
if err := os.MkdirAll(c.FilePath, 0755); err != nil {
return fmt.Errorf("recreate dir %s: %w", c.FilePath, err)
}
} else {
+ if c.OldContent == nil {
+ return fmt.Errorf("restore deleted %s: no snapshot recorded", c.FilePath)
+ }
if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
@@ -266,7 +273,14 @@ func applyUndoExtraFile(ef ExtraFile) error {
if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("undo remove %s: %w", ef.FilePath, err)
}
+ case ef.IsDelete && ef.IsDirDelete:
+ if err := os.MkdirAll(ef.FilePath, 0755); err != nil {
+ return fmt.Errorf("undo mkdir: %w", err)
+ }
case ef.IsDelete:
+ if ef.OldContent == nil {
+ return fmt.Errorf("undo restore %s: no snapshot recorded", ef.FilePath)
+ }
if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
return fmt.Errorf("undo mkdir: %w", err)
}
@@ -354,6 +368,7 @@ func (us *UndoStack) save() {
NewContent string `json:"new_content"`
IsDelete bool `json:"is_delete,omitempty"`
IsCreate bool `json:"is_create,omitempty"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
}
type entry struct {
Time string `json:"time"`
@@ -364,6 +379,7 @@ func (us *UndoStack) save() {
NewContent string `json:"new_content"`
IsDelete bool `json:"is_delete"`
IsCreate bool `json:"is_create"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
ExtraFiles []extraEntry `json:"extra_files,omitempty"`
IDMap map[int]int `json:"id_map,omitempty"`
}
@@ -383,6 +399,7 @@ func (us *UndoStack) save() {
NewContent: string(ef.NewContent),
IsDelete: ef.IsDelete,
IsCreate: ef.IsCreate,
+ IsDirDelete: ef.IsDirDelete,
})
}
entries = append(entries, entry{
@@ -394,6 +411,7 @@ func (us *UndoStack) save() {
NewContent: string(c.NewContent),
IsDelete: c.IsDelete,
IsCreate: c.IsCreate,
+ IsDirDelete: c.IsDirDelete,
ExtraFiles: extra,
IDMap: c.IDMap,
})
@@ -414,6 +432,79 @@ func (us *UndoStack) save() {
}
}
+// load restores persisted undo/redo history written by save. A missing or
+// unreadable history file is not fatal: the stack simply starts empty. The
+// editor documents that history survives restarts, so NewUndoStack calls this.
+func (us *UndoStack) load() {
+ data, err := os.ReadFile(us.filePath)
+ if err != nil {
+ return
+ }
+ type extraEntry struct {
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete,omitempty"`
+ IsCreate bool `json:"is_create,omitempty"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
+ }
+ type entry struct {
+ Time string `json:"time"`
+ Description string `json:"description"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete"`
+ IsCreate bool `json:"is_create"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
+ ExtraFiles []extraEntry `json:"extra_files,omitempty"`
+ IDMap map[int]int `json:"id_map,omitempty"`
+ }
+ type saveData struct {
+ History []entry `json:"history"`
+ Redo []entry `json:"redo"`
+ }
+ var sd saveData
+ if err := json.Unmarshal(data, &sd); err != nil {
+ log.Printf("undo: failed to load history: %v", err)
+ return
+ }
+ toChanges := func(entries []entry) []ChangeDesc {
+ out := make([]ChangeDesc, 0, len(entries))
+ for _, e := range entries {
+ c := ChangeDesc{
+ Time: e.Time,
+ Description: e.Description,
+ FilePath: e.FilePath,
+ NewFilePath: e.NewFilePath,
+ OldContent: []byte(e.OldContent),
+ NewContent: []byte(e.NewContent),
+ IsDelete: e.IsDelete,
+ IsCreate: e.IsCreate,
+ IsDirDelete: e.IsDirDelete,
+ IDMap: e.IDMap,
+ }
+ for _, ef := range e.ExtraFiles {
+ c.ExtraFiles = append(c.ExtraFiles, ExtraFile{
+ FilePath: ef.FilePath,
+ NewFilePath: ef.NewFilePath,
+ OldContent: []byte(ef.OldContent),
+ NewContent: []byte(ef.NewContent),
+ IsDelete: ef.IsDelete,
+ IsCreate: ef.IsCreate,
+ IsDirDelete: ef.IsDirDelete,
+ })
+ }
+ out = append(out, c)
+ }
+ return out
+ }
+ us.history = toChanges(sd.History)
+ us.redo = toChanges(sd.Redo)
+}
+
func (us *UndoStack) Clear() {
us.mu.Lock()
defer us.mu.Unlock()
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
index c042248..44712a3 100644
--- a/internal/admin/yaml_util.go
+++ b/internal/admin/yaml_util.go
@@ -9,6 +9,8 @@ import (
"strings"
"gopkg.in/yaml.v3"
+
+ "thehouseoficarus/internal/persistence"
)
func readYAMLFile(dataDir, subdir, id string) ([]byte, string, error) {
@@ -35,7 +37,7 @@ func writeYAMLFile(path string, data any) ([]byte, error) {
if err != nil {
return nil, err
}
- if err := os.WriteFile(path, content, 0644); err != nil {
+ if err := persistence.WriteFile(path, content, 0644); err != nil {
return nil, err
}
return content, nil
@@ -338,6 +340,7 @@ func deleteEntityDir(dataDir, subdir, dir string) ([]ChangeDesc, error) {
Description: fmt.Sprintf("delete directory %s/%s", subdir, dir),
FilePath: srcDir,
IsDelete: true,
+ IsDirDelete: true,
})
return changes, nil