package app import ( "encoding/json" "fmt" "net/http" "path/filepath" "sync" "time" "github.com/andyleap/hnh-map/webapp" "go.etcd.io/bbolt" "golang.org/x/crypto/bcrypt" ) // App is the main application (map server) state. type App struct { gridStorage string frontendRoot string db *bbolt.DB characters map[string]Character chmu sync.RWMutex *webapp.WebApp gridUpdates topic mergeUpdates mergeTopic } // NewApp creates an App with the given storage paths and database. // frontendRoot is the directory for the map SPA (e.g. "frontend"). func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) { w, err := webapp.New() if err != nil { return nil, err } return &App{ gridStorage: gridStorage, frontendRoot: frontendRoot, db: db, characters: make(map[string]Character), WebApp: w, }, nil } type Session struct { ID string Username string Auths Auths `json:"-"` TempAdmin bool } type Character struct { Name string `json:"name"` ID int `json:"id"` Map int `json:"map"` Position Position `json:"position"` Type string `json:"type"` updated time.Time } type Marker struct { Name string `json:"name"` ID int `json:"id"` GridID string `json:"gridID"` Position Position `json:"position"` Image string `json:"image"` Hidden bool `json:"hidden"` } type FrontendMarker struct { Name string `json:"name"` ID int `json:"id"` Map int `json:"map"` Position Position `json:"position"` Image string `json:"image"` Hidden bool `json:"hidden"` } type MapInfo struct { ID int Name string Hidden bool Priority bool } type GridData struct { ID string Coord Coord NextUpdate time.Time Map int } type Coord struct { X int `json:"x"` Y int `json:"y"` } type Position struct { X int `json:"x"` Y int `json:"y"` } func (c Coord) Name() string { return fmt.Sprintf("%d_%d", c.X, c.Y) } func (c Coord) Parent() Coord { if c.X < 0 { c.X-- } if c.Y < 0 { c.Y-- } return Coord{ X: c.X / 2, Y: c.Y / 2, } } type Auths []string func (a Auths) Has(auth string) bool { for _, v := range a { if v == auth { return true } } return false } const ( AUTH_ADMIN = "admin" AUTH_MAP = "map" AUTH_MARKERS = "markers" AUTH_UPLOAD = "upload" ) type User struct { Pass []byte Auths Auths Tokens []string } func (a *App) getSession(req *http.Request) *Session { c, err := req.Cookie("session") if err != nil { return nil } var s *Session a.db.View(func(tx *bbolt.Tx) error { sessions := tx.Bucket([]byte("sessions")) if sessions == nil { return nil } session := sessions.Get([]byte(c.Value)) if session == nil { return nil } err := json.Unmarshal(session, &s) if err != nil { return err } if s.TempAdmin { s.Auths = Auths{AUTH_ADMIN} return nil } users := tx.Bucket([]byte("users")) if users == nil { return nil } raw := users.Get([]byte(s.Username)) if raw == nil { s = nil return nil } u := User{} err = json.Unmarshal(raw, &u) if err != nil { s = nil return err } s.Auths = u.Auths return nil }) return s } func (a *App) deleteSession(s *Session) { a.db.Update(func(tx *bbolt.Tx) error { sessions, err := tx.CreateBucketIfNotExists([]byte("sessions")) if err != nil { return err } return sessions.Delete([]byte(s.ID)) }) } func (a *App) saveSession(s *Session) { a.db.Update(func(tx *bbolt.Tx) error { sessions, err := tx.CreateBucketIfNotExists([]byte("sessions")) if err != nil { return err } buf, err := json.Marshal(s) if err != nil { return err } return sessions.Put([]byte(s.ID), buf) }) } type Page struct { Title string `json:"title"` } func (a *App) getPage(req *http.Request) Page { p := Page{} a.db.View(func(tx *bbolt.Tx) error { c := tx.Bucket([]byte("config")) if c == nil { return nil } p.Title = string(c.Get([]byte("title"))) return nil }) return p } func (a *App) getUser(user, pass string) (u *User) { a.db.View(func(tx *bbolt.Tx) error { users := tx.Bucket([]byte("users")) if users == nil { return nil } raw := users.Get([]byte(user)) if raw != nil { json.Unmarshal(raw, &u) if bcrypt.CompareHashAndPassword(u.Pass, []byte(pass)) != nil { u = nil return nil } } return nil }) return u } // serveMapFrontend serves the map SPA: static files from frontend, fallback to index.html for client-side routes. func (a *App) serveMapFrontend(rw http.ResponseWriter, req *http.Request) { path := req.URL.Path if len(path) <= len("/map/") { path = "" } else { path = path[len("/map/"):] } root := a.frontendRoot if path == "" { path = "index.html" } path = filepath.Clean(path) if path == "." || path == ".." || (len(path) >= 2 && path[:2] == "..") { http.NotFound(rw, req) return } tryPaths := []string{filepath.Join("map", path), path} var f http.File for _, p := range tryPaths { var err error f, err = http.Dir(root).Open(p) if err == nil { path = p break } } if f == nil { http.ServeFile(rw, req, filepath.Join(root, "index.html")) return } defer f.Close() stat, err := f.Stat() if err != nil || stat.IsDir() { http.ServeFile(rw, req, filepath.Join(root, "index.html")) return } http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f) } // CleanChars runs a background loop that removes stale character entries. Call once as a goroutine. func (a *App) CleanChars() { for range time.Tick(time.Second * 10) { a.chmu.Lock() for n, c := range a.characters { if c.updated.Before(time.Now().Add(-10 * time.Second)) { delete(a.characters, n) } } a.chmu.Unlock() } } // RegisterRoutes registers all HTTP handlers for the app. func (a *App) RegisterRoutes() { http.HandleFunc("/client/", a.client) http.HandleFunc("/login", a.redirectLogin) http.HandleFunc("/logout", a.logout) http.HandleFunc("/", a.redirectRoot) http.HandleFunc("/generateToken", a.generateToken) http.HandleFunc("/password", a.changePassword) http.HandleFunc("/admin/", a.admin) http.HandleFunc("/admin/user", a.adminUser) http.HandleFunc("/admin/deleteUser", a.deleteUser) http.HandleFunc("/admin/wipe", a.wipe) http.HandleFunc("/admin/setPrefix", a.setPrefix) http.HandleFunc("/admin/setDefaultHide", a.setDefaultHide) http.HandleFunc("/admin/setTitle", a.setTitle) http.HandleFunc("/admin/rebuildZooms", a.rebuildZooms) http.HandleFunc("/admin/export", a.export) http.HandleFunc("/admin/merge", a.merge) http.HandleFunc("/admin/map", a.adminMap) http.HandleFunc("/admin/mapic", a.adminICMap) http.HandleFunc("/map/api/", a.apiRouter) http.HandleFunc("/map/updates", a.watchGridUpdates) http.HandleFunc("/map/grids/", a.gridTile) http.HandleFunc("/map/", a.serveMapFrontend) }