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" "strings" "time" "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 } func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir 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, } 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/rooms/link", s.handleRoomLink) apiMux.HandleFunc("/api/rooms/move", s.handleRoomMove) apiMux.HandleFunc("/api/rooms/rename", s.handleRoomRename) apiMux.HandleFunc("/api/rooms", s.handleRooms) apiMux.HandleFunc("/api/rooms/", s.handleRoomByID) apiMux.HandleFunc("/api/objects", s.handleObjects) apiMux.HandleFunc("/api/objects/", s.handleObjectByID) apiMux.HandleFunc("/api/items", s.handleItems) apiMux.HandleFunc("/api/items/", s.handleItemByID) apiMux.HandleFunc("/api/mobs", s.handleMobs) apiMux.HandleFunc("/api/mobs/", s.handleMobByID) apiMux.HandleFunc("/api/drops", s.handleDrops) apiMux.HandleFunc("/api/drops/", s.handleDropByID) apiMux.HandleFunc("/api/hazards", s.handleHazards) apiMux.HandleFunc("/api/hazards/", s.handleHazardByID) apiMux.HandleFunc("/api/techs", s.handleTechs) apiMux.HandleFunc("/api/techs/", s.handleTechByID) apiMux.HandleFunc("/api/courses", s.handleCourses) apiMux.HandleFunc("/api/courses/", s.handleCourseByID) apiMux.HandleFunc("/api/modules", s.handleModules) apiMux.HandleFunc("/api/modules/", s.handleModuleByID) apiMux.HandleFunc("/api/players", s.handlePlayers) apiMux.HandleFunc("/api/dashboard", s.handleDashboard) apiMux.HandleFunc("/api/flags", s.handleFlags) 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) 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" } 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/") 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) 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 } writeJSON(w, map[string]any{"message": "Undid: " + change.Description}) } 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 } writeJSON(w, map[string]any{"message": "Redid: " + change.Description}) }