aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/server.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-29 22:31:11 -0400
committerhistoria <[not public]>2026-06-29 22:31:11 -0400
commit069d3c1c81d71042706372df153254487a765dfc (patch)
treed83a4a3954d2b8304f49d83e365f4c2b075b91eb /internal/admin/server.go
parent6c5271fb085b4571a10f106391974c249cd84317 (diff)
downloadthehouseoficarus-069d3c1c81d71042706372df153254487a765dfc.tar.gz
feat: wip web admin for mapping and crud
Diffstat (limited to 'internal/admin/server.go')
-rw-r--r--internal/admin/server.go395
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})
+}