From 5ffa10f8b7b35de401093c720a590647b80ffaa2 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Wed, 25 Feb 2026 16:32:55 +0300 Subject: [PATCH] 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. --- .cursor/rules/backend-go.mdc | 11 + .cursor/rules/frontend-nuxt.mdc | 11 + .cursor/rules/project-conventions.mdc | 12 + .gitignore | 4 +- AGENTS.md | 28 + README.md | 2 + cmd/hnh-map/main.go | 21 +- frontend-nuxt/app.vue | 3 +- frontend-nuxt/components/MapPageWrapper.vue | 17 +- frontend-nuxt/components/MapView.vue | 395 ++++-------- .../components/map/MapContextMenu.vue | 63 ++ frontend-nuxt/components/map/MapControls.vue | 167 +++++ .../components/map/MapCoordSetModal.vue | 61 ++ .../components/map/MapCoordsDisplay.vue | 17 + frontend-nuxt/composables/useAdminApi.ts | 23 + frontend-nuxt/composables/useAuth.ts | 15 + frontend-nuxt/composables/useMapApi.ts | 25 +- frontend-nuxt/composables/useMapLogic.ts | 157 +++++ frontend-nuxt/types/api.ts | 17 + go.mod | 1 + go.sum | 2 + internal/app/admin_export.go | 9 +- internal/app/admin_markers.go | 9 +- internal/app/admin_merge.go | 15 +- internal/app/admin_rebuild.go | 5 +- internal/app/admin_tiles.go | 11 +- internal/app/api.go | 63 +- internal/app/app.go | 30 +- internal/app/auth.go | 18 +- internal/app/client.go | 7 +- internal/app/client_grid.go | 15 +- internal/app/client_markers.go | 7 +- internal/app/client_positions.go | 3 +- internal/app/handlers/api.go | 584 ++++++++++++++++++ internal/app/handlers/handlers.go | 36 ++ internal/app/handlers/response.go | 17 + internal/app/map.go | 11 +- internal/app/migrations.go | 38 +- internal/app/oauth.go | 15 +- internal/app/response/response.go | 25 + internal/app/router.go | 37 ++ internal/app/services/admin.go | 216 +++++++ internal/app/services/auth.go | 242 ++++++++ internal/app/services/map.go | 174 ++++++ internal/app/store/buckets.go | 20 + internal/app/store/db.go | 444 +++++++++++++ internal/app/tile.go | 15 +- internal/app/topic.go | 46 +- 48 files changed, 2699 insertions(+), 465 deletions(-) create mode 100644 .cursor/rules/backend-go.mdc create mode 100644 .cursor/rules/frontend-nuxt.mdc create mode 100644 .cursor/rules/project-conventions.mdc create mode 100644 AGENTS.md create mode 100644 frontend-nuxt/components/map/MapContextMenu.vue create mode 100644 frontend-nuxt/components/map/MapControls.vue create mode 100644 frontend-nuxt/components/map/MapCoordSetModal.vue create mode 100644 frontend-nuxt/components/map/MapCoordsDisplay.vue create mode 100644 frontend-nuxt/composables/useAdminApi.ts create mode 100644 frontend-nuxt/composables/useAuth.ts create mode 100644 frontend-nuxt/composables/useMapLogic.ts create mode 100644 internal/app/handlers/api.go create mode 100644 internal/app/handlers/handlers.go create mode 100644 internal/app/handlers/response.go create mode 100644 internal/app/response/response.go create mode 100644 internal/app/router.go create mode 100644 internal/app/services/admin.go create mode 100644 internal/app/services/auth.go create mode 100644 internal/app/services/map.go create mode 100644 internal/app/store/buckets.go create mode 100644 internal/app/store/db.go diff --git a/.cursor/rules/backend-go.mdc b/.cursor/rules/backend-go.mdc new file mode 100644 index 0000000..3cc2349 --- /dev/null +++ b/.cursor/rules/backend-go.mdc @@ -0,0 +1,11 @@ +--- +description: Go style and backend layout (internal/app) +globs: "**/*.go" +alwaysApply: false +--- + +# Backend (Go) + +- Entry point: [cmd/hnh-map/main.go](cmd/hnh-map/main.go). All app logic in `internal/app/` (package `app`): `app.go`, `auth.go`, `api.go`, `map.go`, `tile.go`, `admin_*.go`, `client*.go`, `migrations.go`, etc. +- Use `go fmt ./...` before committing. Run `go test ./...` when changing behaviour. +- Compatibility note: keep in mind [hnh-auto-mapper-server](https://github.com/APXEOLOG/hnh-auto-mapper-server) when touching client protocol or public API. diff --git a/.cursor/rules/frontend-nuxt.mdc b/.cursor/rules/frontend-nuxt.mdc new file mode 100644 index 0000000..ece827e --- /dev/null +++ b/.cursor/rules/frontend-nuxt.mdc @@ -0,0 +1,11 @@ +--- +description: Nuxt 3 frontend in frontend-nuxt, composables, public API +globs: "frontend-nuxt/**/*" +alwaysApply: false +--- + +# Frontend (Nuxt 3) + +- All frontend source lives in **frontend-nuxt/** (pages, components, composables, layouts, plugins, `public/gfx`). Production build output goes to `frontend/` and is served by the Go backend. +- Public API to backend: use composables — e.g. `composables/useMapApi.ts`, `useAuth.ts`, `useAdminApi.ts` — not raw fetch in components. +- Nuxt 3 conventions; ensure dev proxy in `nuxt.config.ts` points at backend when running locally (e.g. 3080 or 8080). diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc new file mode 100644 index 0000000..b73d257 --- /dev/null +++ b/.cursor/rules/project-conventions.mdc @@ -0,0 +1,12 @@ +--- +description: Monorepo layout, backend/frontend locations, API/config pointers +alwaysApply: true +--- + +# Project conventions + +- **Monorepo:** Go backend + Nuxt 3 frontend. Backend: `cmd/hnh-map/`, `internal/app/`. Frontend source: `frontend-nuxt/`; production static output: `frontend/` (build artifact from `frontend-nuxt/`, do not edit). +- **Changing API:** Update `internal/app/` (e.g. `api.go`, `map.go`) and [docs/api.md](docs/api.md); frontend uses composables in `frontend-nuxt/composables/` (e.g. `useMapApi.ts`). +- **Changing config:** Update [.env.example](.env.example) and [docs/configuration.md](docs/configuration.md). +- **Local run / build:** [docs/development.md](docs/development.md), [CONTRIBUTING.md](CONTRIBUTING.md). Dev ports: frontend 3000, backend 3080; prod: 8080. +- **Docs:** [docs/](docs/) (architecture, API, configuration, development, deployment). Some docs are in Russian. diff --git a/.gitignore b/.gitignore index 1131938..791b617 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ grids/ frontend-nuxt/node_modules frontend-nuxt/.nuxt frontend-nuxt/.output +node_modules # Old Vue 2 frontend (if present) frontend/node_modules @@ -22,5 +23,6 @@ frontend/dist # OS / IDE .DS_Store -.cursor/ +.cursor/* +!.cursor/rules/ .vscode/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..da46e0d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Agent guide — hnh-map + +Automapper server for HnH, (mostly) compatible with [hnh-auto-mapper-server](https://github.com/APXEOLOG/hnh-auto-mapper-server). This repo is a monorepo: Go backend + Nuxt 3 frontend. + +## Structure + +| Path | Purpose | +|------|--------| +| `cmd/hnh-map/` | Go entry point (`main.go`) | +| `internal/app/` | Backend logic (auth, API, map, tiles, admin, migrations) | +| `frontend-nuxt/` | Nuxt 3 app source (pages, components, composables, layouts, `public/gfx`) | +| `frontend/` | **Build output** — static assets served in production; generated from `frontend-nuxt/` (do not edit here) | +| `docs/` | Architecture, API, configuration, development, deployment (part of docs is in Russian) | +| `grids/` | Runtime data (in `.gitignore`) | + +## Where to look + +- **API:** [docs/api.md](docs/api.md) and handlers in `internal/app/` (e.g. `api.go`, `map.go`, `admin_*.go`). +- **Configuration:** [.env.example](.env.example) and [docs/configuration.md](docs/configuration.md). +- **Local run / build:** [docs/development.md](docs/development.md) and [CONTRIBUTING.md](CONTRIBUTING.md). + +## Conventions + +- **Backend:** Go; use `go fmt ./...`; tests with `go test ./...`. +- **Frontend:** Nuxt 3 in `frontend-nuxt/`; public API access via composables (e.g. `useMapApi`, `useAuth`, `useAdminApi`). +- **Ports:** Dev — frontend 3000, backend 3080 (docker-compose.dev); prod — single server 8080 serving backend + static from `frontend/`. + +See [.cursor/rules/](.cursor/rules/) for project-specific Cursor rules. diff --git a/README.md b/README.md index 291a6c1..60e0da1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ point your auto-mapping supported client at it (like Purus pasta). See also [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow. +In production the app serves static assets from the `frontend/` directory; that directory is the **build output** of the app in `frontend-nuxt/` (see [docs/development.md](docs/development.md)). + Only other thing you need to do is setup users and set your zero grid. First login: username **admin**, password from `HNHMAP_BOOTSTRAP_PASSWORD` (in dev Compose it defaults to `admin`). Go to the admin portal and hit "ADD USER". Don't forget to toggle on all the roles (you'll need admin, at least) diff --git a/cmd/hnh-map/main.go b/cmd/hnh-map/main.go index e4a9034..491c92a 100644 --- a/cmd/hnh-map/main.go +++ b/cmd/hnh-map/main.go @@ -10,6 +10,9 @@ import ( "strconv" "github.com/andyleap/hnh-map/internal/app" + "github.com/andyleap/hnh-map/internal/app/handlers" + "github.com/andyleap/hnh-map/internal/app/services" + "github.com/andyleap/hnh-map/internal/app/store" "go.etcd.io/bbolt" ) @@ -54,12 +57,22 @@ func main() { go a.CleanChars() - a.RegisterRoutes() + // Phase 3: store, services, handlers layers + st := store.New(db) + authSvc := services.NewAuthService(st) + mapSvc := services.NewMapService(services.MapServiceDeps{ + Store: st, + GridStorage: a.GridStorage(), + GridUpdates: a.GridUpdates(), + MergeUpdates: a.MergeUpdates(), + GetChars: a.GetCharacters, + }) + adminSvc := services.NewAdminService(st) + h := handlers.New(a, authSvc, mapSvc, adminSvc) - // Static assets under /js/ (e.g. from public/) publicDir := filepath.Join(workDir, "public") - http.Handle("/js/", http.FileServer(http.Dir(publicDir))) + r := a.Router(publicDir, h) log.Printf("Listening on port %d", *port) - log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil)) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), r)) } diff --git a/frontend-nuxt/app.vue b/frontend-nuxt/app.vue index 4751fa1..9579c27 100644 --- a/frontend-nuxt/app.vue +++ b/frontend-nuxt/app.vue @@ -19,7 +19,8 @@ // Global error handling: on API auth failure, redirect to login const { onApiError } = useMapApi() const { fullUrl } = useAppPaths() -onApiError(() => { +const unsubscribe = onApiError(() => { if (import.meta.client) window.location.href = fullUrl('/login') }) +onUnmounted(() => unsubscribe()) diff --git a/frontend-nuxt/components/MapPageWrapper.vue b/frontend-nuxt/components/MapPageWrapper.vue index 9c1fa0e..11b9c1c 100644 --- a/frontend-nuxt/components/MapPageWrapper.vue +++ b/frontend-nuxt/components/MapPageWrapper.vue @@ -1,21 +1,6 @@ diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index 048e5c3..c614a88 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -1,5 +1,5 @@ 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 @@ + + + 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 @@ + + + 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] -}