package app import ( "fmt" "net/http" "path/filepath" "strings" "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[TileData] mergeUpdates Topic[Merge] } // GridStorage returns the grid storage path. func (a *App) GridStorage() string { return a.gridStorage } // GridUpdates returns the tile updates topic for MapService. func (a *App) GridUpdates() *Topic[TileData] { return &a.gridUpdates } // MergeUpdates returns the merge updates topic for MapService. func (a *App) MergeUpdates() *Topic[Merge] { return &a.mergeUpdates } // GetCharacters returns a copy of all characters (for MapService). func (a *App) GetCharacters() []Character { a.chmu.RLock() defer a.chmu.RUnlock() chars := make([]Character, 0, len(a.characters)) for _, v := range a.characters { chars = append(chars, v) } return chars } // 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 // OAuth: provider -> subject (unique ID from provider) OAuthLinks map[string]string `json:"oauth_links,omitempty"` // e.g. "google" -> "123456789" } type Page struct { Title string `json:"title"` } // serveSPARoot serves the map SPA from root: static files from frontend, fallback to index.html for client-side routes. // Handles redirects from old /map/* URLs for backward compatibility. func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) { path := req.URL.Path // Redirect old /map/* URLs to flat routes if path == "/map" || path == "/map/" { http.Redirect(rw, req, "/", http.StatusFound) return } if strings.HasPrefix(path, "/map/") { rest := path[len("/map/"):] switch { case rest == "login": http.Redirect(rw, req, "/login", http.StatusFound) return case rest == "profile": http.Redirect(rw, req, "/profile", http.StatusFound) return case rest == "admin" || strings.HasPrefix(rest, "admin/"): http.Redirect(rw, req, "/"+rest, http.StatusFound) return case rest == "setup": http.Redirect(rw, req, "/setup", http.StatusFound) return case strings.HasPrefix(rest, "character/"): http.Redirect(rw, req, "/"+rest, http.StatusFound) return case strings.HasPrefix(rest, "grid/"): http.Redirect(rw, req, "/"+rest, http.StatusFound) return } } // File serving: path relative to frontend root (with baseURL /, files are at root) filePath := strings.TrimPrefix(path, "/") if filePath == "" { filePath = "index.html" } filePath = filepath.Clean(filePath) if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") { http.NotFound(rw, req) return } // Try both root and map/ for backward compatibility with old builds tryPaths := []string{filePath, filepath.Join("map", filePath)} var f http.File for _, p := range tryPaths { var err error f, err = http.Dir(a.frontendRoot).Open(p) if err == nil { filePath = p break } } if f == nil { http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html")) return } defer f.Close() stat, err := f.Stat() if err != nil || stat.IsDir() { http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "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("/logout", a.redirectLogout) http.HandleFunc("/map/api/", a.apiRouter) http.HandleFunc("/map/updates", a.watchGridUpdates) http.HandleFunc("/map/grids/", a.gridTile) // SPA catch-all: must be last http.HandleFunc("/", a.serveSPARoot) }