package app import ( "fmt" "net/http" "path/filepath" "sync" "time" "go.etcd.io/bbolt" ) // 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 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) { return &App{ gridStorage: gridStorage, frontendRoot: frontendRoot, db: db, characters: make(map[string]Character), }, 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 } type Page struct { Title string `json:"title"` } // 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.redirectLogout) http.HandleFunc("/admin", a.redirectAdmin) http.HandleFunc("/admin/", a.redirectAdmin) http.HandleFunc("/", a.redirectRoot) http.HandleFunc("/map/api/", a.apiRouter) http.HandleFunc("/map/updates", a.watchGridUpdates) http.HandleFunc("/map/grids/", a.gridTile) http.HandleFunc("/map/", a.serveMapFrontend) }