package admin import ( "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" "thehouseoficarus/internal/persistence" ) type ExtraFile struct { FilePath string `json:"file_path"` OldContent []byte `json:"-"` NewContent []byte `json:"-"` 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 { Time string `json:"time"` Description string `json:"description"` FilePath string `json:"file_path"` NewFilePath string `json:"new_file_path,omitempty"` OldContent []byte `json:"old_content"` 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 // wired runtime migration so players, Hub membership, and casino room // keys move back and forth with the file images. IDMap map[int]int `json:"id_map,omitempty"` } func (c ChangeDesc) ShortDesc() string { switch { case c.IsCreate: return fmt.Sprintf("Created %s", filepath.Base(c.FilePath)) case c.IsDelete: return fmt.Sprintf("Deleted %s", filepath.Base(c.FilePath)) case c.NewFilePath != "": if filepath.Dir(c.FilePath) == filepath.Dir(c.NewFilePath) { return fmt.Sprintf("Renamed %s", filepath.Base(c.NewFilePath)) } return fmt.Sprintf("Moved %s", filepath.Base(c.NewFilePath)) default: return fmt.Sprintf("Edited %s", filepath.Base(c.FilePath)) } } type UndoInfo struct { CanUndo bool `json:"can_undo"` CanRedo bool `json:"can_redo"` UndoDesc string `json:"undo_desc"` RedoDesc string `json:"redo_desc"` StackSize int `json:"stack_size"` RedoSize int `json:"redo_size"` } // UndoStack protects history with a mutex, not live game state. Production // callers must execute Push/Undo/Redo/Clear within the editor's owner task. // Undo/Redo and the subsequent rebuildAfterUndo are one task, not separate // dispatches; migrateRoomIDs is an owner-only callback. type UndoStack struct { mu sync.Mutex dataDir string history []ChangeDesc redo []ChangeDesc maxSize int filePath string // migrateRoomIDs applies a room-ID map to runtime state (players, Hub // membership, casino room keys) during undo/redo of room-ID changes. migrateRoomIDs func(map[int]int) // playerFileOnlineCheck reports whether the character/account addressed // by a data-relative player file path is currently online. undo/redo // writes those files directly, so it must refuse the same files the raw // file editor refuses. Optional; nil disables the refusal. playerFileOnlineCheck func(relPath string) bool } func NewUndoStack(dataDir string) *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 // of room-ID changes. Called by the server after construction. func (us *UndoStack) SetMigrateRoomIDs(fn func(map[int]int)) { us.mu.Lock() defer us.mu.Unlock() us.migrateRoomIDs = fn } // SetPlayerFileOnlineCheck wires the online-session check used by undo/redo to // refuse touching a character/account file the game still owns. It takes the // same data-relative path form as AdminServer.SetPlayerFileOnlineCheck, which // passes its own hook through here. func (us *UndoStack) SetPlayerFileOnlineCheck(fn func(relPath string) bool) { us.mu.Lock() defer us.mu.Unlock() us.playerFileOnlineCheck = fn } func (us *UndoStack) Push(desc ChangeDesc) { us.mu.Lock() defer us.mu.Unlock() desc.Time = time.Now().Format(time.RFC3339) us.history = append(us.history, desc) us.redo = nil if len(us.history) > us.maxSize { us.history = us.history[len(us.history)-us.maxSize:] } us.save() log.Printf("undo: push %s", desc.ShortDesc()) } func (us *UndoStack) Undo() *ChangeDesc { us.mu.Lock() defer us.mu.Unlock() if len(us.history) == 0 { return nil } last := us.history[len(us.history)-1] if err := us.applyUndo(last); err != nil { log.Printf("undo: failed %s: %v", last.ShortDesc(), err) return nil } // Runtime migration moves with the file images: undo applies the // inverse room-ID map so players, Hub membership, and casino room keys // return to the old location together. if len(last.IDMap) > 0 && us.migrateRoomIDs != nil { inverse := make(map[int]int, len(last.IDMap)) for from, to := range last.IDMap { inverse[to] = from } us.migrateRoomIDs(inverse) } us.history = us.history[:len(us.history)-1] us.redo = append(us.redo, last) us.save() log.Printf("undo: undid %s", last.ShortDesc()) return &last } func (us *UndoStack) Redo() *ChangeDesc { us.mu.Lock() defer us.mu.Unlock() if len(us.redo) == 0 { return nil } last := us.redo[len(us.redo)-1] if err := us.applyRedo(last); err != nil { log.Printf("redo: failed %s: %v", last.ShortDesc(), err) return nil } // Redo applies the forward room-ID map through the runtime migration. if len(last.IDMap) > 0 && us.migrateRoomIDs != nil { forward := make(map[int]int, len(last.IDMap)) for from, to := range last.IDMap { forward[from] = to } us.migrateRoomIDs(forward) } us.redo = us.redo[:len(us.redo)-1] us.history = append(us.history, last) us.save() log.Printf("redo: redid %s", last.ShortDesc()) return &last } // applyUndo reverts a change: the primary file, then every extra file. // Extra-file failures propagate: a multi-file undo that restored only part // of a change must be reported as failed, never as success. func (us *UndoStack) applyUndo(c ChangeDesc) error { if err := us.checkPlayerFilesOffline(c); err != nil { return err } // Resolve every secret-bearing image BEFORE touching the filesystem: the // rename path below removes its target before the restored file is // written, so a redacted history image that cannot be repaired must fail // while both files are still intact. restored, err := us.restoreSecret(c.OldContent, c.FilePath, c.NewFilePath) if err != nil { return err } for _, ef := range c.ExtraFiles { if _, err := us.restoreSecret(ef.OldContent, ef.FilePath, ef.NewFilePath); err != nil { return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) } } if c.NewFilePath != "" { if err := os.Remove(c.NewFilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", c.NewFilePath, err) } } if c.IsDelete { 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 := us.writeSnapshot(c.FilePath, restored); err != nil { return fmt.Errorf("restore deleted %s: %w", c.FilePath, err) } } } else if c.IsCreate { if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove created %s: %w", c.FilePath, err) } } else { if err := us.writeSnapshot(c.FilePath, restored); err != nil { return fmt.Errorf("revert %s: %w", c.FilePath, err) } } for _, ef := range c.ExtraFiles { if err := us.applyUndoExtraFile(ef); err != nil { return err } } return nil } // applyRedo re-applies a change: the primary file, then every extra file. // Extra-file failures propagate like undo. func (us *UndoStack) applyRedo(c ChangeDesc) error { if err := us.checkPlayerFilesOffline(c); err != nil { return err } // See applyUndo: every image's secret is resolved before any file is // removed, so a redacted image fails the redo without destroying either // end of a rename. restored, err := us.restoreSecret(c.NewContent, c.NewFilePath, c.FilePath) if err != nil { return err } for _, ef := range c.ExtraFiles { if _, err := us.restoreSecret(ef.NewContent, ef.NewFilePath, ef.FilePath); err != nil { return fmt.Errorf("redo restore %s: %w", ef.FilePath, err) } } if c.NewFilePath != "" { if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", c.FilePath, err) } } if c.IsCreate { target := c.FilePath if c.NewFilePath != "" { target = c.NewFilePath } if err := us.writeSnapshot(target, restored); err != nil { return fmt.Errorf("recreate %s: %w", target, err) } for _, ef := range c.ExtraFiles { if err := us.applyRedoExtraFile(ef); err != nil { return err } } } else if c.IsDelete { if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("re-delete %s: %w", c.FilePath, err) } for _, ef := range c.ExtraFiles { if err := us.applyRedoExtraFile(ef); err != nil { return err } } } else { target := c.FilePath if c.NewFilePath != "" { target = c.NewFilePath } if err := us.writeSnapshot(target, restored); err != nil { return fmt.Errorf("reapply %s: %w", target, err) } for _, ef := range c.ExtraFiles { if err := us.applyRedoExtraFile(ef); err != nil { return err } } } return nil } func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error { switch { case ef.IsCreate: 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 := us.writeRestored(ef.FilePath, ef.OldContent, ef.NewFilePath); err != nil { return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) } case ef.NewFilePath != "": if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err) } if err := us.writeRestored(ef.FilePath, ef.OldContent, ef.NewFilePath); err != nil { return fmt.Errorf("undo restore %s: %w", ef.FilePath, err) } default: if err := us.writeRestored(ef.FilePath, ef.OldContent); err != nil { return fmt.Errorf("undo revert %s: %w", ef.FilePath, err) } } return nil } func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error { switch { case ef.IsDelete: if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("redo remove %s: %w", ef.FilePath, err) } case ef.IsCreate: if err := us.writeRestored(ef.FilePath, ef.NewContent, ef.NewFilePath); err != nil { return fmt.Errorf("redo create %s: %w", ef.FilePath, err) } case ef.NewFilePath != "": if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("redo remove %s: %w", ef.FilePath, err) } if err := us.writeRestored(ef.NewFilePath, ef.NewContent, ef.FilePath); err != nil { return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err) } default: if err := us.writeRestored(ef.FilePath, ef.NewContent); err != nil { return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err) } } return nil } // writeRestored writes content to path after merging the live on-disk player // secret back into it (see restoreSecret). A history image whose secret was // redacted and has no live file left to take it from fails here instead of // being written, so undo/redo reports the failure. Further paths are the // change's counterparts (a rename's other end). func (us *UndoStack) writeRestored(path string, content []byte, others ...string) error { restored, err := us.restoreSecret(content, append([]string{path}, others...)...) if err != nil { return err } return us.writeSnapshot(path, restored) } // checkPlayerFilesOffline refuses an undo/redo touching a character/account // file whose owner is online. The raw file editor refuses those files before // writing them; undo/redo writes them through this stack, so it must refuse // them too or a live character can be clobbered behind the editor's back. func (us *UndoStack) checkPlayerFilesOffline(c ChangeDesc) error { if us.playerFileOnlineCheck == nil { return nil } for _, p := range changePaths(c) { rel, ok := us.playerDataRelPath(p) if !ok { continue } if us.playerFileOnlineCheck(rel) { return fmt.Errorf("%s is online; it must be logged out before this change can be undone or redone", rel) } } return nil } // changePaths lists every file a change touches: the primary path, a rename's // new path, and every extra file with its own new path. func changePaths(c ChangeDesc) []string { paths := []string{c.FilePath, c.NewFilePath} for _, ef := range c.ExtraFiles { paths = append(paths, ef.FilePath, ef.NewFilePath) } return paths } // writeSnapshot writes content to path, creating the parent directory and // choosing the file mode from the path: player/account files are owner-only. func (us *UndoStack) writeSnapshot(path string, content []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } if err := os.WriteFile(path, content, us.fileMode(path)); err != nil { return err } if us.isPlayerDataPath(path) { // Tighten a file that was created world-readable before this fix. if err := os.Chmod(path, 0600); err != nil { return err } } return nil } // fileMode returns the mode an undo/redo write should use for path. func (us *UndoStack) fileMode(path string) os.FileMode { if us.isPlayerDataPath(path) { return 0600 } return 0644 } // isPlayerDataPath reports whether path is an account or character file under // the data directory. These carry the password hash and live player state. func (us *UndoStack) isPlayerDataPath(path string) bool { _, ok := us.playerDataRelPath(path) return ok } // playerDataRelPath returns the data-relative, slash-separated form of path // when it is an account or character file, which is the form the game's // online-session check (AdminPlayerFileOnline) accepts. func (us *UndoStack) playerDataRelPath(path string) (string, bool) { if path == "" { return "", false } rel, err := filepath.Rel(us.dataDir, path) if err != nil { return "", false } rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "players/accounts/") || strings.HasPrefix(rel, "players/characters/") { return rel, true } return "", false } func (us *UndoStack) Info() UndoInfo { us.mu.Lock() defer us.mu.Unlock() info := UndoInfo{ StackSize: len(us.history), RedoSize: len(us.redo), } if len(us.history) > 0 { info.CanUndo = true info.UndoDesc = us.history[len(us.history)-1].ShortDesc() } if len(us.redo) > 0 { info.CanRedo = true info.RedoDesc = us.redo[len(us.redo)-1].ShortDesc() } return info } func (us *UndoStack) save() { 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"` } toEntries := func(changes []ChangeDesc) []entry { var entries []entry for _, c := range changes { var extra []extraEntry for _, ef := range c.ExtraFiles { extra = append(extra, extraEntry{ FilePath: ef.FilePath, NewFilePath: ef.NewFilePath, OldContent: string(us.redactContent(ef.OldContent, ef.FilePath, ef.NewFilePath)), NewContent: string(us.redactContent(ef.NewContent, ef.FilePath, ef.NewFilePath)), IsDelete: ef.IsDelete, IsCreate: ef.IsCreate, IsDirDelete: ef.IsDirDelete, }) } entries = append(entries, entry{ Time: c.Time, Description: c.Description, FilePath: c.FilePath, NewFilePath: c.NewFilePath, OldContent: string(us.redactContent(c.OldContent, c.FilePath, c.NewFilePath)), NewContent: string(us.redactContent(c.NewContent, c.FilePath, c.NewFilePath)), IsDelete: c.IsDelete, IsCreate: c.IsCreate, IsDirDelete: c.IsDirDelete, ExtraFiles: extra, IDMap: c.IDMap, }) } return entries } data := saveData{ History: toEntries(us.history), Redo: toEntries(us.redo), } b, err := json.Marshal(data) if err != nil { log.Printf("undo: failed to marshal history: %v", err) return } if err := persistence.WriteFile(us.filePath, b, 0600); err != nil { log.Printf("undo: failed to write history file: %v", err) return } // persistence.WriteFile preserves an existing file's mode: tighten a // history file that a previous version created world-readable. if err := os.Chmod(us.filePath, 0600); err != nil { log.Printf("undo: failed to secure history file: %v", err) } } // playerSecretKeys are the top-level YAML keys whose values must never be // written to the on-disk undo history. Account password hashes are the // primary case: the raw file editor exposes account files and an unredacted // history file would leak every hash to any local reader (and be stageable // into the repository). var playerSecretKeys = []string{"password_hash"} // redactContent replaces secret values with a placeholder before the content // is serialized into the history file. The in-memory ChangeDesc keeps the // real bytes; only the persisted copy is redacted. A redacted value cannot // restore a deleted account's hash across a restart, which is an accepted // trade for never storing credentials in the history. func (us *UndoStack) redactContent(content []byte, paths ...string) []byte { if content == nil { return nil } for _, p := range paths { if us.isPlayerDataPath(p) { return redactPlayerSecrets(content) } } return content } // redactPlayerSecrets replaces every known secret value in a player/account // document with the editor's placeholder, returning content unchanged when // the document does not parse or carries no secret. func redactPlayerSecrets(content []byte) []byte { m, err := yamlToMap(content) if err != nil { return content } changed := false for _, key := range playerSecretKeys { if _, ok := m[key]; ok { m[key] = redactedAccountHash changed = true } } if !changed { return content } out, err := marshalMapYAML(m) if err != nil { return content } return out } // restoreSecret merges the live on-disk password hash into content before an // undo/redo overwrites an account file, so a hash that was redacted in the // persisted history can never blank a live account after a restart. It looks // at the primary target path and then the change's other path (a rename). // // When no path holds a live hash and content still carries the redaction // placeholder, it returns an error: writing the placeholder would replace the // account's real hash and lock both the owner and the raw editor out of it // permanently, so undo/redo must report the failure instead of overwriting. func (us *UndoStack) restoreSecret(content []byte, paths ...string) ([]byte, error) { if content == nil { return nil, nil } playerFile := false for _, p := range paths { if us.isPlayerDataPath(p) { playerFile = true break } } if !playerFile { return content, nil } cm, err := yamlToMap(content) if err != nil { return content, nil } hash, ok := cm["password_hash"] if !ok { return content, nil } for _, p := range paths { if !us.isPlayerDataPath(p) { continue } existing, err := os.ReadFile(p) if err != nil { continue } em, err := yamlToMap(existing) if err != nil { continue } live, ok := em["password_hash"] if !ok { continue } cm["password_hash"] = live out, err := marshalMapYAML(cm) if err != nil { return content, nil } return out, nil } if fmt.Sprint(hash) == redactedAccountHash { return nil, fmt.Errorf("the stored history image has a redacted password hash and no live account file to take it from") } return content, nil } // 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() us.history = nil us.redo = nil os.Remove(us.filePath) log.Printf("undo: cleared history") }