+
-
-
- {{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
-
-
-
-
-
-
-
- Zoom
-
-
-
-
-
-
-
-
-
-
- Navigation
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
(mapLogic.state.showGridCoordinates.value = v)"
+ :hide-markers="mapLogic.state.hideMarkers"
+ @update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
+ :selected-map-id="mapLogic.state.selectedMapId.value"
+ @update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
+ :overlay-map-id="mapLogic.state.overlayMapId.value"
+ @update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
+ :selected-marker-id="mapLogic.state.selectedMarkerId.value"
+ @update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
+ :selected-player-id="mapLogic.state.selectedPlayerId.value"
+ @update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
+ :maps="maps"
+ :quest-givers="questGivers"
+ :players="players"
+ @zoom-in="mapLogic.zoomIn(map)"
+ @zoom-out="mapLogic.zoomOutControl(map)"
+ @reset-view="mapLogic.resetView(map)"
+ />
+
+
diff --git a/frontend-nuxt/components/map/MapControls.vue b/frontend-nuxt/components/map/MapControls.vue
new file mode 100644
index 0000000..e2e03a3
--- /dev/null
+++ b/frontend-nuxt/components/map/MapControls.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+ Zoom
+
+
+
+
+
+
+
+
+
+
+ Navigation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-nuxt/components/map/MapCoordSetModal.vue b/frontend-nuxt/components/map/MapCoordSetModal.vue
new file mode 100644
index 0000000..fca4c61
--- /dev/null
+++ b/frontend-nuxt/components/map/MapCoordSetModal.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
diff --git a/frontend-nuxt/components/map/MapCoordsDisplay.vue b/frontend-nuxt/components/map/MapCoordsDisplay.vue
new file mode 100644
index 0000000..b6f3cf0
--- /dev/null
+++ b/frontend-nuxt/components/map/MapCoordsDisplay.vue
@@ -0,0 +1,17 @@
+
+
+ {{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
+
+
+
+
diff --git a/frontend-nuxt/composables/useAdminApi.ts b/frontend-nuxt/composables/useAdminApi.ts
new file mode 100644
index 0000000..887c0d6
--- /dev/null
+++ b/frontend-nuxt/composables/useAdminApi.ts
@@ -0,0 +1,23 @@
+/** Admin API composable. Uses useMapApi internally. */
+export function useAdminApi() {
+ const api = useMapApi()
+
+ return {
+ adminUsers: api.adminUsers,
+ adminUserByName: api.adminUserByName,
+ adminUserPost: api.adminUserPost,
+ adminUserDelete: api.adminUserDelete,
+ adminSettings: api.adminSettings,
+ adminSettingsPost: api.adminSettingsPost,
+ adminMaps: api.adminMaps,
+ adminMapPost: api.adminMapPost,
+ adminMapToggleHidden: api.adminMapToggleHidden,
+ adminWipe: api.adminWipe,
+ adminRebuildZooms: api.adminRebuildZooms,
+ adminExportUrl: api.adminExportUrl,
+ adminMerge: api.adminMerge,
+ adminWipeTile: api.adminWipeTile,
+ adminSetCoords: api.adminSetCoords,
+ adminHideMarker: api.adminHideMarker,
+ }
+}
diff --git a/frontend-nuxt/composables/useAuth.ts b/frontend-nuxt/composables/useAuth.ts
new file mode 100644
index 0000000..6c803b2
--- /dev/null
+++ b/frontend-nuxt/composables/useAuth.ts
@@ -0,0 +1,15 @@
+import type { MeResponse } from '~/types/api'
+
+/** Auth composable: login, logout, me, OAuth, setup. Uses useMapApi internally. */
+export function useAuth() {
+ const api = useMapApi()
+
+ return {
+ login: api.login,
+ logout: api.logout,
+ me: api.me,
+ oauthLoginUrl: api.oauthLoginUrl,
+ oauthProviders: api.oauthProviders,
+ setupRequired: api.setupRequired,
+ }
+}
diff --git a/frontend-nuxt/composables/useMapApi.ts b/frontend-nuxt/composables/useMapApi.ts
index 8d67e24..4f72cee 100644
--- a/frontend-nuxt/composables/useMapApi.ts
+++ b/frontend-nuxt/composables/useMapApi.ts
@@ -1,16 +1,27 @@
-import type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse } from '~/types/api'
+import type {
+ Character,
+ ConfigResponse,
+ MapInfo,
+ MapInfoAdmin,
+ Marker,
+ MeResponse,
+ SettingsResponse,
+} from '~/types/api'
-export type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse }
+export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse }
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
-const onApiErrorCallbacks: (() => void)[] = []
+const onApiErrorCallbacks = new Map
void>()
export function useMapApi() {
const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string
- function onApiError(cb: () => void) {
- onApiErrorCallbacks.push(cb)
+ /** Subscribe to API auth errors (401). Returns unsubscribe function. */
+ function onApiError(cb: () => void): () => void {
+ const id = Symbol()
+ onApiErrorCallbacks.set(id, cb)
+ return () => onApiErrorCallbacks.delete(id)
}
async function request(path: string, opts?: RequestInit): Promise {
@@ -34,11 +45,11 @@ export function useMapApi() {
}
async function getCharacters() {
- return request('v1/characters')
+ return request('v1/characters')
}
async function getMarkers() {
- return request('v1/markers')
+ return request('v1/markers')
}
async function getMaps() {
diff --git a/frontend-nuxt/composables/useMapLogic.ts b/frontend-nuxt/composables/useMapLogic.ts
new file mode 100644
index 0000000..1fdb4f5
--- /dev/null
+++ b/frontend-nuxt/composables/useMapLogic.ts
@@ -0,0 +1,157 @@
+import type L from 'leaflet'
+import { HnHMinZoom, HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
+
+export interface MapLogicState {
+ showGridCoordinates: Ref
+ hideMarkers: Ref
+ panelCollapsed: Ref
+ trackingCharacterId: Ref
+ selectedMapId: Ref
+ overlayMapId: Ref
+ selectedMarkerId: Ref
+ selectedPlayerId: Ref
+ displayCoords: Ref<{ x: number; y: number; z: number } | null>
+ mapid: Ref
+}
+
+export interface ContextMenuTileData {
+ coords: { x: number; y: number }
+}
+
+export interface ContextMenuMarkerData {
+ id: number
+ name: string
+}
+
+export interface ContextMenuState {
+ tile: {
+ show: boolean
+ x: number
+ y: number
+ data: ContextMenuTileData | null
+ }
+ marker: {
+ show: boolean
+ x: number
+ y: number
+ data: ContextMenuMarkerData | null
+ }
+}
+
+export interface CoordSetState {
+ from: { x: number; y: number }
+ to: { x: number; y: number }
+}
+
+/**
+ * Composable for map logic: zoom, display options, overlays, navigation, context menus.
+ * Map instance is passed to functions that need it (set by MapView after init).
+ */
+export function useMapLogic() {
+ const showGridCoordinates = ref(false)
+ const hideMarkers = ref(false)
+ const panelCollapsed = ref(false)
+ const trackingCharacterId = ref(-1)
+ const selectedMapId = ref(null)
+ const overlayMapId = ref(-1)
+ const selectedMarkerId = ref(null)
+ const selectedPlayerId = ref(null)
+ const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
+ const mapid = ref(0)
+
+ const contextMenu = reactive({
+ tile: { show: false, x: 0, y: 0, data: null },
+ marker: { show: false, x: 0, y: 0, data: null },
+ })
+
+ const coordSetFrom = ref({ x: 0, y: 0 })
+ const coordSet = ref({ x: 0, y: 0 })
+ const coordSetModalOpen = ref(false)
+
+ function zoomIn(map: L.Map | null) {
+ map?.zoomIn()
+ }
+
+ function zoomOutControl(map: L.Map | null) {
+ map?.zoomOut()
+ }
+
+ function resetView(map: L.Map | null) {
+ trackingCharacterId.value = -1
+ map?.setView([0, 0], HnHMinZoom, { animate: false })
+ }
+
+ function updateDisplayCoords(map: L.Map | null) {
+ if (!map) return
+ const point = map.project(map.getCenter(), 6)
+ displayCoords.value = {
+ x: Math.floor(point.x / TileSize),
+ y: Math.floor(point.y / TileSize),
+ z: map.getZoom(),
+ }
+ }
+
+ function toLatLng(map: L.Map | null, x: number, y: number): L.LatLng | null {
+ return map ? map.unproject([x, y], HnHMaxZoom) : null
+ }
+
+ function closeContextMenus() {
+ contextMenu.tile.show = false
+ contextMenu.marker.show = false
+ }
+
+ function openTileContextMenu(clientX: number, clientY: number, coords: { x: number; y: number }) {
+ contextMenu.tile.show = true
+ contextMenu.tile.x = clientX
+ contextMenu.tile.y = clientY
+ contextMenu.tile.data = { coords }
+ }
+
+ function openMarkerContextMenu(clientX: number, clientY: number, id: number, name: string) {
+ contextMenu.marker.show = true
+ contextMenu.marker.x = clientX
+ contextMenu.marker.y = clientY
+ contextMenu.marker.data = { id, name }
+ }
+
+ function openCoordSet(coords: { x: number; y: number }) {
+ coordSetFrom.value = { ...coords }
+ coordSet.value = { x: coords.x, y: coords.y }
+ coordSetModalOpen.value = true
+ }
+
+ function closeCoordSetModal() {
+ coordSetModalOpen.value = false
+ }
+
+ const state: MapLogicState = {
+ showGridCoordinates,
+ hideMarkers,
+ panelCollapsed,
+ trackingCharacterId,
+ selectedMapId,
+ overlayMapId,
+ selectedMarkerId,
+ selectedPlayerId,
+ displayCoords,
+ mapid,
+ }
+
+ return {
+ state,
+ contextMenu,
+ coordSetFrom,
+ coordSet,
+ zoomIn,
+ zoomOutControl,
+ resetView,
+ updateDisplayCoords,
+ toLatLng,
+ closeContextMenus,
+ openTileContextMenu,
+ openMarkerContextMenu,
+ openCoordSet,
+ closeCoordSetModal,
+ coordSetModalOpen,
+ }
+}
diff --git a/frontend-nuxt/types/api.ts b/frontend-nuxt/types/api.ts
index 15a2905..71b5501 100644
--- a/frontend-nuxt/types/api.ts
+++ b/frontend-nuxt/types/api.ts
@@ -28,3 +28,20 @@ export interface MapInfo {
Name: string
size?: number
}
+
+export interface Character {
+ name: string
+ id: number
+ map: number
+ position: { x: number; y: number }
+ type: string
+}
+
+export interface Marker {
+ name: string
+ id: number
+ map: number
+ position: { x: number; y: number }
+ image: string
+ hidden: boolean
+}
diff --git a/go.mod b/go.mod
index 74583b8..44ae843 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/andyleap/hnh-map
go 1.21
require (
+ github.com/go-chi/chi/v5 v5.1.0
go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
golang.org/x/image v0.0.0-20200119044424-58c23975cae1
diff --git a/go.sum b/go.sum
index c0d1380..76139c2 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,6 @@
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
diff --git a/internal/app/admin_export.go b/internal/app/admin_export.go
index 6b8c50c..8222f48 100644
--- a/internal/app/admin_export.go
+++ b/internal/app/admin_export.go
@@ -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
}
diff --git a/internal/app/admin_markers.go b/internal/app/admin_markers.go
index 5cffb69..5530be1 100644
--- a/internal/app/admin_markers.go
+++ b/internal/app/admin_markers.go
@@ -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
}
diff --git a/internal/app/admin_merge.go b/internal/app/admin_merge.go
index 1341ba0..893db09 100644
--- a/internal/app/admin_merge.go
+++ b/internal/app/admin_merge.go
@@ -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
}
diff --git a/internal/app/admin_rebuild.go b/internal/app/admin_rebuild.go
index acc5586..06e61ae 100644
--- a/internal/app/admin_rebuild.go
+++ b/internal/app/admin_rebuild.go
@@ -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
})
diff --git a/internal/app/admin_tiles.go b/internal/app/admin_tiles.go
index 99f61f3..64848bc 100644
--- a/internal/app/admin_tiles.go
+++ b/internal/app/admin_tiles.go
@@ -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
}
diff --git a/internal/app/api.go b/internal/app/api.go
index f54ac90..378620e 100644
--- a/internal/app/api.go
+++ b/internal/app/api.go
@@ -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
}
diff --git a/internal/app/app.go b/internal/app/app.go
index 69e0a59..79d8afe 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -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.
diff --git a/internal/app/auth.go b/internal/app/auth.go
index 197ed6d..55f3dfb 100644
--- a/internal/app/auth.go
+++ b/internal/app/auth.go
@@ -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
diff --git a/internal/app/client.go b/internal/app/client.go
index 8e90eac..03b8981 100644
--- a/internal/app/client.go
+++ b/internal/app/client.go
@@ -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
}
diff --git a/internal/app/client_grid.go b/internal/app/client_grid.go
index d635a44..68144b7 100644
--- a/internal/app/client_grid.go
+++ b/internal/app/client_grid.go
@@ -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
}
diff --git a/internal/app/client_markers.go b/internal/app/client_markers.go
index 101bde6..ea04a50 100644
--- a/internal/app/client_markers.go
+++ b/internal/app/client_markers.go
@@ -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
}
diff --git a/internal/app/client_positions.go b/internal/app/client_positions.go
index ae21938..f9156af 100644
--- a/internal/app/client_positions.go
+++ b/internal/app/client_positions.go
@@ -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
}
diff --git a/internal/app/handlers/api.go b/internal/app/handlers/api.go
new file mode 100644
index 0000000..4ed923b
--- /dev/null
+++ b/internal/app/handlers/api.go
@@ -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")
+}
diff --git a/internal/app/handlers/handlers.go b/internal/app/handlers/handlers.go
new file mode 100644
index 0000000..cb1738f
--- /dev/null
+++ b/internal/app/handlers/handlers.go
@@ -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))
+}
diff --git a/internal/app/handlers/response.go b/internal/app/handlers/response.go
new file mode 100644
index 0000000..377c678
--- /dev/null
+++ b/internal/app/handlers/response.go
@@ -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)
+}
diff --git a/internal/app/map.go b/internal/app/map.go
index 4291ce7..3371116 100644
--- a/internal/app/map.go
+++ b/internal/app/map.go
@@ -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
}
diff --git a/internal/app/migrations.go b/internal/app/migrations.go
index 49e8656..e392a38 100644
--- a/internal/app/migrations.go
+++ b/internal/app/migrations.go
@@ -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
}
diff --git a/internal/app/oauth.go b/internal/app/oauth.go
index 077a61e..65bba45 100644
--- a/internal/app/oauth.go
+++ b/internal/app/oauth.go
@@ -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
diff --git a/internal/app/response/response.go b/internal/app/response/response.go
new file mode 100644
index 0000000..70828f3
--- /dev/null
+++ b/internal/app/response/response.go
@@ -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,
+ })
+}
diff --git a/internal/app/router.go b/internal/app/router.go
new file mode 100644
index 0000000..df1d91b
--- /dev/null
+++ b/internal/app/router.go
@@ -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
+}
diff --git a/internal/app/services/admin.go b/internal/app/services/admin.go
new file mode 100644
index 0000000..ce0562f
--- /dev/null
+++ b/internal/app/services/admin.go
@@ -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
+ })
+}
diff --git a/internal/app/services/auth.go b/internal/app/services/auth.go
new file mode 100644
index 0000000..74d5141
--- /dev/null
+++ b/internal/app/services/auth.go
@@ -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)
+ })
+}
diff --git a/internal/app/services/map.go b/internal/app/services/map.go
new file mode 100644
index 0000000..a111d72
--- /dev/null
+++ b/internal/app/services/map.go
@@ -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,
+ })
+}
diff --git a/internal/app/store/buckets.go b/internal/app/store/buckets.go
new file mode 100644
index 0000000..72ac7fa
--- /dev/null
+++ b/internal/app/store/buckets.go
@@ -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")
+)
diff --git a/internal/app/store/db.go b/internal/app/store/db.go
new file mode 100644
index 0000000..7dbe210
--- /dev/null
+++ b/internal/app/store/db.go
@@ -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)
+}
+
diff --git a/internal/app/tile.go b/internal/app/tile.go
index 9086fd1..89535ac 100644
--- a/internal/app/tile.go
+++ b/internal/app/tile.go
@@ -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
}
diff --git a/internal/app/topic.go b/internal/app/topic.go
index 6654f28..0846e27 100644
--- a/internal/app/topic.go
+++ b/internal/app/topic.go
@@ -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]
-}