Update project structure and enhance frontend functionality

- Added a new AGENTS.md file to document the project structure and conventions.
- Updated .gitignore to include node_modules and refined cursor rules.
- Introduced new backend and frontend components for improved map interactions, including context menus and controls.
- Enhanced API composables for better admin and authentication functionalities.
- Refactored existing components for cleaner code and improved user experience.
- Updated README.md to clarify production asset serving and user setup instructions.
This commit is contained in:
2026-02-25 16:32:55 +03:00
parent 104fde7640
commit 5ffa10f8b7
48 changed files with 2699 additions and 465 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

4
.gitignore vendored
View File

@@ -9,6 +9,7 @@ grids/
frontend-nuxt/node_modules frontend-nuxt/node_modules
frontend-nuxt/.nuxt frontend-nuxt/.nuxt
frontend-nuxt/.output frontend-nuxt/.output
node_modules
# Old Vue 2 frontend (if present) # Old Vue 2 frontend (if present)
frontend/node_modules frontend/node_modules
@@ -22,5 +23,6 @@ frontend/dist
# OS / IDE # OS / IDE
.DS_Store .DS_Store
.cursor/ .cursor/*
!.cursor/rules/
.vscode/ .vscode/

28
AGENTS.md Normal file
View File

@@ -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.

View File

@@ -24,6 +24,8 @@ point your auto-mapping supported client at it (like Purus pasta).
See also [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow. 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. 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) 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)

View File

@@ -10,6 +10,9 @@ import (
"strconv" "strconv"
"github.com/andyleap/hnh-map/internal/app" "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" "go.etcd.io/bbolt"
) )
@@ -54,12 +57,22 @@ func main() {
go a.CleanChars() 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") 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.Printf("Listening on port %d", *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil)) log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), r))
} }

View File

@@ -19,7 +19,8 @@
// Global error handling: on API auth failure, redirect to login // Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi() const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths() const { fullUrl } = useAppPaths()
onApiError(() => { const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login') if (import.meta.client) window.location.href = fullUrl('/login')
}) })
onUnmounted(() => unsubscribe())
</script> </script>

View File

@@ -1,21 +1,6 @@
<template> <template>
<div class="absolute inset-0"> <div class="absolute inset-0">
<ClientOnly> <slot />
<div class="absolute inset-0">
<slot />
</div>
<template #fallback>
<div class="h-screen flex flex-col items-center justify-center gap-4 bg-base-200">
<span class="loading loading-spinner loading-lg text-primary" />
<p class="text-base-content/80 font-medium">Loading map</p>
<div class="flex gap-2">
<div class="w-24 h-3 rounded bg-base-300 animate-pulse" />
<div class="w-32 h-3 rounded bg-base-300 animate-pulse" />
<div class="w-20 h-3 rounded bg-base-300 animate-pulse" />
</div>
</div>
</template>
</ClientOnly>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="relative h-full w-full" @click="contextMenu.tile.show = false; contextMenu.marker.show = false"> <div class="relative h-full w-full" @click="mapLogic.closeContextMenus()">
<div <div
v-if="mapsLoaded && maps.length === 0" v-if="mapsLoaded && maps.length === 0"
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6" class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
@@ -14,160 +14,48 @@
</div> </div>
</div> </div>
<div ref="mapRef" class="map h-full w-full" /> <div ref="mapRef" class="map h-full w-full" />
<!-- Grid coords & zoom (bottom-right) --> <MapMapCoordsDisplay
<div :mapid="mapLogic.state.mapid"
v-if="displayCoords" :display-coords="mapLogic.state.displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow" />
aria-label="Current grid position and zoom" <MapControls
title="mapId · x, y · zoom" :show-grid-coordinates="mapLogic.state.showGridCoordinates"
> @update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }} :hide-markers="mapLogic.state.hideMarkers"
</div> @update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
<!-- Control panel --> :selected-map-id="mapLogic.state.selectedMapId.value"
<div @update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
class="absolute left-3 top-[10%] z-[502] flex transition-all duration-300 ease-out" :overlay-map-id="mapLogic.state.overlayMapId.value"
:class="panelCollapsed ? 'w-12' : 'w-64'" @update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
> :selected-marker-id="mapLogic.state.selectedMarkerId.value"
<div @update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden transition-all duration-300 flex flex-col" :selected-player-id="mapLogic.state.selectedPlayerId.value"
:class="panelCollapsed ? 'w-12 items-center py-2' : 'w-56'" @update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
> :maps="maps"
<div v-show="!panelCollapsed" class="flex flex-col p-4 gap-4 flex-1 min-w-0"> :quest-givers="questGivers"
<!-- Zoom --> :players="players"
<section class="flex flex-col gap-2"> @zoom-in="mapLogic.zoomIn(map)"
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Zoom</h3> @zoom-out="mapLogic.zoomOutControl(map)"
<div class="flex items-center gap-2"> @reset-view="mapLogic.resetView(map)"
<button />
type="button" <MapMapContextMenu
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105" :context-menu="mapLogic.contextMenu"
title="Zoom in" @wipe-tile="onWipeTile"
aria-label="Zoom in" @rewrite-coords="onRewriteCoords"
@click="zoomIn" @hide-marker="onHideMarker"
> />
<icons-icon-zoom-in /> <MapMapCoordSetModal
</button> :coord-set-from="mapLogic.coordSetFrom"
<button :coord-set="mapLogic.coordSet"
type="button" :open="mapLogic.coordSetModalOpen"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105" @close="mapLogic.closeCoordSetModal()"
title="Zoom out" @submit="onSubmitCoordSet"
aria-label="Zoom out" />
@click="zoomOutControl"
>
<icons-icon-zoom-out />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Reset view — center map and minimum zoom"
aria-label="Reset view"
@click="zoomOut"
>
<icons-icon-home />
</button>
</div>
</section>
<!-- Display -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
<select v-model="selectedMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="null">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
<select v-model="selectedMarkerId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="null">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
<select v-model="selectedPlayerId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="null">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</section>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105"
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
@click="panelCollapsed = !panelCollapsed"
>
<icons-icon-chevron-right v-if="panelCollapsed" />
<icons-icon-panel-left v-else />
</button>
</div>
</div>
<!-- Context menu (tile) -->
<div
v-show="contextMenu.tile.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.tile.data && (wipeTile(contextMenu.tile.data), (contextMenu.tile.show = false))">
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
</button>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.tile.data && (openCoordSet(contextMenu.tile.data), (contextMenu.tile.show = false))">
Rewrite tile coords
</button>
</div>
<!-- Context menu (marker) -->
<div
v-show="contextMenu.marker.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.marker.data?.id != null && (hideMarkerById(contextMenu.marker.data.id), (contextMenu.marker.show = false))">
Hide marker {{ contextMenu.marker.data?.name }}
</button>
</div>
<!-- Coord-set modal: close via Cancel, backdrop click, or Escape -->
<dialog ref="coordSetModal" class="modal" @cancel="closeCoordSetModal">
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2">
<input v-model.number="coordSet.x" type="number" class="input input-bordered flex-1" placeholder="X" />
<input v-model.number="coordSet.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
</div>
<div class="modal-action">
<form method="dialog" @submit="submitCoordSet">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="button" class="btn" @click="closeCoordSetModal">Cancel</button>
</form>
</div>
</div>
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="closeCoordSetModal" />
</dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import MapControls from '~/components/map/MapControls.vue'
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes' import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer' import { SmartTileLayer } from '~/lib/SmartTileLayer'
import { Marker } from '~/lib/Marker' import { Marker } from '~/lib/Marker'
@@ -187,31 +75,14 @@ const props = withDefaults(
) )
const mapRef = ref<HTMLElement | null>(null) const mapRef = ref<HTMLElement | null>(null)
const route = useRoute()
const api = useMapApi() const api = useMapApi()
const mapLogic = useMapLogic()
const showGridCoordinates = ref(false)
const hideMarkers = ref(false)
const panelCollapsed = ref(false)
const trackingCharacterId = ref(-1)
const maps = ref<{ ID: number; Name: string; size?: number }[]>([]) const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const selectedMapId = ref<number | null>(null)
const overlayMapId = ref<number>(-1)
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([]) const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
const players = ref<{ id: number; name: string }[]>([]) const players = ref<{ id: number; name: string }[]>([])
const selectedMarkerId = ref<number | null>(null)
const selectedPlayerId = ref<number | null>(null)
const auths = ref<string[]>([]) const auths = ref<string[]>([])
const coordSetFrom = ref({ x: 0, y: 0 })
const coordSet = ref({ x: 0, y: 0 })
const coordSetModal = ref<HTMLDialogElement | null>(null)
const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
const contextMenu = reactive({
tile: { show: false, x: 0, y: 0, data: null as { coords: { x: number; y: number } } | null },
marker: { show: false, x: 0, y: 0, data: null as { id: number; name: string } | null },
})
let map: L.Map | null = null let map: L.Map | null = null
let layer: InstanceType<typeof SmartTileLayer> | null = null let layer: InstanceType<typeof SmartTileLayer> | null = null
@@ -220,7 +91,6 @@ let coordLayer: InstanceType<typeof GridCoordLayer> | null = null
let markerLayer: L.LayerGroup | null = null let markerLayer: L.LayerGroup | null = null
let source: EventSource | null = null let source: EventSource | null = null
let intervalId: ReturnType<typeof setInterval> | null = null let intervalId: ReturnType<typeof setInterval> | null = null
let mapid = 0
let markers: UniqueList<InstanceType<typeof Marker>> | null = null let markers: UniqueList<InstanceType<typeof Marker>> | null = null
let characters: UniqueList<InstanceType<typeof Character>> | null = null let characters: UniqueList<InstanceType<typeof Character>> | null = null
let markersHidden = false let markersHidden = false
@@ -231,10 +101,11 @@ function toLatLng(x: number, y: number) {
} }
function changeMap(id: number) { function changeMap(id: number) {
if (id === mapid) return if (id === mapLogic.state.mapid.value) return
mapid = id mapLogic.state.mapid.value = id
mapLogic.state.selectedMapId.value = id
if (layer) { if (layer) {
layer.map = mapid layer.map = id
layer.redraw() layer.redraw()
} }
if (overlayLayer) { if (overlayLayer) {
@@ -242,71 +113,50 @@ function changeMap(id: number) {
overlayLayer.redraw() overlayLayer.redraw()
} }
if (markers && !markersHidden) { if (markers && !markersHidden) {
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid })) markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid: id }))
markers.getElements().filter((it: any) => it.map === mapid).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid })) markers.getElements().filter((it: any) => it.map === id).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid: id }))
} }
if (characters) { if (characters) {
characters.getElements().forEach((it: any) => { characters.getElements().forEach((it: any) => {
it.remove({ map: map! }) it.remove({ map: map! })
it.add({ map: map!, mapid }) it.add({ map: map!, mapid: id })
}) })
} }
} }
function zoomIn() { function onWipeTile(coords: { x: number; y: number } | undefined) {
map?.zoomIn() if (!coords) return
mapLogic.closeContextMenus()
api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
} }
function zoomOutControl() { function onRewriteCoords(coords: { x: number; y: number } | undefined) {
map?.zoomOut() if (!coords) return
mapLogic.closeContextMenus()
mapLogic.openCoordSet(coords)
} }
function zoomOut() { function onHideMarker(id: number | undefined) {
trackingCharacterId.value = -1
map?.setView([0, 0], HnHMinZoom, { animate: false })
}
function wipeTile(data: { coords: { x: number; y: number } }) {
if (!data?.coords) return
api.adminWipeTile({ map: mapid, x: data.coords.x, y: data.coords.y })
}
function closeCoordSetModal() {
coordSetModal.value?.close()
}
function openCoordSet(data: { coords: { x: number; y: number } }) {
if (!data?.coords) return
coordSetFrom.value = { ...data.coords }
coordSet.value = { x: data.coords.x, y: data.coords.y }
coordSetModal.value?.showModal()
}
function submitCoordSet() {
api.adminSetCoords({
map: mapid,
fx: coordSetFrom.value.x,
fy: coordSetFrom.value.y,
tx: coordSet.value.x,
ty: coordSet.value.y,
})
coordSetModal.value?.close()
}
function hideMarkerById(id: number) {
if (id == null) return if (id == null) return
mapLogic.closeContextMenus()
api.adminHideMarker({ id }) api.adminHideMarker({ id })
const m = markers?.byId(id) const m = markers?.byId(id)
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid }) if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value })
} }
function closeContextMenus() { function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
contextMenu.tile.show = false api.adminSetCoords({
contextMenu.marker.show = false map: mapLogic.state.mapid.value,
fx: from.x,
fy: from.y,
tx: to.x,
ty: to.y,
})
mapLogic.closeCoordSetModal()
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeContextMenus() if (e.key === 'Escape') mapLogic.closeContextMenus()
} }
onMounted(async () => { onMounted(async () => {
@@ -353,9 +203,8 @@ onMounted(async () => {
const initialMapId = const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0 props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
mapid = initialMapId mapLogic.state.mapid.value = initialMapId
// Tiles are served at /map/grids/ (backend path, not SPA baseURL)
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api' const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map' const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
@@ -412,14 +261,10 @@ onMounted(async () => {
if (auths.value.includes('admin')) { if (auths.value.includes('admin')) {
const point = map!.project(mev.latlng, 6) const point = map!.project(mev.latlng, 6)
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) } const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
contextMenu.tile.show = true mapLogic.openTileContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, coords)
contextMenu.tile.x = mev.originalEvent.clientX
contextMenu.tile.y = mev.originalEvent.clientY
contextMenu.tile.data = { coords }
} }
}) })
// SSE is at /map/updates (backend path, not SPA baseURL). Same origin so it connects to correct host/port.
const updatesPath = `${backendBase}/updates` const updatesPath = `${backendBase}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
source = new EventSource(updatesUrl) source = new EventSource(updatesUrl)
@@ -437,28 +282,26 @@ onMounted(async () => {
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z) if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
} }
} catch { } catch {
// Ignore parse errors (e.g. empty SSE message or non-JSON) // Ignore parse errors
} }
} }
source.onerror = () => { source.onerror = () => {}
// Connection lost or 401; avoid uncaught errors
}
source.addEventListener('merge', (e: MessageEvent) => { source.addEventListener('merge', (e: MessageEvent) => {
try { try {
const merge = JSON.parse(e?.data ?? '{}') const merge = JSON.parse(e?.data ?? '{}')
if (mapid === merge.From) { if (mapLogic.state.mapid.value === merge.From) {
const mapTo = merge.To const mapTo = merge.To
const point = map!.project(map!.getCenter(), 6) const point = map!.project(map!.getCenter(), 6)
const coordinate = { const coordinate = {
x: Math.floor(point.x / TileSize) + merge.Shift.x, x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y, y: Math.floor(point.y / TileSize) + merge.Shift.y,
z: map!.getZoom(), z: map!.getZoom(),
}
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
changeMap(mapTo)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
map!.setView(latLng, map!.getZoom())
} }
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
changeMap(mapTo)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
map!.setView(latLng, map!.getZoom())
}
} catch { } catch {
// Ignore merge parse errors // Ignore merge parse errors
} }
@@ -470,22 +313,21 @@ onMounted(async () => {
updateCharacters(charactersData as any[]) updateCharacters(charactersData as any[])
if (props.characterId !== undefined && props.characterId >= 0) { if (props.characterId !== undefined && props.characterId >= 0) {
trackingCharacterId.value = props.characterId mapLogic.state.trackingCharacterId.value = props.characterId
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) { } else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
const latLng = toLatLng(props.gridX * 100, props.gridY * 100) const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
if (mapid !== props.mapId) changeMap(props.mapId) if (mapLogic.state.mapid.value !== props.mapId) changeMap(props.mapId)
selectedMapId.value = props.mapId mapLogic.state.selectedMapId.value = props.mapId
map.setView(latLng, props.zoom, { animate: false }) map.setView(latLng, props.zoom, { animate: false })
} else if (mapsList.length > 0) { } else if (mapsList.length > 0) {
const first = mapsList[0] const first = mapsList[0]
if (first) { if (first) {
changeMap(first.ID) changeMap(first.ID)
selectedMapId.value = first.ID mapLogic.state.selectedMapId.value = first.ID
map.setView([0, 0], HnHDefaultZoom, { animate: false }) map.setView([0, 0], HnHDefaultZoom, { animate: false })
} }
} }
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
nextTick(() => { nextTick(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -503,18 +345,15 @@ onMounted(async () => {
function updateMarkers(markersData: any[]) { function updateMarkers(markersData: any[]) {
if (!markers || !map || !markerLayer) return if (!markers || !map || !markerLayer) return
const list = Array.isArray(markersData) ? markersData : [] const list = Array.isArray(markersData) ? markersData : []
const ctx = { map, markerLayer, mapid, overlayLayer, auths: auths.value } const ctx = { map, markerLayer, mapid: mapLogic.state.mapid.value, overlayLayer, auths: auths.value }
markers.update( markers.update(
list.map((it) => new Marker(it)), list.map((it) => new Marker(it)),
(marker: InstanceType<typeof Marker>) => { (marker: InstanceType<typeof Marker>) => {
if (marker.map === mapid || marker.map === overlayLayer?.map) marker.add(ctx) if (marker.map === mapLogic.state.mapid.value || marker.map === overlayLayer?.map) marker.add(ctx)
marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom)) marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom))
marker.setContextMenu((mev: L.LeafletMouseEvent) => { marker.setContextMenu((mev: L.LeafletMouseEvent) => {
if (auths.value.includes('admin')) { if (auths.value.includes('admin')) {
contextMenu.marker.show = true mapLogic.openMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
contextMenu.marker.x = mev.originalEvent.clientX
contextMenu.marker.y = mev.originalEvent.clientY
contextMenu.marker.data = { id: marker.id, name: marker.name }
} }
}) })
}, },
@@ -527,17 +366,17 @@ onMounted(async () => {
function updateCharacters(charactersData: any[]) { function updateCharacters(charactersData: any[]) {
if (!characters || !map) return if (!characters || !map) return
const list = Array.isArray(charactersData) ? charactersData : [] const list = Array.isArray(charactersData) ? charactersData : []
const ctx = { map, mapid } const ctx = { map, mapid: mapLogic.state.mapid.value }
characters.update( characters.update(
list.map((it) => new Character(it)), list.map((it) => new Character(it)),
(character: InstanceType<typeof Character>) => { (character: InstanceType<typeof Character>) => {
character.add(ctx) character.add(ctx)
character.setClickCallback(() => (trackingCharacterId.value = character.id)) character.setClickCallback(() => (mapLogic.state.trackingCharacterId.value = character.id))
}, },
(character: InstanceType<typeof Character>) => character.remove(ctx), (character: InstanceType<typeof Character>) => character.remove(ctx),
(character: InstanceType<typeof Character>, updated: any) => { (character: InstanceType<typeof Character>, updated: any) => {
if (trackingCharacterId.value === updated.id) { if (mapLogic.state.trackingCharacterId.value === updated.id) {
if (mapid !== updated.map) changeMap(updated.map) if (mapLogic.state.mapid.value !== updated.map) changeMap(updated.map)
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom) const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom) map!.setView(latlng, HnHMaxZoom)
} }
@@ -547,7 +386,7 @@ onMounted(async () => {
players.value = characters.getElements() players.value = characters.getElements()
} }
watch(showGridCoordinates, (v) => { watch(mapLogic.state.showGridCoordinates, (v) => {
if (coordLayer) { if (coordLayer) {
;(coordLayer.options as { visible?: boolean }).visible = v ;(coordLayer.options as { visible?: boolean }).visible = v
coordLayer.setOpacity(v ? 1 : 0) coordLayer.setOpacity(v ? 1 : 0)
@@ -561,19 +400,19 @@ onMounted(async () => {
} }
}) })
watch(hideMarkers, (v) => { watch(mapLogic.state.hideMarkers, (v) => {
markersHidden = v markersHidden = v
if (!markers) return if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer } const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
if (v) { if (v) {
markers.getElements().forEach((it: any) => it.remove(ctx)) markers.getElements().forEach((it: any) => it.remove(ctx))
} else { } else {
markers.getElements().forEach((it: any) => it.remove(ctx)) markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapid || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx)) markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
} }
}) })
watch(trackingCharacterId, (value) => { watch(mapLogic.state.trackingCharacterId, (value) => {
if (value === -1) return if (value === -1) return
const character = characters?.byId(value) const character = characters?.byId(value)
if (character) { if (character) {
@@ -583,59 +422,49 @@ onMounted(async () => {
autoMode = true autoMode = true
} else { } else {
map!.setView([0, 0], HnHMinZoom) map!.setView([0, 0], HnHMinZoom)
trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
} }
}) })
watch(selectedMapId, (value) => { watch(mapLogic.state.selectedMapId, (value) => {
if (value == null) return if (value == null) return
changeMap(value) changeMap(value)
const zoom = map!.getZoom() const zoom = map!.getZoom()
map!.setView([0, 0], zoom) map!.setView([0, 0], zoom)
}) })
watch(overlayMapId, (value) => { watch(mapLogic.state.overlayMapId, (value) => {
if (overlayLayer) overlayLayer.map = value ?? -1 if (overlayLayer) overlayLayer.map = value ?? -1
overlayLayer?.redraw() overlayLayer?.redraw()
if (!markers) return if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer } const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
markers.getElements().forEach((it: any) => it.remove(ctx)) markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapid || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx)) markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
}) })
watch(selectedMarkerId, (value) => { watch(mapLogic.state.selectedMarkerId, (value) => {
if (value == null) return if (value == null) return
const marker = markers?.byId(value) const marker = markers?.byId(value)
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom()) if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
}) })
watch(selectedPlayerId, (value) => { watch(mapLogic.state.selectedPlayerId, (value) => {
if (value != null) trackingCharacterId.value = value if (value != null) mapLogic.state.trackingCharacterId.value = value
}) })
function updateDisplayCoords() { map.on('moveend', () => mapLogic.updateDisplayCoords(map))
if (!map) return mapLogic.updateDisplayCoords(map)
const point = map.project(map.getCenter(), 6)
displayCoords.value = {
x: Math.floor(point.x / TileSize),
y: Math.floor(point.y / TileSize),
z: map.getZoom(),
}
}
map.on('moveend', updateDisplayCoords)
updateDisplayCoords()
map.on('zoomend', () => { map.on('zoomend', () => {
if (map) map.invalidateSize() if (map) map.invalidateSize()
}) })
map.on('drag', () => { map.on('drag', () => {
trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
}) })
map.on('zoom', () => { map.on('zoom', () => {
if (autoMode) { if (autoMode) {
autoMode = false autoMode = false
} else { } else {
trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
} }
}) })
}) })

View File

@@ -0,0 +1,63 @@
<template>
<!-- Context menu (tile) -->
<div
v-show="contextMenu.tile.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onWipeTile(contextMenu.tile.data?.coords)"
>
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
</button>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onRewriteCoords(contextMenu.tile.data?.coords)"
>
Rewrite tile coords
</button>
</div>
<!-- Context menu (marker) -->
<div
v-show="contextMenu.marker.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onHideMarker(contextMenu.marker.data?.id)"
>
Hide marker {{ contextMenu.marker.data?.name }}
</button>
</div>
</template>
<script setup lang="ts">
import type { ContextMenuState } from '~/composables/useMapLogic'
defineProps<{
contextMenu: ContextMenuState
}>()
const emit = defineEmits<{
wipeTile: [coords: { x: number; y: number } | undefined]
rewriteCoords: [coords: { x: number; y: number } | undefined]
hideMarker: [id: number | undefined]
}>()
function onWipeTile(coords: { x: number; y: number } | undefined) {
if (coords) emit('wipeTile', coords)
}
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (coords) emit('rewriteCoords', coords)
}
function onHideMarker(id: number | undefined) {
if (id != null) emit('hideMarker', id)
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div
class="absolute left-3 top-[10%] z-[502] flex min-w-[3rem] min-h-[3rem] transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12' : 'w-64'"
>
<div
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden transition-all duration-300 flex flex-col"
:class="panelCollapsed ? 'w-12 items-center py-2' : 'w-56'"
>
<div v-show="!panelCollapsed" class="flex flex-col p-4 gap-4 flex-1 min-w-0">
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Zoom</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom in"
aria-label="Zoom in"
@click="$emit('zoomIn')"
>
<icons-icon-zoom-in />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom out"
aria-label="Zoom out"
@click="$emit('zoomOut')"
>
<icons-icon-zoom-out />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Reset view — center map and minimum zoom"
aria-label="Reset view"
@click="$emit('resetView')"
>
<icons-icon-home />
</button>
</div>
</section>
<!-- Display -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
<select v-model="selectedMarkerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
<select v-model="selectedPlayerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</section>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105"
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
@click.stop="panelCollapsed = !panelCollapsed"
>
<icons-icon-chevron-right v-if="panelCollapsed" />
<icons-icon-panel-left v-else />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
interface QuestGiver {
id: number
name: string
}
interface Player {
id: number
name: string
}
const props = withDefaults(
defineProps<{
maps: MapInfo[]
questGivers: QuestGiver[]
players: Player[]
}>(),
{ maps: () => [], questGivers: () => [], players: () => [] }
)
const emit = defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
}>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
const panelCollapsed = ref(false)
const selectedMapId = defineModel<number | null>('selectedMapId', { default: null })
const overlayMapId = defineModel<number>('overlayMapId', { default: -1 })
const selectedMarkerId = defineModel<number | null>('selectedMarkerId', { default: null })
const selectedPlayerId = defineModel<number | null>('selectedPlayerId', { default: null })
// String bindings for selects with placeholder ('' ↔ null)
const selectedMapIdSelect = computed({
get() {
const v = selectedMapId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedMapId.value = v === '' ? null : Number(v)
},
})
const selectedMarkerIdSelect = computed({
get() {
const v = selectedMarkerId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedMarkerId.value = v === '' ? null : Number(v)
},
})
const selectedPlayerIdSelect = computed({
get() {
const v = selectedPlayerId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedPlayerId.value = v === '' ? null : Number(v)
},
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')">
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2">
<input v-model.number="localTo.x" type="number" class="input input-bordered flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
</div>
<div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="button" class="btn" @click="$emit('close')">Cancel</button>
</form>
</div>
</div>
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="$emit('close')" />
</dialog>
</template>
<script setup lang="ts">
const props = defineProps<{
coordSetFrom: { x: number; y: number }
coordSet: { x: number; y: number }
open: boolean
}>()
const emit = defineEmits<{
close: []
submit: [from: { x: number; y: number }; to: { x: number; y: number }]
}>()
const modalRef = ref<HTMLDialogElement | null>(null)
const localTo = ref({ x: props.coordSet.x, y: props.coordSet.y })
watch(
() => props.open,
(open) => {
if (open) {
localTo.value = { x: props.coordSet.x, y: props.coordSet.y }
nextTick(() => modalRef.value?.showModal())
} else {
modalRef.value?.close()
}
},
{ immediate: true }
)
watch(
() => props.coordSet,
(c) => {
localTo.value = { x: c.x, y: c.y }
},
{ deep: true }
)
function onSubmit() {
emit('submit', props.coordSetFrom, localTo.value)
emit('close')
}
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div
v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow"
aria-label="Current grid position and zoom"
title="mapId · x, y · zoom"
>
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
</div>
</template>
<script setup lang="ts">
defineProps<{
mapid: number
displayCoords: { x: number; y: number; z: number } | null
}>()
</script>

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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) // Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
const onApiErrorCallbacks: (() => void)[] = [] const onApiErrorCallbacks = new Map<symbol, () => void>()
export function useMapApi() { export function useMapApi() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string const apiBase = config.public.apiBase as string
function onApiError(cb: () => void) { /** Subscribe to API auth errors (401). Returns unsubscribe function. */
onApiErrorCallbacks.push(cb) function onApiError(cb: () => void): () => void {
const id = Symbol()
onApiErrorCallbacks.set(id, cb)
return () => onApiErrorCallbacks.delete(id)
} }
async function request<T>(path: string, opts?: RequestInit): Promise<T> { async function request<T>(path: string, opts?: RequestInit): Promise<T> {
@@ -34,11 +45,11 @@ export function useMapApi() {
} }
async function getCharacters() { async function getCharacters() {
return request<unknown[]>('v1/characters') return request<Character[]>('v1/characters')
} }
async function getMarkers() { async function getMarkers() {
return request<unknown[]>('v1/markers') return request<Marker[]>('v1/markers')
} }
async function getMaps() { async function getMaps() {

View File

@@ -0,0 +1,157 @@
import type L from 'leaflet'
import { HnHMinZoom, HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
export interface MapLogicState {
showGridCoordinates: Ref<boolean>
hideMarkers: Ref<boolean>
panelCollapsed: Ref<boolean>
trackingCharacterId: Ref<number>
selectedMapId: Ref<number | null>
overlayMapId: Ref<number>
selectedMarkerId: Ref<number | null>
selectedPlayerId: Ref<number | null>
displayCoords: Ref<{ x: number; y: number; z: number } | null>
mapid: Ref<number>
}
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<number | null>(null)
const overlayMapId = ref(-1)
const selectedMarkerId = ref<number | null>(null)
const selectedPlayerId = ref<number | null>(null)
const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
const mapid = ref(0)
const contextMenu = reactive<ContextMenuState>({
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,
}
}

View File

@@ -28,3 +28,20 @@ export interface MapInfo {
Name: string Name: string
size?: number 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
}

1
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/andyleap/hnh-map
go 1.21 go 1.21
require ( require (
github.com/go-chi/chi/v5 v5.1.0
go.etcd.io/bbolt v1.3.3 go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 golang.org/x/image v0.0.0-20200119044424-58c23975cae1

2
go.sum
View File

@@ -1,4 +1,6 @@
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= 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= 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 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,34 @@ type App struct {
characters map[string]Character characters map[string]Character
chmu sync.RWMutex chmu sync.RWMutex
gridUpdates topic gridUpdates Topic[TileData]
mergeUpdates mergeTopic 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. // NewApp creates an App with the given storage paths and database.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,216 @@
package services
import (
"encoding/json"
"strconv"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
// AdminService handles admin business logic (users, settings, maps, wipe).
type AdminService struct {
st *store.Store
}
// NewAdminService creates an AdminService.
func NewAdminService(st *store.Store) *AdminService {
return &AdminService{st: st}
}
// ListUsers returns all usernames.
func (s *AdminService) ListUsers() ([]string, error) {
var list []string
err := s.st.View(func(tx *bbolt.Tx) error {
return s.st.ForEachUser(tx, func(k, _ []byte) error {
list = append(list, string(k))
return nil
})
})
return list, err
}
// GetUser returns user auths by username.
func (s *AdminService) GetUser(username string) (auths app.Auths, found bool) {
s.st.View(func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username)
if raw == nil {
return nil
}
var u app.User
json.Unmarshal(raw, &u)
auths = u.Auths
found = true
return nil
})
return auths, found
}
// CreateOrUpdateUser creates or updates a user.
// Returns (true, nil) when admin user was created and didn't exist before (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(func(tx *bbolt.Tx) error {
existed := s.st.GetUser(tx, username) != nil
u := app.User{}
raw := s.st.GetUser(tx, username)
if raw != nil {
json.Unmarshal(raw, &u)
}
if pass != "" {
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if e != nil {
return e
}
u.Pass = hash
}
u.Auths = auths
raw, _ = json.Marshal(u)
if e := s.st.PutUser(tx, username, raw); e != nil {
return e
}
if username == "admin" && !existed {
adminCreated = true
}
return nil
})
return adminCreated, err
}
// DeleteUser removes a user and their tokens.
func (s *AdminService) DeleteUser(username string) error {
return s.st.Update(func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw != nil {
var u app.User
json.Unmarshal(uRaw, &u)
for _, tok := range u.Tokens {
s.st.DeleteToken(tx, tok)
}
}
return s.st.DeleteUser(tx, username)
})
}
// GetSettings returns prefix, defaultHide, title.
func (s *AdminService) GetSettings() (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(func(tx *bbolt.Tx) error {
if v := s.st.GetConfig(tx, "prefix"); v != nil {
prefix = string(v)
}
if v := s.st.GetConfig(tx, "defaultHide"); v != nil {
defaultHide = true
}
if v := s.st.GetConfig(tx, "title"); v != nil {
title = string(v)
}
return nil
})
return prefix, defaultHide, title, err
}
// UpdateSettings updates config keys.
func (s *AdminService) UpdateSettings(prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(func(tx *bbolt.Tx) error {
if prefix != nil {
s.st.PutConfig(tx, "prefix", []byte(*prefix))
}
if defaultHide != nil {
if *defaultHide {
s.st.PutConfig(tx, "defaultHide", []byte("1"))
} else {
s.st.DeleteConfig(tx, "defaultHide")
}
}
if title != nil {
s.st.PutConfig(tx, "title", []byte(*title))
}
return nil
})
}
// ListMaps returns all maps.
func (s *AdminService) ListMaps() ([]app.MapInfo, error) {
var maps []app.MapInfo
err := s.st.View(func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error {
mi := app.MapInfo{}
json.Unmarshal(v, &mi)
if id, err := strconv.Atoi(string(k)); err == nil {
mi.ID = id
}
maps = append(maps, mi)
return nil
})
})
return maps, err
}
// GetMap returns a map by ID.
func (s *AdminService) GetMap(id int) (*app.MapInfo, bool) {
var mi *app.MapInfo
s.st.View(func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
if raw != nil {
mi = &app.MapInfo{}
json.Unmarshal(raw, mi)
mi.ID = id
}
return nil
})
return mi, mi != nil
}
// UpdateMap updates map name, hidden, priority.
func (s *AdminService) UpdateMap(id int, name string, hidden, priority bool) error {
return s.st.Update(func(tx *bbolt.Tx) error {
mi := app.MapInfo{}
raw := s.st.GetMap(tx, id)
if raw != nil {
json.Unmarshal(raw, &mi)
}
mi.ID = id
mi.Name = name
mi.Hidden = hidden
mi.Priority = priority
raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw)
})
}
// ToggleMapHidden flips the hidden flag.
func (s *AdminService) ToggleMapHidden(id int) (*app.MapInfo, error) {
var mi *app.MapInfo
err := s.st.Update(func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id)
mi = &app.MapInfo{}
if raw != nil {
json.Unmarshal(raw, mi)
}
mi.ID = id
mi.Hidden = !mi.Hidden
raw, _ = json.Marshal(mi)
return s.st.PutMap(tx, id, raw)
})
return mi, err
}
// Wipe deletes grids, markers, tiles, maps buckets.
func (s *AdminService) Wipe() error {
return s.st.Update(func(tx *bbolt.Tx) error {
for _, b := range [][]byte{
store.BucketGrids,
store.BucketMarkers,
store.BucketTiles,
store.BucketMaps,
} {
if s.st.BucketExists(tx, b) {
if err := s.st.DeleteBucket(tx, b); err != nil {
return err
}
}
}
return nil
})
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -2,18 +2,21 @@ package app
import "sync" import "sync"
type topic struct { // Topic is a generic pub/sub for broadcasting updates.
c []chan *TileData type Topic[T any] struct {
c []chan *T
mu sync.Mutex 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() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
t.c = append(t.c, c) 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() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
for i := 0; i < len(t.c); i++ { 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 { for _, c := range t.c {
close(c) close(c)
} }
@@ -38,35 +42,3 @@ type Merge struct {
From, To int From, To int
Shift Coord 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]
}