package app import ( "fmt" "net/http" "path/filepath" "strings" "sync" "time" "go.etcd.io/bbolt" ) const ( AUTH_ADMIN = "admin" AUTH_MAP = "map" AUTH_MARKERS = "markers" AUTH_UPLOAD = "upload" CharCleanupInterval = 10 * time.Second CharStaleThreshold = 10 * time.Second TileUpdateInterval = 30 * time.Minute MaxZoomLevel = 5 GridSize = 100 SessionMaxAge = 7 * 24 * 3600 // 1 week in seconds MultipartMaxMemory = 100 << 20 // 100 MB MergeMaxMemory = 500 << 20 // 500 MB ClientVersion = "4" SSETickInterval = 5 * time.Second SSETileChannelSize = 1000 SSEMergeChannelSize = 5 ) // 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] } // NewApp creates an App with the given storage paths and database. func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) { return &App{ gridStorage: gridStorage, frontendRoot: frontendRoot, db: db, characters: make(map[string]Character), }, nil } // GridStorage returns the path to the grid storage directory. func (a *App) GridStorage() string { return a.gridStorage } // GridUpdates returns the tile update pub/sub topic. func (a *App) GridUpdates() *Topic[TileData] { return &a.gridUpdates } // MergeUpdates returns the merge event pub/sub topic. func (a *App) MergeUpdates() *Topic[Merge] { return &a.mergeUpdates } // DB returns the underlying bbolt database. func (a *App) DB() *bbolt.DB { return a.db } // GetCharacters returns a copy of all characters. 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 } // WithCharacters provides locked mutable access to the character map. func (a *App) WithCharacters(fn func(chars map[string]Character)) { a.chmu.Lock() defer a.chmu.Unlock() fn(a.characters) } // CleanChars runs a background loop that removes stale character entries. func (a *App) CleanChars() { for range time.Tick(CharCleanupInterval) { a.chmu.Lock() for n, c := range a.characters { if c.Updated.Before(time.Now().Add(-CharStaleThreshold)) { delete(a.characters, n) } } a.chmu.Unlock() } } // serveSPARoot serves the map SPA: static files from frontend, fallback to index.html. func (a *App) ServeSPARoot(rw http.ResponseWriter, req *http.Request) { path := req.URL.Path 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 } } filePath := strings.TrimPrefix(path, "/") if filePath == "" { filePath = "index.html" } filePath = filepath.Clean(filePath) if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") { http.NotFound(rw, req) return } 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 { 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) } // --- Domain types --- // Session represents an authenticated user session. type Session struct { ID string Username string Auths Auths `json:"-"` TempAdmin bool } // ClientUsernameKey is the context key for the username that owns a client token (set by client handlers). var ClientUsernameKey = &struct{ key string }{key: "clientUsername"} // Character represents a game character on the map. 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 Username string `json:"-"` // owner of the token that last updated this character; not sent to API } // Marker represents a map marker stored per grid. 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"` } // FrontendMarker is a marker with map-level coordinates for the frontend. 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"` } // MapInfo describes a map instance. type MapInfo struct { ID int Name string Hidden bool Priority bool } // GridData represents a grid tile with its coordinates and map assignment. type GridData struct { ID string Coord Coord NextUpdate time.Time Map int } // Coord represents a grid coordinate pair. type Coord struct { X int `json:"x"` Y int `json:"y"` } // Position represents a pixel position within a grid. type Position struct { X int `json:"x"` Y int `json:"y"` } // Name returns the string representation "X_Y" used as bucket keys. func (c Coord) Name() string { return fmt.Sprintf("%d_%d", c.X, c.Y) } // Parent returns the parent coordinate at the next zoom level. 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, } } // Auths is a list of permission strings (e.g. "admin", "map", "upload"). type Auths []string // Has returns true if the list contains the given permission. func (a Auths) Has(auth string) bool { for _, v := range a { if v == auth { return true } } return false } // User represents a stored user account. type User struct { Pass []byte Auths Auths Tokens []string OAuthLinks map[string]string `json:"oauth_links,omitempty"` Email string `json:"email,omitempty"` } // Page holds page metadata for rendering. type Page struct { Title string `json:"title"` } // Config holds the application config sent to the frontend. type Config struct { Title string `json:"title"` Auths []string `json:"auths"` } // TileData represents a tile image entry in the database. type TileData struct { MapID int Coord Coord Zoom int File string Cache int64 }