package services import ( "context" "encoding/json" "fmt" "log/slog" "strconv" "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 } // NewAdminService creates an AdminService with the given store and map service. 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) { s.st.View(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetUser(tx, username) if raw == nil { return nil } var u app.User json.Unmarshal(raw, &u) auths = u.Auths found = true return nil }) return auths, found } // 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 { json.Unmarshal(raw, &u) } 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 json.Unmarshal(uRaw, &u) for _, tok := range u.Tokens { s.st.DeleteToken(tx, tok) } } 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 { s.st.PutConfig(tx, "prefix", []byte(*prefix)) } if defaultHide != nil { if *defaultHide { s.st.PutConfig(tx, "defaultHide", []byte("1")) } else { s.st.DeleteConfig(tx, "defaultHide") } } if title != nil { s.st.PutConfig(tx, "title", []byte(*title)) } 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{} json.Unmarshal(v, &mi) 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) { var mi *app.MapInfo s.st.View(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetMap(tx, id) if raw != nil { mi = &app.MapInfo{} json.Unmarshal(raw, mi) mi.ID = id } return nil }) return mi, mi != 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 { json.Unmarshal(raw, &mi) } 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 { json.Unmarshal(raw, mi) } 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 { grids.Delete(id) } 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) grids.Put(k, raw) } 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{} json.Unmarshal(raw, &m) m.Hidden = true raw, _ = json.Marshal(m) grid.Put(key, raw) return nil }) } // RebuildZooms delegates to MapService. func (s *AdminService) RebuildZooms(ctx context.Context) { s.mapSvc.RebuildZooms(ctx) }