Files
hnh-map/internal/app/app.go
Nikolay Tatarinov 5ffa10f8b7 Update project structure and enhance frontend functionality
- Added a new AGENTS.md file to document the project structure and conventions.
- Updated .gitignore to include node_modules and refined cursor rules.
- Introduced new backend and frontend components for improved map interactions, including context menus and controls.
- Enhanced API composables for better admin and authentication functionalities.
- Refactored existing components for cleaner code and improved user experience.
- Updated README.md to clarify production asset serving and user setup instructions.
2026-02-25 16:32:55 +03:00

262 lines
6.1 KiB
Go

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)
}