Files
hnh-map/internal/app/app.go
Nikolay Tatarinov 7bdaa6bfcc Enhance API and frontend components for character management and map visibility
- Updated API documentation to include `ownedByMe` field in character responses, indicating if a character was last updated by the current user's tokens.
- Modified MapView component to track and display the live status based on user-owned characters.
- Enhanced map data handling to exclude hidden maps for non-admin users and improved character icon representation on the map.
- Refactored character data structures to support new properties and ensure accurate rendering in the frontend.
2026-03-01 17:21:15 +03:00

295 lines
7.1 KiB
Go

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
}