package admin import ( "crypto/hmac" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "embed" "encoding/hex" "encoding/json" "fmt" "html/template" "io" "io/fs" "log" "math/big" "net" "net/http" "os" "path/filepath" "strconv" "strings" "time" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/config" "thehouseoficarus/internal/item" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) //go:embed templates static var embedded embed.FS type AdminServer struct { cfg *config.Config useTLS bool accountStore *player.AccountStore world *world.World itemStore *item.ItemStore objectStore *object.ObjectStore mobStore *world.MobStore dataDir string httpServer *http.Server undoStack *UndoStack tmpl *template.Template cookieSecret []byte reloadCourses func() // rewriteRoomIDs migrates room-ID-embedded state across all characters // (online + offline) when a room ID changes. Wired from the Game so the // admin web GUI can trigger the same migration as the swapid command // without the admin package depending on game. rewriteRoomIDs func(idMap map[int]int) // configPath is the on-disk config.yaml path, used by the color themer // to persist edited default_colors back to the file the game loaded. configPath string } func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), configPath string) (*AdminServer, error) { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("cookie secret: %w", err) } tmpl, err := template.New("").Funcs(template.FuncMap{ "json": func(v any) string { b, _ := json.Marshal(v) return string(b) }, }).ParseFS(embedded, "templates/*.html") if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } var port int if useTLS { port = cfg.AdminHTTPS.Port } else { port = cfg.AdminHTTP.Port } var cert tls.Certificate if useTLS { cert, err = loadOrGenerateCert(cfg) if err != nil { return nil, fmt.Errorf("admin cert: %w", err) } } s := &AdminServer{ cfg: cfg, useTLS: useTLS, accountStore: accountStore, world: w, itemStore: is, objectStore: os, mobStore: ms, dataDir: dataDir, undoStack: NewUndoStack(dataDir), tmpl: tmpl, cookieSecret: secret, reloadCourses: reloadCourses, rewriteRoomIDs: rewriteRoomIDs, configPath: configPath, } mux := http.NewServeMux() staticFS, err := fs.Sub(embedded, "static") if err != nil { return nil, fmt.Errorf("static subfs: %w", err) } mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/logout", s.handleLogout) apiMux := http.NewServeMux() apiMux.HandleFunc("/api/map", s.handleMap) apiMux.HandleFunc("/api/room-dirs", s.handleRoomDirs) apiMux.HandleFunc("/api/room-dirs/rename", s.handleRenameRoomDir) apiMux.HandleFunc("/api/room-dirs/create", s.handleCreateRoomDir) apiMux.HandleFunc("/api/rooms/link", s.handleRoomLink) apiMux.HandleFunc("/api/rooms/insert", s.handleRoomInsert) apiMux.HandleFunc("/api/rooms/remove", s.handleRoomRemove) apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove) apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename) apiMux.HandleFunc("/api/rooms/swap", s.handleRoomSwap) apiMux.HandleFunc("/api/rooms/bulk-edit", s.handleBulkEdit) apiMux.HandleFunc("/api/rooms/bulk-delete", s.handleBulkDelete) apiMux.HandleFunc("/api/rooms", s.handleRooms) apiMux.HandleFunc("/api/rooms/new-empty", s.handleCreateEmptyRoom) apiMux.HandleFunc("/api/rooms/", s.handleRoomByID) apiMux.HandleFunc("/api/objects/rename", s.handleEntityRename("objects")) apiMux.HandleFunc("/api/objects/move", s.handleEntityMove("objects")) apiMux.HandleFunc("/api/objects/dirs", s.handleEntityDirs("objects")) apiMux.HandleFunc("/api/objects", s.handleObjects) apiMux.HandleFunc("/api/objects/", s.handleObjectByID) apiMux.HandleFunc("/api/items/rename", s.handleEntityRename("items")) apiMux.HandleFunc("/api/items/move", s.handleEntityMove("items")) apiMux.HandleFunc("/api/items/dirs", s.handleEntityDirs("items")) apiMux.HandleFunc("/api/items", s.handleItems) apiMux.HandleFunc("/api/items/", s.handleItemByID) apiMux.HandleFunc("/api/mobs/rename", s.handleEntityRename("mobs")) apiMux.HandleFunc("/api/mobs/move", s.handleEntityMove("mobs")) apiMux.HandleFunc("/api/mobs/dirs", s.handleEntityDirs("mobs")) apiMux.HandleFunc("/api/mobs", s.handleMobs) apiMux.HandleFunc("/api/mobs/", s.handleMobByID) apiMux.HandleFunc("/api/drops/rename", s.handleEntityRename("drops")) apiMux.HandleFunc("/api/drops/move", s.handleEntityMove("drops")) apiMux.HandleFunc("/api/drops/dirs", s.handleEntityDirs("drops")) apiMux.HandleFunc("/api/drops", s.handleDrops) apiMux.HandleFunc("/api/drops/", s.handleDropByID) apiMux.HandleFunc("/api/hazards/rename", s.handleEntityRename("hazards")) apiMux.HandleFunc("/api/hazards/move", s.handleEntityMove("hazards")) apiMux.HandleFunc("/api/hazards/dirs", s.handleEntityDirs("hazards")) apiMux.HandleFunc("/api/hazards", s.handleHazards) apiMux.HandleFunc("/api/hazards/", s.handleHazardByID) apiMux.HandleFunc("/api/techs/rename", s.handleEntityRename("techs")) apiMux.HandleFunc("/api/techs/move", s.handleEntityMove("techs")) apiMux.HandleFunc("/api/techs/dirs", s.handleEntityDirs("techs")) apiMux.HandleFunc("/api/techs", s.handleTechs) apiMux.HandleFunc("/api/techs/", s.handleTechByID) apiMux.HandleFunc("/api/modules/rename", s.handleEntityRename("modules")) apiMux.HandleFunc("/api/modules/move", s.handleEntityMove("modules")) apiMux.HandleFunc("/api/modules/dirs", s.handleEntityDirs("modules")) apiMux.HandleFunc("/api/modules", s.handleModules) apiMux.HandleFunc("/api/modules/", s.handleModuleByID) apiMux.HandleFunc("/api/courses", s.handleCourses) apiMux.HandleFunc("/api/courses/", s.handleCourseByID) apiMux.HandleFunc("/api/players", s.handlePlayers) apiMux.HandleFunc("/api/players/", s.handlePlayerByID) apiMux.HandleFunc("/api/dashboard", s.handleDashboard) apiMux.HandleFunc("/api/global_flags", s.handleGlobalFlags) apiMux.HandleFunc("/api/tools", s.handleTools) apiMux.HandleFunc("/api/stations", s.handleStations) apiMux.HandleFunc("/api/search", s.handleSearch) apiMux.HandleFunc("/api/undo/state", s.handleUndoState) apiMux.HandleFunc("/api/undo/undo", s.doUndo) apiMux.HandleFunc("/api/undo/redo", s.doRedo) apiMux.HandleFunc("/api/next-room-id", s.handleNextRoomID) apiMux.HandleFunc("/api/files", s.handleFiles) apiMux.HandleFunc("/api/files/rename", s.handleFileRename) apiMux.HandleFunc("/api/duplicate-rooms", s.handleDuplicateRooms) apiMux.HandleFunc("/api/rooms/resolve-duplicate", s.handleResolveDuplicate) apiMux.HandleFunc("/api/triggers", s.handleTriggers) apiMux.HandleFunc("/api/triggers/", s.handleTriggerByID) apiMux.HandleFunc("/api/colors", s.handleColors) apiMux.HandleFunc("/api/colors/reset", s.handleColorsReset) mux.Handle("/api/", s.authMiddleware(apiMux)) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Redirect(w, r, "/login", http.StatusFound) return } page := strings.TrimPrefix(r.URL.Path, "/") if page == "" { page = "map" } if page == "map" { dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": "duplicate-rooms", } s.renderPage(w, r, "layout", data) return } } data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": page, } s.renderPage(w, r, "layout", data) }) mux.HandleFunc("/editor/", func(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Redirect(w, r, "/login", http.StatusFound) return } page := strings.TrimPrefix(r.URL.Path, "/editor/") if page == "map" { dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": "duplicate-rooms", } s.renderPage(w, r, "layout", data) return } } data := map[string]any{ "Account": s.accountFromCookie(r), "UndoStack": s.undoStack.Info(), "Page": page, } s.renderPage(w, r, "layout", data) }) s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: mux, } if useTLS { s.httpServer.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, } } return s, nil } func (s *AdminServer) ListenAndServe() error { ln, err := net.Listen("tcp", s.httpServer.Addr) if err != nil { return fmt.Errorf("admin listen: %w", err) } if s.useTLS { tlsLn := tls.NewListener(ln, s.httpServer.TLSConfig) return s.httpServer.Serve(tlsLn) } return s.httpServer.Serve(ln) } func (s *AdminServer) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } func (s *AdminServer) checkAuth(r *http.Request) bool { cookie, err := r.Cookie("admin_session") if err != nil { return false } parts := strings.SplitN(cookie.Value, ":", 2) if len(parts) != 2 { return false } account := parts[0] sig := parts[1] if !s.isAdminAccount(account) { return false } mac := hmac.New(sha256.New, s.cookieSecret) mac.Write([]byte(account)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expected)) } func (s *AdminServer) isAdminAccount(name string) bool { acc, err := s.accountStore.LoadAccount(name) if err != nil { return false } return acc.Admin } func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) { mac := hmac.New(sha256.New, s.cookieSecret) mac.Write([]byte(account)) sig := hex.EncodeToString(mac.Sum(nil)) http.SetCookie(w, &http.Cookie{ Name: "admin_session", Value: account + ":" + sig, Path: "/", HttpOnly: true, Secure: s.useTLS, SameSite: http.SameSiteStrictMode, MaxAge: 86400, }) } func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { data := map[string]any{} s.renderPage(w, r, "login.html", data) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } account := strings.TrimSpace(r.FormValue("account")) password := r.FormValue("password") acc, err := s.accountStore.LoadAccount(account) if err != nil || !player.CheckPassword(password, acc.PasswordHash) { s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."}) return } if !acc.Admin { s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."}) return } s.setAuthCookie(w, account) s.undoStack.Clear() http.Redirect(w, r, "/", http.StatusFound) } func (s *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "admin_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.useTLS, }) http.Redirect(w, r, "/login", http.StatusFound) } func (s *AdminServer) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { if data == nil { data = map[string]any{} } if _, ok := data["Account"]; !ok { if acc := s.accountFromCookie(r); acc != "" { data["Account"] = acc } } if _, ok := data["UndoStack"]; !ok { data["UndoStack"] = s.undoStack.Info() } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { log.Printf("template error (%s): %v", name, err) } } func (s *AdminServer) accountFromCookie(r *http.Request) string { cookie, err := r.Cookie("admin_session") if err != nil { return "" } parts := strings.SplitN(cookie.Value, ":", 2) if len(parts) != 2 { return "" } return parts[0] } func loadOrGenerateCert(cfg *config.Config) (tls.Certificate, error) { if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { return tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) } log.Printf("admin_https: using self-signed certificate (no shared tls cert configured)") return generateSelfSignedCert() } func generateSelfSignedCert() (tls.Certificate, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return tls.Certificate{}, fmt.Errorf("rsa key generation: %w", err) } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return tls.Certificate{}, fmt.Errorf("serial number: %w", err) } now := time.Now() tmpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: "THOI Admin Self-Signed Certificate", }, NotBefore: now.Add(-1 * time.Hour), NotAfter: now.Add(365 * 24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, } certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { return tls.Certificate{}, fmt.Errorf("x509 creation: %w", err) } return tls.Certificate{ Certificate: [][]byte{certDER}, PrivateKey: key, }, nil } func writeJSON(w http.ResponseWriter, data any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(data); err != nil { log.Printf("json encode error: %v", err) } } func writeJSONError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]any{"error": msg}) } func readJSON(r *http.Request, v any) error { body, err := io.ReadAll(r.Body) if err != nil { return err } defer r.Body.Close() return json.Unmarshal(body, v) } func (s *AdminServer) handleUndoState(w http.ResponseWriter, r *http.Request) { writeJSON(w, s.undoStack.Info()) } func (s *AdminServer) doUndo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } change := s.undoStack.Undo() if change == nil { writeJSON(w, map[string]any{"message": "Nothing to undo"}) return } s.rebuildAfterUndo(change) resp := map[string]any{"message": "Undid: " + change.ShortDesc()} dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { resp["has_duplicates"] = true } writeJSON(w, resp) } func (s *AdminServer) doRedo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } change := s.undoStack.Redo() if change == nil { writeJSON(w, map[string]any{"message": "Nothing to redo"}) return } s.rebuildAfterUndo(change) resp := map[string]any{"message": "Redid: " + change.ShortDesc()} dups := behavior.CheckDuplicateIDs(filepath.Join(s.dataDir, "rooms")) if len(dups) > 0 { resp["has_duplicates"] = true } writeJSON(w, resp) } func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Action string `json:"action"` Name string `json:"name"` Dir string `json:"dir"` NewName string `json:"new_name"` } if err := readJSON(r, &body); err != nil { writeJSON(w, map[string]any{"error": "invalid"}) return } switch body.Action { case "create": if err := createEntityDir(s.dataDir, entityType, body.Name); err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } writeJSON(w, map[string]any{"ok": true, "name": body.Name}) case "delete": changes, err := deleteEntityDir(s.dataDir, entityType, body.Dir) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } for _, ch := range changes { s.undoStack.Push(ch) } writeJSON(w, map[string]any{"ok": true, "moved": len(changes) - 1}) case "rename_dir": if err := renameEntityDir(s.dataDir, entityType, body.Dir, body.NewName); err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } writeJSON(w, map[string]any{"ok": true, "name": body.NewName}) default: writeJSON(w, map[string]any{"error": "unknown action"}) } } } func (s *AdminServer) handleEntityRename(entityType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { ID string `json:"id"` NewID string `json:"new_id"` } if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" { writeJSON(w, map[string]any{"error": "invalid"}) return } oldPath, newPath, data, err := renameEntityFile(s.dataDir, entityType, body.ID, body.NewID) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } desc := fmt.Sprintf("rename %s %s to %s", entityType, body.ID, body.NewID) s.undoStack.Push(ChangeDesc{ Description: desc, FilePath: oldPath, NewFilePath: newPath, OldContent: data, NewContent: data, }) s.invalidateEntityCaches(entityType) writeJSON(w, map[string]any{"ok": true, "id": body.NewID}) } } func (s *AdminServer) handleEntityMove(entityType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { ID string `json:"id"` Dir string `json:"dir"` } if err := readJSON(r, &body); err != nil || body.ID == "" { writeJSON(w, map[string]any{"error": "invalid"}) return } oldPath, newPath, data, err := moveEntityFile(s.dataDir, entityType, body.ID, body.Dir) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } desc := fmt.Sprintf("move %s %s to %s/", entityType, body.ID, body.Dir) if body.Dir == "" { desc = fmt.Sprintf("move %s %s to root", entityType, body.ID) } s.undoStack.Push(ChangeDesc{ Description: desc, FilePath: oldPath, NewFilePath: newPath, OldContent: data, NewContent: data, }) s.invalidateEntityCaches(entityType) writeJSON(w, map[string]any{"ok": true}) } } func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { s.world.RebuildRoomIndex(s.dataDir) s.world.ClearHazardCache() for _, p := range changeFiles(change) { rel, err := filepath.Rel(filepath.Join(s.dataDir, "rooms"), p) if err != nil { continue } if rel == "." || rel == ".." || strings.HasPrefix(rel, "..") { continue } name := filepath.Base(p) if idStr := strings.TrimSuffix(name, ".yaml"); idStr != name { if id, err := strconv.Atoi(idStr); err == nil && id > 0 { s.world.ClearRoomState(id) // If the room file is now gone (true after undo-of-create and // redo-of-delete), drop any in-memory mob instances for it so // they don't keep ticking against a room that no longer exists. if _, statErr := os.Stat(p); os.IsNotExist(statErr) && s.mobStore != nil { s.mobStore.RemoveMobsInRoom(id) } } } } for _, p := range changeFiles(change) { s.invalidateEntityCacheForPath(p) } } func (s *AdminServer) invalidateEntityCaches(entityType string) { switch entityType { case "mobs": s.mobStore.ReloadDefs(s.dataDir) case "drops": behavior.ClearDropIndex() } } func (s *AdminServer) invalidateEntityCacheForPath(path string) { for _, subdir := range []string{"mobs", "drops"} { rel, err := filepath.Rel(filepath.Join(s.dataDir, subdir), path) if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { s.invalidateEntityCaches(subdir) } } } func changeFiles(change *ChangeDesc) []string { files := []string{change.FilePath} if change.NewFilePath != "" { files = append(files, change.NewFilePath) } for _, ef := range change.ExtraFiles { files = append(files, ef.FilePath) } return files }