- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded. - Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups. - Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
424 lines
10 KiB
Go
424 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"github.com/andyleap/hnh-map/internal/app"
|
|
"github.com/andyleap/hnh-map/internal/app/store"
|
|
"go.etcd.io/bbolt"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// AdminService handles admin business logic (users, settings, maps, wipe, tile ops).
|
|
type AdminService struct {
|
|
st *store.Store
|
|
mapSvc *MapService
|
|
|
|
rebuildMu sync.Mutex
|
|
rebuildRunning bool
|
|
}
|
|
|
|
// NewAdminService creates an AdminService with the given store and map service.
|
|
// Uses direct args (two dependencies) rather than a deps struct.
|
|
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
|
|
return &AdminService{st: st, mapSvc: mapSvc}
|
|
}
|
|
|
|
// ListUsers returns all usernames.
|
|
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
|
|
var list []string
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
return s.st.ForEachUser(tx, func(k, _ []byte) error {
|
|
list = append(list, string(k))
|
|
return nil
|
|
})
|
|
})
|
|
return list, err
|
|
}
|
|
|
|
// GetUser returns a user's permissions by username.
|
|
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
|
|
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
raw := s.st.GetUser(tx, username)
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
var u app.User
|
|
if err := json.Unmarshal(raw, &u); err != nil {
|
|
return err
|
|
}
|
|
auths = u.Auths
|
|
found = true
|
|
return nil
|
|
})
|
|
return auths, found, err
|
|
}
|
|
|
|
// CreateOrUpdateUser creates or updates a user.
|
|
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
|
|
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
|
|
err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
existed := s.st.GetUser(tx, username) != nil
|
|
u := app.User{}
|
|
raw := s.st.GetUser(tx, username)
|
|
if raw != nil {
|
|
if err := json.Unmarshal(raw, &u); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if pass != "" {
|
|
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
u.Pass = hash
|
|
}
|
|
u.Auths = auths
|
|
raw, _ = json.Marshal(u)
|
|
if e := s.st.PutUser(tx, username, raw); e != nil {
|
|
return e
|
|
}
|
|
if username == "admin" && !existed {
|
|
adminCreated = true
|
|
}
|
|
return nil
|
|
})
|
|
return adminCreated, err
|
|
}
|
|
|
|
// DeleteUser removes a user and their tokens.
|
|
func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
|
|
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
uRaw := s.st.GetUser(tx, username)
|
|
if uRaw != nil {
|
|
var u app.User
|
|
if err := json.Unmarshal(uRaw, &u); err != nil {
|
|
return err
|
|
}
|
|
for _, tok := range u.Tokens {
|
|
if err := s.st.DeleteToken(tx, tok); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return s.st.DeleteUser(tx, username)
|
|
})
|
|
}
|
|
|
|
// GetSettings returns the current server settings.
|
|
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
|
|
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
if v := s.st.GetConfig(tx, "prefix"); v != nil {
|
|
prefix = string(v)
|
|
}
|
|
if v := s.st.GetConfig(tx, "defaultHide"); v != nil {
|
|
defaultHide = true
|
|
}
|
|
if v := s.st.GetConfig(tx, "title"); v != nil {
|
|
title = string(v)
|
|
}
|
|
return nil
|
|
})
|
|
return prefix, defaultHide, title, err
|
|
}
|
|
|
|
// UpdateSettings updates the specified server settings (nil fields are skipped).
|
|
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
|
|
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
if prefix != nil {
|
|
if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if defaultHide != nil {
|
|
if *defaultHide {
|
|
if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if title != nil {
|
|
if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ListMaps returns all maps for the admin panel.
|
|
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
|
|
var maps []app.MapInfo
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
return s.st.ForEachMap(tx, func(k, v []byte) error {
|
|
mi := app.MapInfo{}
|
|
if err := json.Unmarshal(v, &mi); err != nil {
|
|
return err
|
|
}
|
|
if id, err := strconv.Atoi(string(k)); err == nil {
|
|
mi.ID = id
|
|
}
|
|
maps = append(maps, mi)
|
|
return nil
|
|
})
|
|
})
|
|
return maps, err
|
|
}
|
|
|
|
// GetMap returns a map by ID.
|
|
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) {
|
|
var mi *app.MapInfo
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
raw := s.st.GetMap(tx, id)
|
|
if raw != nil {
|
|
mi = &app.MapInfo{}
|
|
return json.Unmarshal(raw, mi)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if mi != nil {
|
|
mi.ID = id
|
|
}
|
|
return mi, mi != nil, nil
|
|
}
|
|
|
|
// UpdateMap updates a map's name, hidden, and priority fields.
|
|
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
|
|
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
mi := app.MapInfo{}
|
|
raw := s.st.GetMap(tx, id)
|
|
if raw != nil {
|
|
if err := json.Unmarshal(raw, &mi); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
mi.ID = id
|
|
mi.Name = name
|
|
mi.Hidden = hidden
|
|
mi.Priority = priority
|
|
raw, _ = json.Marshal(mi)
|
|
return s.st.PutMap(tx, id, raw)
|
|
})
|
|
}
|
|
|
|
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
|
|
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
|
|
var mi *app.MapInfo
|
|
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
raw := s.st.GetMap(tx, id)
|
|
mi = &app.MapInfo{}
|
|
if raw != nil {
|
|
if err := json.Unmarshal(raw, mi); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
mi.ID = id
|
|
mi.Hidden = !mi.Hidden
|
|
raw, _ = json.Marshal(mi)
|
|
return s.st.PutMap(tx, id, raw)
|
|
})
|
|
return mi, err
|
|
}
|
|
|
|
// Wipe deletes all grids, markers, tiles, and maps from the database.
|
|
func (s *AdminService) Wipe(ctx context.Context) error {
|
|
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
for _, b := range [][]byte{
|
|
store.BucketGrids,
|
|
store.BucketMarkers,
|
|
store.BucketTiles,
|
|
store.BucketMaps,
|
|
} {
|
|
if s.st.BucketExists(tx, b) {
|
|
if err := s.st.DeleteBucket(tx, b); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
|
|
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
|
|
c := app.Coord{X: x, Y: y}
|
|
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
grids := tx.Bucket(store.BucketGrids)
|
|
if grids == nil {
|
|
return nil
|
|
}
|
|
var ids [][]byte
|
|
err := grids.ForEach(func(k, v []byte) error {
|
|
g := app.GridData{}
|
|
if err := json.Unmarshal(v, &g); err != nil {
|
|
return err
|
|
}
|
|
if g.Coord == c && g.Map == mapid {
|
|
ids = append(ids, k)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, id := range ids {
|
|
if err := grids.Delete(id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
|
|
zc := c
|
|
for z := 1; z <= app.MaxZoomLevel; z++ {
|
|
zc = zc.Parent()
|
|
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetCoords shifts all grid and tile coordinates by a delta.
|
|
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
|
|
fc := app.Coord{X: fx, Y: fy}
|
|
tc := app.Coord{X: tx2, Y: ty}
|
|
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
|
|
|
|
var tds []*app.TileData
|
|
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
grids := tx.Bucket(store.BucketGrids)
|
|
if grids == nil {
|
|
return nil
|
|
}
|
|
tiles := tx.Bucket(store.BucketTiles)
|
|
if tiles == nil {
|
|
return nil
|
|
}
|
|
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
|
|
if mapZooms == nil {
|
|
return nil
|
|
}
|
|
mapTiles := mapZooms.Bucket([]byte("0"))
|
|
if err := grids.ForEach(func(k, v []byte) error {
|
|
g := app.GridData{}
|
|
if err := json.Unmarshal(v, &g); err != nil {
|
|
return err
|
|
}
|
|
if g.Map == mapid {
|
|
g.Coord.X += diff.X
|
|
g.Coord.Y += diff.Y
|
|
raw, _ := json.Marshal(g)
|
|
if err := grids.Put(k, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := mapTiles.ForEach(func(k, v []byte) error {
|
|
td := &app.TileData{}
|
|
if err := json.Unmarshal(v, td); err != nil {
|
|
return err
|
|
}
|
|
td.Coord.X += diff.X
|
|
td.Coord.Y += diff.Y
|
|
tds = append(tds, td)
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
ops := make([]TileOp, len(tds))
|
|
for i, td := range tds {
|
|
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
|
|
}
|
|
s.mapSvc.ProcessZoomLevels(ctx, ops)
|
|
return nil
|
|
}
|
|
|
|
// HideMarker marks a marker as hidden.
|
|
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
|
|
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
_, idB, err := s.st.CreateMarkersBuckets(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
grid := s.st.GetMarkersGridBucket(tx)
|
|
if grid == nil {
|
|
return fmt.Errorf("markers grid bucket not found")
|
|
}
|
|
key := idB.Get([]byte(markerID))
|
|
if key == nil {
|
|
slog.Warn("marker not found", "id", markerID)
|
|
return nil
|
|
}
|
|
raw := grid.Get(key)
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
m := app.Marker{}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return err
|
|
}
|
|
m.Hidden = true
|
|
raw, _ = json.Marshal(m)
|
|
if err := grid.Put(key, raw); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// RebuildZooms delegates to MapService.
|
|
func (s *AdminService) RebuildZooms(ctx context.Context) error {
|
|
return s.mapSvc.RebuildZooms(ctx)
|
|
}
|
|
|
|
// StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately.
|
|
// RebuildZoomsRunning returns true while the rebuild is in progress.
|
|
func (s *AdminService) StartRebuildZooms() {
|
|
s.rebuildMu.Lock()
|
|
if s.rebuildRunning {
|
|
s.rebuildMu.Unlock()
|
|
return
|
|
}
|
|
s.rebuildRunning = true
|
|
s.rebuildMu.Unlock()
|
|
go func() {
|
|
defer func() {
|
|
s.rebuildMu.Lock()
|
|
s.rebuildRunning = false
|
|
s.rebuildMu.Unlock()
|
|
}()
|
|
if err := s.mapSvc.RebuildZooms(context.Background()); err != nil {
|
|
slog.Error("RebuildZooms background failed", "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// RebuildZoomsRunning returns true if a rebuild is currently in progress.
|
|
func (s *AdminService) RebuildZoomsRunning() bool {
|
|
s.rebuildMu.Lock()
|
|
defer s.rebuildMu.Unlock()
|
|
return s.rebuildRunning
|
|
}
|