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.
This commit is contained in:
2026-02-25 16:32:55 +03:00
parent 104fde7640
commit 5ffa10f8b7
48 changed files with 2699 additions and 465 deletions

View File

@@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -35,11 +36,11 @@ func (a *App) export(rw http.ResponseWriter, req *http.Request) {
maps := map[int]mapData{}
gridMap := map[string]int{}
grids := tx.Bucket([]byte("grids"))
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket([]byte("tiles"))
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
@@ -94,11 +95,11 @@ func (a *App) export(rw http.ResponseWriter, req *http.Request) {
}
err = func() error {
markersb := tx.Bucket([]byte("markers"))
markersb := tx.Bucket(store.BucketMarkers)
if markersb == nil {
return nil
}
markersgrid := markersb.Bucket([]byte("grid"))
markersgrid := markersb.Bucket(store.BucketMarkersGrid)
if markersgrid == nil {
return nil
}

View File

@@ -6,24 +6,25 @@ import (
"log"
"net/http"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func (a *App) hideMarker(rw http.ResponseWriter, req *http.Request) {
func (a *App) HideMarker(rw http.ResponseWriter, req *http.Request) {
if a.requireAdmin(rw, req) == nil {
return
}
err := a.db.Update(func(tx *bbolt.Tx) error {
mb, err := tx.CreateBucketIfNotExists([]byte("markers"))
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
if err != nil {
return err
}
grid, err := mb.CreateBucketIfNotExists([]byte("grid"))
grid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
if err != nil {
return err
}
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
if err != nil {
return err
}

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -48,27 +49,27 @@ func (a *App) merge(rw http.ResponseWriter, req *http.Request) {
newTiles := map[string]struct{}{}
err = a.db.Update(func(tx *bbolt.Tx) error {
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mb, err := tx.CreateBucketIfNotExists([]byte("markers"))
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
if err != nil {
return err
}
mgrid, err := mb.CreateBucketIfNotExists([]byte("grid"))
mgrid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
if err != nil {
return err
}
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
if err != nil {
return err
}
configb, err := tx.CreateBucketIfNotExists([]byte("config"))
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
@@ -114,7 +115,7 @@ func (a *App) merge(rw http.ResponseWriter, req *http.Request) {
}
}
mapB, err := tx.CreateBucketIfNotExists([]byte("maps"))
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}

View File

@@ -6,6 +6,7 @@ import (
"os"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -19,7 +20,7 @@ func (a *App) doRebuildZooms() {
saveGrid := map[zoomproc]string{}
a.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("grids"))
b := tx.Bucket(store.BucketGrids)
if b == nil {
return nil
}
@@ -30,7 +31,7 @@ func (a *App) doRebuildZooms() {
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil
})
tx.DeleteBucket([]byte("tiles"))
tx.DeleteBucket(store.BucketTiles)
return nil
})

View File

@@ -6,10 +6,11 @@ import (
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) {
func (a *App) WipeTile(rw http.ResponseWriter, req *http.Request) {
if a.requireAdmin(rw, req) == nil {
return
}
@@ -37,7 +38,7 @@ func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) {
}
a.db.Update(func(tx *bbolt.Tx) error {
grids := tx.Bucket([]byte("grids"))
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
@@ -71,7 +72,7 @@ func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(200)
}
func (a *App) setCoords(rw http.ResponseWriter, req *http.Request) {
func (a *App) SetCoords(rw http.ResponseWriter, req *http.Request) {
if a.requireAdmin(rw, req) == nil {
return
}
@@ -121,11 +122,11 @@ func (a *App) setCoords(rw http.ResponseWriter, req *http.Request) {
}
tds := []*TileData{}
a.db.Update(func(tx *bbolt.Tx) error {
grids := tx.Bucket([]byte("grids"))
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
tiles := tx.Bucket([]byte("tiles"))
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
@@ -52,7 +53,7 @@ func (a *App) apiLogin(rw http.ResponseWriter, req *http.Request) {
if bootstrap != "" && body.Pass == bootstrap {
var created bool
a.db.Update(func(tx *bbolt.Tx) error {
users, err := tx.CreateBucketIfNotExists([]byte("users"))
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
if err != nil {
return err
}
@@ -135,7 +136,7 @@ func (a *App) apiMe(rw http.ResponseWriter, req *http.Request) {
}
out := meResponse{Username: s.Username, Auths: s.Auths}
a.db.View(func(tx *bbolt.Tx) error {
ub := tx.Bucket([]byte("users"))
ub := tx.Bucket(store.BucketUsers)
if ub != nil {
uRaw := ub.Get([]byte(s.Username))
if uRaw != nil {
@@ -144,7 +145,7 @@ func (a *App) apiMe(rw http.ResponseWriter, req *http.Request) {
out.Tokens = u.Tokens
}
}
config := tx.Bucket([]byte("config"))
config := tx.Bucket(store.BucketConfig)
if config != nil {
out.Prefix = string(config.Get([]byte("prefix")))
}
@@ -214,7 +215,7 @@ func (a *App) generateTokenForUser(username string) []string {
token := hex.EncodeToString(tokenRaw)
var tokens []string
err := a.db.Update(func(tx *bbolt.Tx) error {
ub, _ := tx.CreateBucketIfNotExists([]byte("users"))
ub, _ := tx.CreateBucketIfNotExists(store.BucketUsers)
uRaw := ub.Get([]byte(username))
u := User{}
if uRaw != nil {
@@ -224,7 +225,7 @@ func (a *App) generateTokenForUser(username string) []string {
tokens = u.Tokens
buf, _ := json.Marshal(u)
ub.Put([]byte(username), buf)
tb, _ := tx.CreateBucketIfNotExists([]byte("tokens"))
tb, _ := tx.CreateBucketIfNotExists(store.BucketTokens)
return tb.Put([]byte(token), []byte(username))
})
if err != nil {
@@ -239,7 +240,7 @@ func (a *App) setUserPassword(username, pass string) error {
return nil
}
return a.db.Update(func(tx *bbolt.Tx) error {
users, err := tx.CreateBucketIfNotExists([]byte("users"))
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
if err != nil {
return err
}
@@ -266,7 +267,7 @@ func (a *App) apiAdminUsers(rw http.ResponseWriter, req *http.Request) {
}
var list []string
a.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("users"))
b := tx.Bucket(store.BucketUsers)
if b == nil {
return nil
}
@@ -293,7 +294,7 @@ func (a *App) apiAdminUserByName(rw http.ResponseWriter, req *http.Request, name
}
out.Username = name
a.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("users"))
b := tx.Bucket(store.BucketUsers)
if b == nil {
return nil
}
@@ -331,7 +332,7 @@ func (a *App) apiAdminUserPost(rw http.ResponseWriter, req *http.Request) {
}
tempAdmin := false
err := a.db.Update(func(tx *bbolt.Tx) error {
users, err := tx.CreateBucketIfNotExists([]byte("users"))
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
if err != nil {
return err
}
@@ -373,13 +374,13 @@ func (a *App) apiAdminUserDelete(rw http.ResponseWriter, req *http.Request, name
return
}
a.db.Update(func(tx *bbolt.Tx) error {
users, _ := tx.CreateBucketIfNotExists([]byte("users"))
users, _ := tx.CreateBucketIfNotExists(store.BucketUsers)
u := User{}
raw := users.Get([]byte(name))
if raw != nil {
json.Unmarshal(raw, &u)
}
tokens, _ := tx.CreateBucketIfNotExists([]byte("tokens"))
tokens, _ := tx.CreateBucketIfNotExists(store.BucketTokens)
for _, tok := range u.Tokens {
tokens.Delete([]byte(tok))
}
@@ -407,7 +408,7 @@ func (a *App) apiAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
}
out := settingsResponse{}
a.db.View(func(tx *bbolt.Tx) error {
c := tx.Bucket([]byte("config"))
c := tx.Bucket(store.BucketConfig)
if c == nil {
return nil
}
@@ -440,7 +441,7 @@ func (a *App) apiAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
return
}
err := a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("config"))
b, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
@@ -483,7 +484,7 @@ func (a *App) apiAdminMaps(rw http.ResponseWriter, req *http.Request) {
}
var maps []mapInfoJSON
a.db.View(func(tx *bbolt.Tx) error {
mapB := tx.Bucket([]byte("maps"))
mapB := tx.Bucket(store.BucketMaps)
if mapB == nil {
return nil
}
@@ -529,7 +530,7 @@ func (a *App) apiAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr s
return
}
err := a.db.Update(func(tx *bbolt.Tx) error {
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
maps, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
@@ -570,7 +571,7 @@ func (a *App) apiAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request,
}
var mi MapInfo
err = a.db.Update(func(tx *bbolt.Tx) error {
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
maps, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
@@ -605,9 +606,9 @@ func (a *App) apiAdminWipe(rw http.ResponseWriter, req *http.Request) {
return
}
err := a.db.Update(func(tx *bbolt.Tx) error {
for _, bname := range []string{"grids", "markers", "tiles", "maps"} {
if tx.Bucket([]byte(bname)) != nil {
if err := tx.DeleteBucket([]byte(bname)); err != nil {
for _, b := range [][]byte{store.BucketGrids, store.BucketMarkers, store.BucketTiles, store.BucketMaps} {
if tx.Bucket(b) != nil {
if err := tx.DeleteBucket(b); err != nil {
return err
}
}
@@ -621,7 +622,7 @@ func (a *App) apiAdminWipe(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
}
func (a *App) apiAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
func (a *App) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -633,7 +634,7 @@ func (a *App) apiAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
}
func (a *App) apiAdminExport(rw http.ResponseWriter, req *http.Request) {
func (a *App) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -644,7 +645,7 @@ func (a *App) apiAdminExport(rw http.ResponseWriter, req *http.Request) {
a.export(rw, req)
}
func (a *App) apiAdminMerge(rw http.ResponseWriter, req *http.Request) {
func (a *App) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -679,18 +680,18 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" {
switch path {
case "admin/wipeTile":
a.wipeTile(rw, req)
a.WipeTile(rw, req)
case "admin/setCoords":
a.setCoords(rw, req)
a.SetCoords(rw, req)
case "admin/hideMarker":
a.hideMarker(rw, req)
a.HideMarker(rw, req)
}
return
}
switch {
case path == "oauth/providers":
a.apiOAuthProviders(rw, req)
a.APIOAuthProviders(rw, req)
return
case strings.HasPrefix(path, "oauth/"):
rest := strings.TrimPrefix(path, "oauth/")
@@ -703,9 +704,9 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
action := parts[1]
switch action {
case "login":
a.oauthLogin(rw, req, provider)
a.OAuthLogin(rw, req, provider)
case "callback":
a.oauthCallback(rw, req, provider)
a.OAuthCallback(rw, req, provider)
default:
http.Error(rw, "not found", http.StatusNotFound)
}
@@ -775,13 +776,13 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
a.apiAdminWipe(rw, req)
return
case path == "admin/rebuildZooms":
a.apiAdminRebuildZooms(rw, req)
a.APIAdminRebuildZooms(rw, req)
return
case path == "admin/export":
a.apiAdminExport(rw, req)
a.APIAdminExport(rw, req)
return
case path == "admin/merge":
a.apiAdminMerge(rw, req)
a.APIAdminMerge(rw, req)
return
}

View File

@@ -20,8 +20,34 @@ type App struct {
characters map[string]Character
chmu sync.RWMutex
gridUpdates topic
mergeUpdates mergeTopic
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.

View File

@@ -6,6 +6,8 @@ import (
"encoding/json"
"net/http"
"github.com/andyleap/hnh-map/internal/app/response"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
@@ -17,7 +19,7 @@ func (a *App) getSession(req *http.Request) *Session {
}
var s *Session
a.db.View(func(tx *bbolt.Tx) error {
sessions := tx.Bucket([]byte("sessions"))
sessions := tx.Bucket(store.BucketSessions)
if sessions == nil {
return nil
}
@@ -33,7 +35,7 @@ func (a *App) getSession(req *http.Request) *Session {
s.Auths = Auths{AUTH_ADMIN}
return nil
}
users := tx.Bucket([]byte("users"))
users := tx.Bucket(store.BucketUsers)
if users == nil {
return nil
}
@@ -56,7 +58,7 @@ func (a *App) getSession(req *http.Request) *Session {
func (a *App) deleteSession(s *Session) {
a.db.Update(func(tx *bbolt.Tx) error {
sessions, err := tx.CreateBucketIfNotExists([]byte("sessions"))
sessions, err := tx.CreateBucketIfNotExists(store.BucketSessions)
if err != nil {
return err
}
@@ -66,7 +68,7 @@ func (a *App) deleteSession(s *Session) {
func (a *App) saveSession(s *Session) {
a.db.Update(func(tx *bbolt.Tx) error {
sessions, err := tx.CreateBucketIfNotExists([]byte("sessions"))
sessions, err := tx.CreateBucketIfNotExists(store.BucketSessions)
if err != nil {
return err
}
@@ -81,7 +83,7 @@ func (a *App) saveSession(s *Session) {
func (a *App) getPage(req *http.Request) Page {
p := Page{}
a.db.View(func(tx *bbolt.Tx) error {
c := tx.Bucket([]byte("config"))
c := tx.Bucket(store.BucketConfig)
if c == nil {
return nil
}
@@ -93,7 +95,7 @@ func (a *App) getPage(req *http.Request) Page {
func (a *App) getUser(user, pass string) (u *User) {
a.db.View(func(tx *bbolt.Tx) error {
users := tx.Bucket([]byte("users"))
users := tx.Bucket(store.BucketUsers)
if users == nil {
return nil
}
@@ -134,7 +136,7 @@ func (a *App) createSession(username string, tempAdmin bool) string {
func (a *App) setupRequired() bool {
var required bool
a.db.View(func(tx *bbolt.Tx) error {
ub := tx.Bucket([]byte("users"))
ub := tx.Bucket(store.BucketUsers)
if ub == nil {
required = true
return nil
@@ -151,7 +153,7 @@ func (a *App) setupRequired() bool {
func (a *App) requireAdmin(rw http.ResponseWriter, req *http.Request) *Session {
s := a.getSession(req)
if s == nil || !s.Auths.Has(AUTH_ADMIN) {
rw.WriteHeader(http.StatusUnauthorized)
response.JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return nil
}
return s

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"regexp"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -25,7 +26,7 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
auth := false
user := ""
a.db.View(func(tx *bbolt.Tx) error {
tb := tx.Bucket([]byte("tokens"))
tb := tx.Bucket(store.BucketTokens)
if tb == nil {
return nil
}
@@ -33,7 +34,7 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
if userName == nil {
return nil
}
ub := tx.Bucket([]byte("users"))
ub := tx.Bucket(store.BucketUsers)
if ub == nil {
return nil
}
@@ -84,7 +85,7 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
func (a *App) locate(rw http.ResponseWriter, req *http.Request) {
grid := req.FormValue("gridID")
err := a.db.View(func(tx *bbolt.Tx) error {
grids := tx.Bucket([]byte("grids"))
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/image/draw"
)
@@ -53,21 +54,21 @@ func (a *App) gridUpdate(rw http.ResponseWriter, req *http.Request) {
greq := GridRequest{}
err = a.db.Update(func(tx *bbolt.Tx) error {
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
mapB, err := tx.CreateBucketIfNotExists([]byte("maps"))
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
configb, err := tx.CreateBucketIfNotExists([]byte("config"))
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
@@ -269,7 +270,7 @@ func (a *App) gridUpload(rw http.ResponseWriter, req *http.Request) {
if ed.Season == 3 {
needTile := false
a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("grids"))
b, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
@@ -283,7 +284,7 @@ func (a *App) gridUpload(rw http.ResponseWriter, req *http.Request) {
return err
}
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
@@ -346,7 +347,7 @@ func (a *App) gridUpload(rw http.ResponseWriter, req *http.Request) {
mapid := 0
a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("grids"))
b, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -33,15 +34,15 @@ func (a *App) uploadMarkers(rw http.ResponseWriter, req *http.Request) {
return
}
err = a.db.Update(func(tx *bbolt.Tx) error {
mb, err := tx.CreateBucketIfNotExists([]byte("markers"))
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
if err != nil {
return err
}
grid, err := mb.CreateBucketIfNotExists([]byte("grid"))
grid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
if err != nil {
return err
}
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
if err != nil {
return err
}

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -36,7 +37,7 @@ func (a *App) updatePositions(rw http.ResponseWriter, req *http.Request) {
// Avoid holding db.View and chmu simultaneously to prevent deadlock.
gridDataByID := make(map[string]GridData)
a.db.View(func(tx *bbolt.Tx) error {
grids := tx.Bucket([]byte("grids"))
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}

View File

@@ -0,0 +1,584 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
)
type loginRequest struct {
User string `json:"user"`
Pass string `json:"pass"`
}
type meResponse struct {
Username string `json:"username"`
Auths []string `json:"auths"`
Tokens []string `json:"tokens,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
// APILogin handles POST /map/api/login.
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body loginRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if u := h.Auth.GetUserByUsername(body.User); u != nil && u.Pass == nil {
JSONError(rw, http.StatusUnauthorized, "Use OAuth to sign in", "OAUTH_ONLY")
return
}
u := h.Auth.GetUser(body.User, body.Pass)
if u == nil {
if boot := h.Auth.BootstrapAdmin(body.User, body.Pass, services.GetBootstrapPassword()); boot != nil {
u = boot
} else {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
}
sessionID := h.Auth.CreateSession(body.User, u.Auths.Has("tempadmin"))
if sessionID == "" {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
http.SetCookie(rw, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
MaxAge: 24 * 7 * 3600,
HttpOnly: true,
Secure: req.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
JSON(rw, http.StatusOK, meResponse{Username: body.User, Auths: u.Auths})
}
// APISetup handles GET /map/api/setup.
func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
JSON(rw, http.StatusOK, struct {
SetupRequired bool `json:"setupRequired"`
}{SetupRequired: h.Auth.SetupRequired()})
}
// APILogout handles POST /map/api/logout.
func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := h.Auth.GetSession(req)
if s != nil {
h.Auth.DeleteSession(s)
}
rw.WriteHeader(http.StatusOK)
}
// APIMe handles GET /map/api/me.
func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := h.Auth.GetSession(req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
out := meResponse{Username: s.Username, Auths: s.Auths}
out.Tokens, out.Prefix = h.Auth.GetUserTokensAndPrefix(s.Username)
JSON(rw, http.StatusOK, out)
}
// APIMeTokens handles POST /map/api/me/tokens.
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := h.Auth.GetSession(req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_UPLOAD) {
JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN")
return
}
tokens := h.Auth.GenerateTokenForUser(s.Username)
if tokens == nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, map[string][]string{"tokens": tokens})
}
type passwordRequest struct {
Pass string `json:"pass"`
}
// APIMePassword handles POST /map/api/me/password.
func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := h.Auth.GetSession(req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
var body passwordRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Auth.SetUserPassword(s.Username, body.Pass); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
rw.WriteHeader(http.StatusOK)
}
// APIConfig handles GET /map/api/config.
func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
s := h.Auth.GetSession(req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
config, err := h.Map.GetConfig(s.Auths)
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, config)
}
// APIGetChars handles GET /map/api/v1/characters.
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
s := h.Auth.GetSession(req)
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
JSON(rw, http.StatusOK, []interface{}{})
return
}
chars := h.Map.GetCharacters()
JSON(rw, http.StatusOK, chars)
}
// APIGetMarkers handles GET /map/api/v1/markers.
func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) {
s := h.Auth.GetSession(req)
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
JSON(rw, http.StatusOK, []interface{}{})
return
}
markers, err := h.Map.GetMarkers()
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, markers)
}
// APIGetMaps handles GET /map/api/maps.
func (h *Handlers) APIGetMaps(rw http.ResponseWriter, req *http.Request) {
s := h.Auth.GetSession(req)
if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
showHidden := s.Auths.Has(app.AUTH_ADMIN)
maps, err := h.Map.GetMaps(showHidden)
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, maps)
}
// --- Admin API ---
// APIAdminUsers handles GET/POST /map/api/admin/users.
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
if h.requireAdmin(rw, req) == nil {
return
}
list, err := h.Admin.ListUsers()
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, list)
return
}
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
var body struct {
User string `json:"user"`
Pass string `json:"pass"`
Auths []string `json:"auths"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
adminCreated, err := h.Admin.CreateOrUpdateUser(body.User, body.Pass, body.Auths)
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
if body.User == s.Username {
s.Auths = body.Auths
}
if adminCreated && s.Username == "admin" {
h.Auth.DeleteSession(s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.requireAdmin(rw, req) == nil {
return
}
auths, found := h.Admin.GetUser(name)
out := struct {
Username string `json:"username"`
Auths []string `json:"auths"`
}{Username: name}
if found {
out.Auths = auths
}
JSON(rw, http.StatusOK, out)
}
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodDelete {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
s := h.requireAdmin(rw, req)
if s == nil {
return
}
if err := h.Admin.DeleteUser(name); err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
if name == s.Username {
h.Auth.DeleteSession(s)
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.requireAdmin(rw, req) == nil {
return
}
prefix, defaultHide, title, err := h.Admin.GetSettings()
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, struct {
Prefix string `json:"prefix"`
DefaultHide bool `json:"defaultHide"`
Title string `json:"title"`
}{Prefix: prefix, DefaultHide: defaultHide, Title: title})
}
// APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Prefix *string `json:"prefix"`
DefaultHide *bool `json:"defaultHide"`
Title *string `json:"title"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateSettings(body.Prefix, body.DefaultHide, body.Title); err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
rw.WriteHeader(http.StatusOK)
}
type mapInfoJSON struct {
ID int `json:"ID"`
Name string `json:"Name"`
Hidden bool `json:"Hidden"`
Priority bool `json:"Priority"`
}
// APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.requireAdmin(rw, req) == nil {
return
}
maps, err := h.Admin.ListMaps()
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
out := make([]mapInfoJSON, len(maps))
for i, m := range maps {
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
}
JSON(rw, http.StatusOK, out)
}
// APIAdminMapByID handles POST /map/api/admin/maps/:id.
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.requireAdmin(rw, req) == nil {
return
}
var body struct {
Name string `json:"name"`
Hidden bool `json:"hidden"`
Priority bool `json:"priority"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Admin.UpdateMap(id, body.Name, body.Hidden, body.Priority); err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
rw.WriteHeader(http.StatusOK)
}
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr)
if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.requireAdmin(rw, req) == nil {
return
}
mi, err := h.Admin.ToggleMapHidden(id)
if err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
JSON(rw, http.StatusOK, mapInfoJSON{
ID: mi.ID,
Name: mi.Name,
Hidden: mi.Hidden,
Priority: mi.Priority,
})
}
// APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.requireAdmin(rw, req) == nil {
return
}
if err := h.Admin.Wipe(); err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
rw.WriteHeader(http.StatusOK)
}
// APIRouter routes /map/api/* requests.
func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/map/api")
path = strings.TrimPrefix(path, "/")
switch path {
case "config":
h.APIConfig(rw, req)
return
case "v1/characters":
h.APIGetChars(rw, req)
return
case "v1/markers":
h.APIGetMarkers(rw, req)
return
case "maps":
h.APIGetMaps(rw, req)
return
}
if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" {
switch path {
case "admin/wipeTile":
h.App.WipeTile(rw, req)
case "admin/setCoords":
h.App.SetCoords(rw, req)
case "admin/hideMarker":
h.App.HideMarker(rw, req)
}
return
}
switch {
case path == "oauth/providers":
h.App.APIOAuthProviders(rw, req)
return
case strings.HasPrefix(path, "oauth/"):
rest := strings.TrimPrefix(path, "oauth/")
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 {
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return
}
provider := parts[0]
action := parts[1]
switch action {
case "login":
h.App.OAuthLogin(rw, req, provider)
case "callback":
h.App.OAuthCallback(rw, req, provider)
default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
}
return
case path == "setup":
h.APISetup(rw, req)
return
case path == "login":
h.APILogin(rw, req)
return
case path == "logout":
h.APILogout(rw, req)
return
case path == "me":
h.APIMe(rw, req)
return
case path == "me/tokens":
h.APIMeTokens(rw, req)
return
case path == "me/password":
h.APIMePassword(rw, req)
return
case path == "admin/users":
if req.Method == http.MethodPost {
h.APIAdminUsers(rw, req)
} else {
h.APIAdminUsers(rw, req)
}
return
case strings.HasPrefix(path, "admin/users/"):
name := strings.TrimPrefix(path, "admin/users/")
if name == "" {
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return
}
if req.Method == http.MethodDelete {
h.APIAdminUserDelete(rw, req, name)
} else {
h.APIAdminUserByName(rw, req, name)
}
return
case path == "admin/settings":
if req.Method == http.MethodGet {
h.APIAdminSettingsGet(rw, req)
} else {
h.APIAdminSettingsPost(rw, req)
}
return
case path == "admin/maps":
h.APIAdminMaps(rw, req)
return
case strings.HasPrefix(path, "admin/maps/"):
rest := strings.TrimPrefix(path, "admin/maps/")
parts := strings.SplitN(rest, "/", 2)
idStr := parts[0]
if len(parts) == 2 && parts[1] == "toggle-hidden" {
h.APIAdminMapToggleHidden(rw, req, idStr)
return
}
if len(parts) == 1 {
h.APIAdminMapByID(rw, req, idStr)
return
}
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return
case path == "admin/wipe":
h.APIAdminWipe(rw, req)
return
case path == "admin/rebuildZooms":
h.App.APIAdminRebuildZooms(rw, req)
return
case path == "admin/export":
h.App.APIAdminExport(rw, req)
return
case path == "admin/merge":
h.App.APIAdminMerge(rw, req)
return
}
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
}

View File

@@ -0,0 +1,36 @@
package handlers
import (
"net/http"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services"
)
// Handlers holds HTTP handlers and their dependencies.
type Handlers struct {
App *app.App
Auth *services.AuthService
Map *services.MapService
Admin *services.AdminService
}
// New creates Handlers with the given dependencies.
func New(a *app.App, auth *services.AuthService, mapSvc *services.MapService, admin *services.AdminService) *Handlers {
return &Handlers{App: a, Auth: auth, Map: mapSvc, Admin: admin}
}
// requireAdmin returns session if admin, or writes 401 and returns nil.
func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.Session {
s := h.Auth.GetSession(req)
if s == nil || !s.Auths.Has(app.AUTH_ADMIN) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return nil
}
return s
}
// canAccessMap returns true if session has map or admin auth.
func (h *Handlers) canAccessMap(s *app.Session) bool {
return s != nil && (s.Auths.Has(app.AUTH_MAP) || s.Auths.Has(app.AUTH_ADMIN))
}

View File

@@ -0,0 +1,17 @@
package handlers
import (
"net/http"
"github.com/andyleap/hnh-map/internal/app/response"
)
// JSON writes v as JSON with the given status code.
func JSON(rw http.ResponseWriter, status int, v any) {
response.JSON(rw, status, v)
}
// JSONError writes an error response in standard format.
func JSONError(rw http.ResponseWriter, status int, msg, code string) {
response.JSONError(rw, status, msg, code)
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -51,15 +52,15 @@ func (a *App) getMarkers(rw http.ResponseWriter, req *http.Request) {
}
markers := []FrontendMarker{}
a.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("markers"))
b := tx.Bucket(store.BucketMarkers)
if b == nil {
return nil
}
grid := b.Bucket([]byte("grid"))
grid := b.Bucket(store.BucketMarkersGrid)
if grid == nil {
return nil
}
grids := tx.Bucket([]byte("grids"))
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
@@ -99,7 +100,7 @@ func (a *App) getMaps(rw http.ResponseWriter, req *http.Request) {
showHidden := s.Auths.Has(AUTH_ADMIN)
maps := map[int]*MapInfo{}
a.db.View(func(tx *bbolt.Tx) error {
mapB := tx.Bucket([]byte("maps"))
mapB := tx.Bucket(store.BucketMaps)
if mapB == nil {
return nil
}
@@ -131,7 +132,7 @@ func (a *App) config(rw http.ResponseWriter, req *http.Request) {
Auths: s.Auths,
}
a.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("config"))
b := tx.Bucket(store.BucketConfig)
if b == nil {
return nil
}

View File

@@ -7,22 +7,23 @@ import (
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
var migrations = []func(tx *bbolt.Tx) error{
func(tx *bbolt.Tx) error {
if tx.Bucket([]byte("markers")) != nil {
return tx.DeleteBucket([]byte("markers"))
if tx.Bucket(store.BucketMarkers) != nil {
return tx.DeleteBucket(store.BucketMarkers)
}
return nil
},
func(tx *bbolt.Tx) error {
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
@@ -51,22 +52,20 @@ var migrations = []func(tx *bbolt.Tx) error{
})
},
func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("config"))
b, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}
return b.Put([]byte("title"), []byte("HnH Automapper Server"))
},
func(tx *bbolt.Tx) error {
if tx.Bucket([]byte("markers")) != nil {
return tx.DeleteBucket([]byte("markers"))
}
// No-op: markers deletion already in migration 0
return nil
},
func(tx *bbolt.Tx) error {
if tx.Bucket([]byte("tiles")) != nil {
if tx.Bucket(store.BucketTiles) != nil {
allTiles := map[string]map[string]TileData{}
tiles := tx.Bucket([]byte("tiles"))
tiles := tx.Bucket(store.BucketTiles)
err := tiles.ForEach(func(k, v []byte) error {
zoom := tiles.Bucket(k)
zoomTiles := map[string]TileData{}
@@ -82,11 +81,11 @@ var migrations = []func(tx *bbolt.Tx) error{
if err != nil {
return err
}
err = tx.DeleteBucket([]byte("tiles"))
err = tx.DeleteBucket(store.BucketTiles)
if err != nil {
return err
}
tiles, err = tx.CreateBucket([]byte("tiles"))
tiles, err = tx.CreateBucket(store.BucketTiles)
if err != nil {
return err
}
@@ -115,18 +114,16 @@ var migrations = []func(tx *bbolt.Tx) error{
return nil
},
func(tx *bbolt.Tx) error {
if tx.Bucket([]byte("markers")) != nil {
return tx.DeleteBucket([]byte("markers"))
}
// No-op: markers deletion already in migration 0
return nil
},
func(tx *bbolt.Tx) error {
highest := uint64(0)
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
maps, err := tx.CreateBucketIfNotExists(store.BucketMaps)
if err != nil {
return err
}
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
if err != nil {
return err
}
@@ -138,6 +135,7 @@ var migrations = []func(tx *bbolt.Tx) error{
return err
}
if _, ok := mapsFound[gd.Map]; !ok {
mapsFound[gd.Map] = struct{}{}
if uint64(gd.Map) > highest {
highest = uint64(gd.Map)
}
@@ -157,7 +155,7 @@ var migrations = []func(tx *bbolt.Tx) error{
return maps.SetSequence(highest + 1)
},
func(tx *bbolt.Tx) error {
users := tx.Bucket([]byte("users"))
users := tx.Bucket(store.BucketUsers)
if users == nil {
return nil
}
@@ -176,7 +174,7 @@ var migrations = []func(tx *bbolt.Tx) error{
})
},
func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte("oauth_states"))
_, err := tx.CreateBucketIfNotExists(store.BucketOAuthStates)
return err
},
}
@@ -184,7 +182,7 @@ var migrations = []func(tx *bbolt.Tx) error{
// RunMigrations runs all pending migrations on the database.
func RunMigrations(db *bbolt.DB) error {
return db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("config"))
b, err := tx.CreateBucketIfNotExists(store.BucketConfig)
if err != nil {
return err
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
@@ -63,7 +64,7 @@ func (a *App) baseURL(req *http.Request) string {
return scheme + "://" + host
}
func (a *App) oauthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
func (a *App) OAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -88,7 +89,7 @@ func (a *App) oauthLogin(rw http.ResponseWriter, req *http.Request, provider str
}
stRaw, _ := json.Marshal(st)
err := a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("oauth_states"))
b, err := tx.CreateBucketIfNotExists(store.BucketOAuthStates)
if err != nil {
return err
}
@@ -108,7 +109,7 @@ type googleUserInfo struct {
Name string `json:"name"`
}
func (a *App) oauthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
func (a *App) OAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -127,7 +128,7 @@ func (a *App) oauthCallback(rw http.ResponseWriter, req *http.Request, provider
}
var st oauthState
err := a.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("oauth_states"))
b := tx.Bucket(store.BucketOAuthStates)
if b == nil {
return nil
}
@@ -224,7 +225,7 @@ func (a *App) googleUserInfo(accessToken string) (sub, email string, err error)
func (a *App) findOrCreateOAuthUser(provider, sub, email string) (string, *User) {
var username string
err := a.db.Update(func(tx *bbolt.Tx) error {
users, err := tx.CreateBucketIfNotExists([]byte("users"))
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
if err != nil {
return err
}
@@ -283,7 +284,7 @@ func (a *App) findOrCreateOAuthUser(provider, sub, email string) (string, *User)
func (a *App) getUserByUsername(username string) *User {
var u *User
a.db.View(func(tx *bbolt.Tx) error {
users := tx.Bucket([]byte("users"))
users := tx.Bucket(store.BucketUsers)
if users == nil {
return nil
}
@@ -297,7 +298,7 @@ func (a *App) getUserByUsername(username string) *User {
}
// apiOAuthProviders returns list of configured OAuth providers.
func (a *App) apiOAuthProviders(rw http.ResponseWriter, req *http.Request) {
func (a *App) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return

View File

@@ -0,0 +1,25 @@
package response
import (
"encoding/json"
"net/http"
)
// JSON writes v as JSON with the given status code.
func JSON(rw http.ResponseWriter, status int, v any) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(rw).Encode(v)
}
}
// JSONError writes an error response in standard format: {"error": "message", "code": "CODE"}.
func JSONError(rw http.ResponseWriter, status int, msg string, code string) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(status)
_ = json.NewEncoder(rw).Encode(map[string]string{
"error": msg,
"code": code,
})
}

37
internal/app/router.go Normal file
View File

@@ -0,0 +1,37 @@
package app
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// APIHandler is the interface for API routing (implemented by handlers.Handlers).
type APIHandler interface {
APIRouter(rw http.ResponseWriter, req *http.Request)
}
// Router returns the HTTP router for the app.
// publicDir is used for /js/ static file serving (e.g. "public").
// apiHandler handles /map/api/* requests; if nil, uses built-in apiRouter.
func (a *App) Router(publicDir string, apiHandler APIHandler) http.Handler {
r := chi.NewRouter()
r.Handle("/js/*", http.FileServer(http.Dir(publicDir)))
r.HandleFunc("/client/*", a.client)
r.HandleFunc("/logout", a.redirectLogout)
r.Route("/map", func(r chi.Router) {
if apiHandler != nil {
r.HandleFunc("/api/*", apiHandler.APIRouter)
} else {
r.HandleFunc("/api/*", a.apiRouter)
}
r.HandleFunc("/updates", a.watchGridUpdates)
r.Handle("/grids/*", http.StripPrefix("/map/grids", http.HandlerFunc(a.gridTile)))
})
r.HandleFunc("/*", a.serveSPARoot)
return r
}

View File

@@ -0,0 +1,216 @@
package services
import (
"encoding/json"
"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).
type AdminService struct {
st *store.Store
}
// NewAdminService creates an AdminService.
func NewAdminService(st *store.Store) *AdminService {
return &AdminService{st: st}
}
// ListUsers returns all usernames.
func (s *AdminService) ListUsers() ([]string, error) {
var list []string
err := s.st.View(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 user auths by username.
func (s *AdminService) GetUser(username string) (auths app.Auths, found bool) {
s.st.View(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 and didn't exist before (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(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(username string) error {
return s.st.Update(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 prefix, defaultHide, title.
func (s *AdminService) GetSettings() (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(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 config keys.
func (s *AdminService) UpdateSettings(prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(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.
func (s *AdminService) ListMaps() ([]app.MapInfo, error) {
var maps []app.MapInfo
err := s.st.View(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(id int) (*app.MapInfo, bool) {
var mi *app.MapInfo
s.st.View(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 map name, hidden, priority.
func (s *AdminService) UpdateMap(id int, name string, hidden, priority bool) error {
return s.st.Update(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 flips the hidden flag.
func (s *AdminService) ToggleMapHidden(id int) (*app.MapInfo, error) {
var mi *app.MapInfo
err := s.st.Update(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 grids, markers, tiles, maps buckets.
func (s *AdminService) Wipe() error {
return s.st.Update(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
})
}

View File

@@ -0,0 +1,242 @@
package services
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
// AuthService handles authentication and session business logic.
type AuthService struct {
st *store.Store
}
// NewAuthService creates an AuthService.
func NewAuthService(st *store.Store) *AuthService {
return &AuthService{st: st}
}
// GetSession returns the session from the request cookie, or nil.
func (s *AuthService) GetSession(req *http.Request) *app.Session {
c, err := req.Cookie("session")
if err != nil {
return nil
}
var sess *app.Session
s.st.View(func(tx *bbolt.Tx) error {
raw := s.st.GetSession(tx, c.Value)
if raw == nil {
return nil
}
if err := json.Unmarshal(raw, &sess); err != nil {
return err
}
if sess.TempAdmin {
sess.Auths = app.Auths{app.AUTH_ADMIN}
return nil
}
uRaw := s.st.GetUser(tx, sess.Username)
if uRaw == nil {
sess = nil
return nil
}
var u app.User
if err := json.Unmarshal(uRaw, &u); err != nil {
sess = nil
return err
}
sess.Auths = u.Auths
return nil
})
return sess
}
// DeleteSession removes a session.
func (s *AuthService) DeleteSession(sess *app.Session) {
s.st.Update(func(tx *bbolt.Tx) error {
return s.st.DeleteSession(tx, sess.ID)
})
}
// SaveSession stores a session.
func (s *AuthService) SaveSession(sess *app.Session) error {
return s.st.Update(func(tx *bbolt.Tx) error {
buf, err := json.Marshal(sess)
if err != nil {
return err
}
return s.st.PutSession(tx, sess.ID, buf)
})
}
// CreateSession creates a session for username, returns session ID or empty string.
func (s *AuthService) CreateSession(username string, tempAdmin bool) string {
session := make([]byte, 32)
if _, err := rand.Read(session); err != nil {
return ""
}
sid := hex.EncodeToString(session)
sess := &app.Session{
ID: sid,
Username: username,
TempAdmin: tempAdmin,
}
if s.SaveSession(sess) != nil {
return ""
}
return sid
}
// GetUser returns user if username/password match.
func (s *AuthService) GetUser(username, pass string) *app.User {
var u *app.User
s.st.View(func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
}
json.Unmarshal(raw, &u)
if u.Pass == nil {
u = nil
return nil
}
if bcrypt.CompareHashAndPassword(u.Pass, []byte(pass)) != nil {
u = nil
return nil
}
return nil
})
return u
}
// GetUserByUsername returns user without password check (for OAuth-only check).
func (s *AuthService) GetUserByUsername(username string) *app.User {
var u *app.User
s.st.View(func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw != nil {
json.Unmarshal(raw, &u)
}
return nil
})
return u
}
// SetupRequired returns true if no users exist (first run).
func (s *AuthService) SetupRequired() bool {
var required bool
s.st.View(func(tx *bbolt.Tx) error {
if s.st.UserCount(tx) == 0 {
required = true
}
return nil
})
return required
}
// BootstrapAdmin creates the first admin user if bootstrap env is set and no users exist.
// Returns the user if created, nil otherwise.
func (s *AuthService) BootstrapAdmin(username, pass, bootstrapEnv string) *app.User {
if username != "admin" || pass == "" || bootstrapEnv == "" || pass != bootstrapEnv {
return nil
}
var created bool
var u *app.User
s.st.Update(func(tx *bbolt.Tx) error {
if s.st.GetUser(tx, "admin") != nil {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
return err
}
user := app.User{
Pass: hash,
Auths: app.Auths{app.AUTH_ADMIN, app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD},
}
raw, _ := json.Marshal(user)
if err := s.st.PutUser(tx, "admin", raw); err != nil {
return err
}
created = true
u = &user
return nil
})
if created {
return u
}
return nil
}
// GetBootstrapPassword returns HNHMAP_BOOTSTRAP_PASSWORD from env.
func GetBootstrapPassword() string {
return os.Getenv("HNHMAP_BOOTSTRAP_PASSWORD")
}
// GetUserTokensAndPrefix returns tokens and config prefix for a user.
func (s *AuthService) GetUserTokensAndPrefix(username string) (tokens []string, prefix string) {
s.st.View(func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
json.Unmarshal(uRaw, &u)
tokens = u.Tokens
}
if p := s.st.GetConfig(tx, "prefix"); p != nil {
prefix = string(p)
}
return nil
})
return tokens, prefix
}
// GenerateTokenForUser adds a new token for user and returns the full list.
func (s *AuthService) GenerateTokenForUser(username string) []string {
tokenRaw := make([]byte, 16)
if _, err := rand.Read(tokenRaw); err != nil {
return nil
}
token := hex.EncodeToString(tokenRaw)
var tokens []string
s.st.Update(func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
json.Unmarshal(uRaw, &u)
}
u.Tokens = append(u.Tokens, token)
tokens = u.Tokens
buf, _ := json.Marshal(u)
s.st.PutUser(tx, username, buf)
return s.st.PutToken(tx, token, username)
})
return tokens
}
// SetUserPassword sets password for user (empty pass = no change).
func (s *AuthService) SetUserPassword(username, pass string) error {
if pass == "" {
return nil
}
return s.st.Update(func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
u := app.User{}
if uRaw != nil {
json.Unmarshal(uRaw, &u)
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Pass = hash
raw, _ := json.Marshal(u)
return s.st.PutUser(tx, username, raw)
})
}

View File

@@ -0,0 +1,174 @@
package services
import (
"encoding/json"
"strconv"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
// MapService handles map, markers, grids, tiles business logic.
type MapService struct {
st *store.Store
gridStorage string
gridUpdates *app.Topic[app.TileData]
mergeUpdates *app.Topic[app.Merge]
getChars func() []app.Character
}
// MapServiceDeps holds dependencies for MapService.
type MapServiceDeps struct {
Store *store.Store
GridStorage string
GridUpdates *app.Topic[app.TileData]
MergeUpdates *app.Topic[app.Merge]
GetChars func() []app.Character
}
// NewMapService creates a MapService.
func NewMapService(d MapServiceDeps) *MapService {
return &MapService{
st: d.Store,
gridStorage: d.GridStorage,
gridUpdates: d.GridUpdates,
mergeUpdates: d.MergeUpdates,
getChars: d.GetChars,
}
}
// GridStorage returns the grid storage path.
func (s *MapService) GridStorage() string {
return s.gridStorage
}
// GetCharacters returns all characters (from in-memory map).
func (s *MapService) GetCharacters() []app.Character {
if s.getChars == nil {
return nil
}
return s.getChars()
}
// GetMarkers returns all markers as FrontendMarker list.
func (s *MapService) GetMarkers() ([]app.FrontendMarker, error) {
var markers []app.FrontendMarker
err := s.st.View(func(tx *bbolt.Tx) error {
grid := s.st.GetMarkersGridBucket(tx)
if grid == nil {
return nil
}
grids := tx.Bucket(store.BucketGrids)
if grids == nil {
return nil
}
return grid.ForEach(func(k, v []byte) error {
marker := app.Marker{}
json.Unmarshal(v, &marker)
graw := grids.Get([]byte(marker.GridID))
if graw == nil {
return nil
}
g := app.GridData{}
json.Unmarshal(graw, &g)
markers = append(markers, app.FrontendMarker{
Image: marker.Image,
Hidden: marker.Hidden,
ID: marker.ID,
Name: marker.Name,
Map: g.Map,
Position: app.Position{
X: marker.Position.X + g.Coord.X*100,
Y: marker.Position.Y + g.Coord.Y*100,
},
})
return nil
})
})
return markers, err
}
// GetMaps returns maps, optionally filtering hidden for non-admin.
func (s *MapService) GetMaps(showHidden bool) (map[int]*app.MapInfo, error) {
maps := make(map[int]*app.MapInfo)
err := s.st.View(func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mapid, err := strconv.Atoi(string(k))
if err != nil {
return nil
}
mi := &app.MapInfo{}
json.Unmarshal(v, mi)
if mi.Hidden && !showHidden {
return nil
}
maps[mapid] = mi
return nil
})
})
return maps, err
}
// GetConfig returns config (title) and auths for session.
func (s *MapService) GetConfig(auths app.Auths) (app.Config, error) {
config := app.Config{Auths: auths}
err := s.st.View(func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
config.Title = string(title)
}
return nil
})
return config, err
}
// GetPage returns page title.
func (s *MapService) GetPage() (app.Page, error) {
p := app.Page{}
err := s.st.View(func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title")
if title != nil {
p.Title = string(title)
}
return nil
})
return p, err
}
// GetGrid returns GridData by ID.
func (s *MapService) GetGrid(id string) (*app.GridData, error) {
var gd *app.GridData
err := s.st.View(func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id)
if raw == nil {
return nil
}
gd = &app.GridData{}
return json.Unmarshal(raw, gd)
})
return gd, err
}
// GetTile returns TileData for map/zoom/coord.
func (s *MapService) GetTile(mapID int, c app.Coord, zoom int) *app.TileData {
var td *app.TileData
s.st.View(func(tx *bbolt.Tx) error {
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
if raw != nil {
td = &app.TileData{}
json.Unmarshal(raw, td)
}
return nil
})
return td
}
// ReportMerge sends a merge event.
func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
s.mergeUpdates.Send(&app.Merge{
From: from,
To: to,
Shift: shift,
})
}

View File

@@ -0,0 +1,20 @@
package store
// Bucket names for bbolt database.
var (
BucketUsers = []byte("users")
BucketSessions = []byte("sessions")
BucketTokens = []byte("tokens")
BucketConfig = []byte("config")
BucketGrids = []byte("grids")
BucketTiles = []byte("tiles")
BucketMaps = []byte("maps")
BucketMarkers = []byte("markers")
BucketOAuthStates = []byte("oauth_states")
)
// Sub-bucket names within markers bucket.
var (
BucketMarkersGrid = []byte("grid")
BucketMarkersID = []byte("id")
)

444
internal/app/store/db.go Normal file
View File

@@ -0,0 +1,444 @@
package store
import (
"strconv"
"go.etcd.io/bbolt"
)
// Store provides access to bbolt database (bucket helpers, CRUD).
type Store struct {
db *bbolt.DB
}
// New creates a Store for the given database.
func New(db *bbolt.DB) *Store {
return &Store{db: db}
}
// View runs fn in a read-only transaction.
func (s *Store) View(fn func(tx *bbolt.Tx) error) error {
return s.db.View(fn)
}
// Update runs fn in a read-write transaction.
func (s *Store) Update(fn func(tx *bbolt.Tx) error) error {
return s.db.Update(fn)
}
// --- Users ---
// GetUser returns raw user bytes by username, or nil if not found.
func (s *Store) GetUser(tx *bbolt.Tx, username string) []byte {
b := tx.Bucket(BucketUsers)
if b == nil {
return nil
}
return b.Get([]byte(username))
}
// PutUser stores user bytes by username.
func (s *Store) PutUser(tx *bbolt.Tx, username string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketUsers)
if err != nil {
return err
}
return b.Put([]byte(username), raw)
}
// DeleteUser removes a user.
func (s *Store) DeleteUser(tx *bbolt.Tx, username string) error {
b := tx.Bucket(BucketUsers)
if b == nil {
return nil
}
return b.Delete([]byte(username))
}
// ForEachUser calls fn for each user key.
func (s *Store) ForEachUser(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketUsers)
if b == nil {
return nil
}
return b.ForEach(fn)
}
// UserCount returns the number of users.
func (s *Store) UserCount(tx *bbolt.Tx) int {
b := tx.Bucket(BucketUsers)
if b == nil {
return 0
}
return b.Stats().KeyN
}
// --- Sessions ---
// GetSession returns raw session bytes by ID, or nil if not found.
func (s *Store) GetSession(tx *bbolt.Tx, id string) []byte {
b := tx.Bucket(BucketSessions)
if b == nil {
return nil
}
return b.Get([]byte(id))
}
// PutSession stores session bytes.
func (s *Store) PutSession(tx *bbolt.Tx, id string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketSessions)
if err != nil {
return err
}
return b.Put([]byte(id), raw)
}
// DeleteSession removes a session.
func (s *Store) DeleteSession(tx *bbolt.Tx, id string) error {
b := tx.Bucket(BucketSessions)
if b == nil {
return nil
}
return b.Delete([]byte(id))
}
// --- Tokens ---
// GetTokenUser returns username for token, or nil if not found.
func (s *Store) GetTokenUser(tx *bbolt.Tx, token string) []byte {
b := tx.Bucket(BucketTokens)
if b == nil {
return nil
}
return b.Get([]byte(token))
}
// PutToken stores token -> username mapping.
func (s *Store) PutToken(tx *bbolt.Tx, token, username string) error {
b, err := tx.CreateBucketIfNotExists(BucketTokens)
if err != nil {
return err
}
return b.Put([]byte(token), []byte(username))
}
// DeleteToken removes a token.
func (s *Store) DeleteToken(tx *bbolt.Tx, token string) error {
b := tx.Bucket(BucketTokens)
if b == nil {
return nil
}
return b.Delete([]byte(token))
}
// --- Config ---
// GetConfig returns config value by key.
func (s *Store) GetConfig(tx *bbolt.Tx, key string) []byte {
b := tx.Bucket(BucketConfig)
if b == nil {
return nil
}
return b.Get([]byte(key))
}
// PutConfig stores config value.
func (s *Store) PutConfig(tx *bbolt.Tx, key string, value []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketConfig)
if err != nil {
return err
}
return b.Put([]byte(key), value)
}
// DeleteConfig removes a config key.
func (s *Store) DeleteConfig(tx *bbolt.Tx, key string) error {
b := tx.Bucket(BucketConfig)
if b == nil {
return nil
}
return b.Delete([]byte(key))
}
// --- Maps ---
// GetMap returns raw MapInfo bytes by ID.
func (s *Store) GetMap(tx *bbolt.Tx, id int) []byte {
b := tx.Bucket(BucketMaps)
if b == nil {
return nil
}
return b.Get([]byte(strconv.Itoa(id)))
}
// PutMap stores MapInfo.
func (s *Store) PutMap(tx *bbolt.Tx, id int, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketMaps)
if err != nil {
return err
}
return b.Put([]byte(strconv.Itoa(id)), raw)
}
// DeleteMap removes a map.
func (s *Store) DeleteMap(tx *bbolt.Tx, id int) error {
b := tx.Bucket(BucketMaps)
if b == nil {
return nil
}
return b.Delete([]byte(strconv.Itoa(id)))
}
// MapsNextSequence returns next map ID sequence.
func (s *Store) MapsNextSequence(tx *bbolt.Tx) (uint64, error) {
b, err := tx.CreateBucketIfNotExists(BucketMaps)
if err != nil {
return 0, err
}
return b.NextSequence()
}
// MapsSetSequence sets map bucket sequence.
func (s *Store) MapsSetSequence(tx *bbolt.Tx, v uint64) error {
b := tx.Bucket(BucketMaps)
if b == nil {
return nil
}
return b.SetSequence(v)
}
// ForEachMap calls fn for each map.
func (s *Store) ForEachMap(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketMaps)
if b == nil {
return nil
}
return b.ForEach(fn)
}
// --- Grids ---
// GetGrid returns raw GridData bytes by ID.
func (s *Store) GetGrid(tx *bbolt.Tx, id string) []byte {
b := tx.Bucket(BucketGrids)
if b == nil {
return nil
}
return b.Get([]byte(id))
}
// PutGrid stores GridData.
func (s *Store) PutGrid(tx *bbolt.Tx, id string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketGrids)
if err != nil {
return err
}
return b.Put([]byte(id), raw)
}
// DeleteGrid removes a grid.
func (s *Store) DeleteGrid(tx *bbolt.Tx, id string) error {
b := tx.Bucket(BucketGrids)
if b == nil {
return nil
}
return b.Delete([]byte(id))
}
// ForEachGrid calls fn for each grid.
func (s *Store) ForEachGrid(tx *bbolt.Tx, fn func(k, v []byte) error) error {
b := tx.Bucket(BucketGrids)
if b == nil {
return nil
}
return b.ForEach(fn)
}
// --- Tiles (nested: mapid -> zoom -> coord) ---
// GetTile returns raw TileData bytes.
func (s *Store) GetTile(tx *bbolt.Tx, mapID, zoom int, coordKey string) []byte {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
return nil
}
mapB := tiles.Bucket([]byte(strconv.Itoa(mapID)))
if mapB == nil {
return nil
}
zoomB := mapB.Bucket([]byte(strconv.Itoa(zoom)))
if zoomB == nil {
return nil
}
return zoomB.Get([]byte(coordKey))
}
// PutTile stores TileData.
func (s *Store) PutTile(tx *bbolt.Tx, mapID, zoom int, coordKey string, raw []byte) error {
tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
if err != nil {
return err
}
mapB, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(mapID)))
if err != nil {
return err
}
zoomB, err := mapB.CreateBucketIfNotExists([]byte(strconv.Itoa(zoom)))
if err != nil {
return err
}
return zoomB.Put([]byte(coordKey), raw)
}
// DeleteTilesBucket removes the tiles bucket (for wipe).
func (s *Store) DeleteTilesBucket(tx *bbolt.Tx) error {
if tx.Bucket(BucketTiles) == nil {
return nil
}
return tx.DeleteBucket(BucketTiles)
}
// ForEachTile calls fn for each tile in the nested structure.
func (s *Store) ForEachTile(tx *bbolt.Tx, fn func(mapK, zoomK, coordK, v []byte) error) error {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
return nil
}
return tiles.ForEach(func(mapK, _ []byte) error {
mapB := tiles.Bucket(mapK)
if mapB == nil {
return nil
}
return mapB.ForEach(func(zoomK, _ []byte) error {
zoomB := mapB.Bucket(zoomK)
if zoomB == nil {
return nil
}
return zoomB.ForEach(func(coordK, v []byte) error {
return fn(mapK, zoomK, coordK, v)
})
})
})
}
// GetTilesMapBucket returns the bucket for a map's tiles, or nil.
func (s *Store) GetTilesMapBucket(tx *bbolt.Tx, mapID int) *bbolt.Bucket {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
return nil
}
return tiles.Bucket([]byte(strconv.Itoa(mapID)))
}
// CreateTilesMapBucket creates and returns the bucket for a map's tiles.
func (s *Store) CreateTilesMapBucket(tx *bbolt.Tx, mapID int) (*bbolt.Bucket, error) {
tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
if err != nil {
return nil, err
}
return tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(mapID)))
}
// DeleteTilesMapBucket removes a map's tile bucket.
func (s *Store) DeleteTilesMapBucket(tx *bbolt.Tx, mapID int) error {
tiles := tx.Bucket(BucketTiles)
if tiles == nil {
return nil
}
return tiles.DeleteBucket([]byte(strconv.Itoa(mapID)))
}
// --- Markers (nested: grid bucket, id bucket) ---
// GetMarkersGridBucket returns the markers-by-grid bucket.
func (s *Store) GetMarkersGridBucket(tx *bbolt.Tx) *bbolt.Bucket {
mb := tx.Bucket(BucketMarkers)
if mb == nil {
return nil
}
return mb.Bucket(BucketMarkersGrid)
}
// GetMarkersIDBucket returns the markers-by-id bucket.
func (s *Store) GetMarkersIDBucket(tx *bbolt.Tx) *bbolt.Bucket {
mb := tx.Bucket(BucketMarkers)
if mb == nil {
return nil
}
return mb.Bucket(BucketMarkersID)
}
// CreateMarkersBuckets creates markers, grid, and id buckets.
func (s *Store) CreateMarkersBuckets(tx *bbolt.Tx) (*bbolt.Bucket, *bbolt.Bucket, error) {
mb, err := tx.CreateBucketIfNotExists(BucketMarkers)
if err != nil {
return nil, nil, err
}
grid, err := mb.CreateBucketIfNotExists(BucketMarkersGrid)
if err != nil {
return nil, nil, err
}
idB, err := mb.CreateBucketIfNotExists(BucketMarkersID)
if err != nil {
return nil, nil, err
}
return grid, idB, nil
}
// MarkersNextSequence returns next marker ID.
func (s *Store) MarkersNextSequence(tx *bbolt.Tx) (uint64, error) {
mb := tx.Bucket(BucketMarkers)
if mb == nil {
return 0, nil
}
idB := mb.Bucket(BucketMarkersID)
if idB == nil {
return 0, nil
}
return idB.NextSequence()
}
// --- OAuth states ---
// GetOAuthState returns raw state bytes.
func (s *Store) GetOAuthState(tx *bbolt.Tx, state string) []byte {
b := tx.Bucket(BucketOAuthStates)
if b == nil {
return nil
}
return b.Get([]byte(state))
}
// PutOAuthState stores state.
func (s *Store) PutOAuthState(tx *bbolt.Tx, state string, raw []byte) error {
b, err := tx.CreateBucketIfNotExists(BucketOAuthStates)
if err != nil {
return err
}
return b.Put([]byte(state), raw)
}
// DeleteOAuthState removes state.
func (s *Store) DeleteOAuthState(tx *bbolt.Tx, state string) error {
b := tx.Bucket(BucketOAuthStates)
if b == nil {
return nil
}
return b.Delete([]byte(state))
}
// --- Bucket existence (for wipe) ---
// BucketExists returns true if the bucket exists.
func (s *Store) BucketExists(tx *bbolt.Tx, name []byte) bool {
return tx.Bucket(name) != nil
}
// DeleteBucket removes a bucket.
func (s *Store) DeleteBucket(tx *bbolt.Tx, name []byte) error {
if tx.Bucket(name) == nil {
return nil
}
return tx.DeleteBucket(name)
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -35,7 +36,7 @@ type TileData struct {
func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
a.db.View(func(tx *bbolt.Tx) error {
tiles := tx.Bucket([]byte("tiles"))
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}
@@ -59,7 +60,7 @@ func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
a.db.Update(func(tx *bbolt.Tx) error {
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
if err != nil {
return err
}
@@ -82,14 +83,14 @@ func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
if err != nil {
return err
}
a.gridUpdates.send(td)
a.gridUpdates.Send(td)
return zoom.Put([]byte(c.Name()), raw)
})
return
}
func (a *App) reportMerge(from, to int, shift Coord) {
a.mergeUpdates.send(&Merge{
a.mergeUpdates.Send(&Merge{
From: from,
To: to,
Shift: shift,
@@ -121,13 +122,13 @@ func (a *App) watchGridUpdates(rw http.ResponseWriter, req *http.Request) {
c := make(chan *TileData, 1000)
mc := make(chan *Merge, 5)
a.gridUpdates.watch(c)
a.mergeUpdates.watch(mc)
a.gridUpdates.Watch(c)
a.mergeUpdates.Watch(mc)
tileCache := make([]TileCache, 0, 100)
a.db.View(func(tx *bbolt.Tx) error {
tiles := tx.Bucket([]byte("tiles"))
tiles := tx.Bucket(store.BucketTiles)
if tiles == nil {
return nil
}

View File

@@ -2,18 +2,21 @@ package app
import "sync"
type topic struct {
c []chan *TileData
// Topic is a generic pub/sub for broadcasting updates.
type Topic[T any] struct {
c []chan *T
mu sync.Mutex
}
func (t *topic) watch(c chan *TileData) {
// Watch subscribes a channel to receive updates.
func (t *Topic[T]) Watch(c chan *T) {
t.mu.Lock()
defer t.mu.Unlock()
t.c = append(t.c, c)
}
func (t *topic) send(b *TileData) {
// Send broadcasts to all subscribers.
func (t *Topic[T]) Send(b *T) {
t.mu.Lock()
defer t.mu.Unlock()
for i := 0; i < len(t.c); i++ {
@@ -27,7 +30,8 @@ func (t *topic) send(b *TileData) {
}
}
func (t *topic) close() {
// Close closes all subscriber channels.
func (t *Topic[T]) Close() {
for _, c := range t.c {
close(c)
}
@@ -38,35 +42,3 @@ type Merge struct {
From, To int
Shift Coord
}
type mergeTopic struct {
c []chan *Merge
mu sync.Mutex
}
func (t *mergeTopic) watch(c chan *Merge) {
t.mu.Lock()
defer t.mu.Unlock()
t.c = append(t.c, c)
}
func (t *mergeTopic) send(b *Merge) {
t.mu.Lock()
defer t.mu.Unlock()
for i := 0; i < len(t.c); i++ {
select {
case t.c[i] <- b:
default:
close(t.c[i])
t.c[i] = t.c[len(t.c)-1]
t.c = t.c[:len(t.c)-1]
}
}
}
func (t *mergeTopic) close() {
for _, c := range t.c {
close(c)
}
t.c = t.c[:0]
}