diff options
| author | historia <[not public]> | 2026-06-29 22:31:11 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-29 22:31:11 -0400 |
| commit | 069d3c1c81d71042706372df153254487a765dfc (patch) | |
| tree | d83a4a3954d2b8304f49d83e365f4c2b075b91eb /internal/admin/server.go | |
| parent | 6c5271fb085b4571a10f106391974c249cd84317 (diff) | |
| download | thehouseoficarus-069d3c1c81d71042706372df153254487a765dfc.tar.gz | |
feat: wip web admin for mapping and crud
Diffstat (limited to 'internal/admin/server.go')
| -rw-r--r-- | internal/admin/server.go | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go new file mode 100644 index 0000000..418a693 --- /dev/null +++ b/internal/admin/server.go @@ -0,0 +1,395 @@ +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 + 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, 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) + } + + cert, err := loadOrGenerateCert(cfg) + if err != nil { + return nil, fmt.Errorf("admin cert: %w", err) + } + + s := &AdminServer{ + cfg: cfg, + 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", cfg.AdminHTTPS.Port), + Handler: mux, + 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) + } + tlsLn := tls.NewListener(ln, s.httpServer.TLSConfig) + return s.httpServer.Serve(tlsLn) +} + +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 { + for _, a := range s.cfg.AdminHTTPS.AdminAccounts { + if strings.EqualFold(a, name) { + return true + } + } + return false +} + +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: true, + 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") + + if !s.isAdminAccount(account) { + s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."}) + return + } + + 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 + } + + 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: true, + }) + 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 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}) +} |
