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

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.
In production the app serves static assets from the `frontend/` directory; that directory is the **build output** of the app in `frontend-nuxt/` (see [docs/development.md](docs/development.md)).
Only other thing you need to do is setup users and set your zero grid.
First login: username **admin**, password from `HNHMAP_BOOTSTRAP_PASSWORD` (in dev Compose it defaults to `admin`). Go to the admin portal and hit "ADD USER". Don't forget to toggle on all the roles (you'll need admin, at least)

View File

@@ -10,6 +10,9 @@ import (
"strconv"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/handlers"
"github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
)
@@ -54,12 +57,22 @@ func main() {
go a.CleanChars()
a.RegisterRoutes()
// Phase 3: store, services, handlers layers
st := store.New(db)
authSvc := services.NewAuthService(st)
mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st,
GridStorage: a.GridStorage(),
GridUpdates: a.GridUpdates(),
MergeUpdates: a.MergeUpdates(),
GetChars: a.GetCharacters,
})
adminSvc := services.NewAdminService(st)
h := handlers.New(a, authSvc, mapSvc, adminSvc)
// Static assets under /js/ (e.g. from public/)
publicDir := filepath.Join(workDir, "public")
http.Handle("/js/", http.FileServer(http.Dir(publicDir)))
r := a.Router(publicDir, h)
log.Printf("Listening on port %d", *port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), r))
}

View File

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

View File

@@ -1,21 +1,6 @@
<template>
<div class="absolute inset-0">
<ClientOnly>
<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>
<slot />
</div>
</template>

View File

@@ -1,5 +1,5 @@
<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
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"
@@ -14,160 +14,48 @@
</div>
</div>
<div ref="mapRef" class="map h-full w-full" />
<!-- Grid coords & zoom (bottom-right) -->
<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>
<!-- Control panel -->
<div
class="absolute left-3 top-[10%] z-[502] flex 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="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="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>
<MapMapCoordsDisplay
:mapid="mapLogic.state.mapid"
:display-coords="mapLogic.state.displayCoords"
/>
<MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates"
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
:hide-markers="mapLogic.state.hideMarkers"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
:overlay-map-id="mapLogic.state.overlayMapId.value"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
:selected-marker-id="mapLogic.state.selectedMarkerId.value"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
:selected-player-id="mapLogic.state.selectedPlayerId.value"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
:maps="maps"
:quest-givers="questGivers"
:players="players"
@zoom-in="mapLogic.zoomIn(map)"
@zoom-out="mapLogic.zoomOutControl(map)"
@reset-view="mapLogic.resetView(map)"
/>
<MapMapContextMenu
:context-menu="mapLogic.contextMenu"
@wipe-tile="onWipeTile"
@rewrite-coords="onRewriteCoords"
@hide-marker="onHideMarker"
/>
<MapMapCoordSetModal
:coord-set-from="mapLogic.coordSetFrom"
:coord-set="mapLogic.coordSet"
:open="mapLogic.coordSetModalOpen"
@close="mapLogic.closeCoordSetModal()"
@submit="onSubmitCoordSet"
/>
</div>
</template>
<script setup lang="ts">
import MapControls from '~/components/map/MapControls.vue'
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer'
import { Marker } from '~/lib/Marker'
@@ -187,31 +75,14 @@ const props = withDefaults(
)
const mapRef = ref<HTMLElement | null>(null)
const route = useRoute()
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 mapsLoaded = ref(false)
const selectedMapId = ref<number | null>(null)
const overlayMapId = ref<number>(-1)
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
const players = ref<{ id: number; name: string }[]>([])
const selectedMarkerId = ref<number | null>(null)
const selectedPlayerId = ref<number | null>(null)
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 layer: InstanceType<typeof SmartTileLayer> | null = null
@@ -220,7 +91,6 @@ let coordLayer: InstanceType<typeof GridCoordLayer> | null = null
let markerLayer: L.LayerGroup | null = null
let source: EventSource | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let mapid = 0
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
let characters: UniqueList<InstanceType<typeof Character>> | null = null
let markersHidden = false
@@ -231,10 +101,11 @@ function toLatLng(x: number, y: number) {
}
function changeMap(id: number) {
if (id === mapid) return
mapid = id
if (id === mapLogic.state.mapid.value) return
mapLogic.state.mapid.value = id
mapLogic.state.selectedMapId.value = id
if (layer) {
layer.map = mapid
layer.map = id
layer.redraw()
}
if (overlayLayer) {
@@ -242,71 +113,50 @@ function changeMap(id: number) {
overlayLayer.redraw()
}
if (markers && !markersHidden) {
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid }))
markers.getElements().filter((it: any) => it.map === mapid).forEach((it: any) => it.add({ 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 === id).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid: id }))
}
if (characters) {
characters.getElements().forEach((it: any) => {
it.remove({ map: map! })
it.add({ map: map!, mapid })
it.add({ map: map!, mapid: id })
})
}
}
function zoomIn() {
map?.zoomIn()
function onWipeTile(coords: { x: number; y: number } | undefined) {
if (!coords) return
mapLogic.closeContextMenus()
api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
}
function zoomOutControl() {
map?.zoomOut()
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (!coords) return
mapLogic.closeContextMenus()
mapLogic.openCoordSet(coords)
}
function zoomOut() {
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) {
function onHideMarker(id: number | undefined) {
if (id == null) return
mapLogic.closeContextMenus()
api.adminHideMarker({ 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() {
contextMenu.tile.show = false
contextMenu.marker.show = false
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
api.adminSetCoords({
map: mapLogic.state.mapid.value,
fx: from.x,
fy: from.y,
tx: to.x,
ty: to.y,
})
mapLogic.closeCoordSetModal()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeContextMenus()
if (e.key === 'Escape') mapLogic.closeContextMenus()
}
onMounted(async () => {
@@ -353,9 +203,8 @@ onMounted(async () => {
const initialMapId =
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 apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
@@ -412,14 +261,10 @@ onMounted(async () => {
if (auths.value.includes('admin')) {
const point = map!.project(mev.latlng, 6)
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
contextMenu.tile.show = true
contextMenu.tile.x = mev.originalEvent.clientX
contextMenu.tile.y = mev.originalEvent.clientY
contextMenu.tile.data = { coords }
mapLogic.openTileContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, 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 updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
source = new EventSource(updatesUrl)
@@ -437,28 +282,26 @@ onMounted(async () => {
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
}
} catch {
// Ignore parse errors (e.g. empty SSE message or non-JSON)
// Ignore parse errors
}
}
source.onerror = () => {
// Connection lost or 401; avoid uncaught errors
}
source.onerror = () => {}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge = JSON.parse(e?.data ?? '{}')
if (mapid === merge.From) {
const mapTo = merge.To
const point = map!.project(map!.getCenter(), 6)
const coordinate = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
z: map!.getZoom(),
if (mapLogic.state.mapid.value === merge.From) {
const mapTo = merge.To
const point = map!.project(map!.getCenter(), 6)
const coordinate = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
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 {
// Ignore merge parse errors
}
@@ -470,22 +313,21 @@ onMounted(async () => {
updateCharacters(charactersData as any[])
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) {
const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
if (mapid !== props.mapId) changeMap(props.mapId)
selectedMapId.value = props.mapId
if (mapLogic.state.mapid.value !== props.mapId) changeMap(props.mapId)
mapLogic.state.selectedMapId.value = props.mapId
map.setView(latLng, props.zoom, { animate: false })
} else if (mapsList.length > 0) {
const first = mapsList[0]
if (first) {
changeMap(first.ID)
selectedMapId.value = first.ID
mapLogic.state.selectedMapId.value = first.ID
map.setView([0, 0], HnHDefaultZoom, { animate: false })
}
}
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
@@ -503,18 +345,15 @@ onMounted(async () => {
function updateMarkers(markersData: any[]) {
if (!markers || !map || !markerLayer) return
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(
list.map((it) => new Marker(it)),
(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.setContextMenu((mev: L.LeafletMouseEvent) => {
if (auths.value.includes('admin')) {
contextMenu.marker.show = true
contextMenu.marker.x = mev.originalEvent.clientX
contextMenu.marker.y = mev.originalEvent.clientY
contextMenu.marker.data = { id: marker.id, name: marker.name }
mapLogic.openMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
}
})
},
@@ -527,17 +366,17 @@ onMounted(async () => {
function updateCharacters(charactersData: any[]) {
if (!characters || !map) return
const list = Array.isArray(charactersData) ? charactersData : []
const ctx = { map, mapid }
const ctx = { map, mapid: mapLogic.state.mapid.value }
characters.update(
list.map((it) => new Character(it)),
(character: InstanceType<typeof Character>) => {
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>, updated: any) => {
if (trackingCharacterId.value === updated.id) {
if (mapid !== updated.map) changeMap(updated.map)
if (mapLogic.state.trackingCharacterId.value === updated.id) {
if (mapLogic.state.mapid.value !== updated.map) changeMap(updated.map)
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom)
}
@@ -547,7 +386,7 @@ onMounted(async () => {
players.value = characters.getElements()
}
watch(showGridCoordinates, (v) => {
watch(mapLogic.state.showGridCoordinates, (v) => {
if (coordLayer) {
;(coordLayer.options as { visible?: boolean }).visible = v
coordLayer.setOpacity(v ? 1 : 0)
@@ -561,19 +400,19 @@ onMounted(async () => {
}
})
watch(hideMarkers, (v) => {
watch(mapLogic.state.hideMarkers, (v) => {
markersHidden = v
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) {
markers.getElements().forEach((it: any) => it.remove(ctx))
} else {
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
const character = characters?.byId(value)
if (character) {
@@ -583,59 +422,49 @@ onMounted(async () => {
autoMode = true
} else {
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
changeMap(value)
const zoom = map!.getZoom()
map!.setView([0, 0], zoom)
})
watch(overlayMapId, (value) => {
watch(mapLogic.state.overlayMapId, (value) => {
if (overlayLayer) overlayLayer.map = value ?? -1
overlayLayer?.redraw()
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().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
const marker = markers?.byId(value)
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
})
watch(selectedPlayerId, (value) => {
if (value != null) trackingCharacterId.value = value
watch(mapLogic.state.selectedPlayerId, (value) => {
if (value != null) mapLogic.state.trackingCharacterId.value = value
})
function updateDisplayCoords() {
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(),
}
}
map.on('moveend', updateDisplayCoords)
updateDisplayCoords()
map.on('moveend', () => mapLogic.updateDisplayCoords(map))
mapLogic.updateDisplayCoords(map)
map.on('zoomend', () => {
if (map) map.invalidateSize()
})
map.on('drag', () => {
trackingCharacterId.value = -1
mapLogic.state.trackingCharacterId.value = -1
})
map.on('zoom', () => {
if (autoMode) {
autoMode = false
} 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)
const onApiErrorCallbacks: (() => void)[] = []
const onApiErrorCallbacks = new Map<symbol, () => void>()
export function useMapApi() {
const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string
function onApiError(cb: () => void) {
onApiErrorCallbacks.push(cb)
/** Subscribe to API auth errors (401). Returns unsubscribe function. */
function onApiError(cb: () => void): () => void {
const id = Symbol()
onApiErrorCallbacks.set(id, cb)
return () => onApiErrorCallbacks.delete(id)
}
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
@@ -34,11 +45,11 @@ export function useMapApi() {
}
async function getCharacters() {
return request<unknown[]>('v1/characters')
return request<Character[]>('v1/characters')
}
async function getMarkers() {
return request<unknown[]>('v1/markers')
return request<Marker[]>('v1/markers')
}
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
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
require (
github.com/go-chi/chi/v5 v5.1.0
go.etcd.io/bbolt v1.3.3
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
golang.org/x/image v0.0.0-20200119044424-58c23975cae1

2
go.sum
View File

@@ -1,4 +1,6 @@
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,34 @@ type App struct {
characters map[string]Character
chmu sync.RWMutex
gridUpdates topic
mergeUpdates mergeTopic
gridUpdates Topic[TileData]
mergeUpdates Topic[Merge]
}
// GridStorage returns the grid storage path.
func (a *App) GridStorage() string {
return a.gridStorage
}
// GridUpdates returns the tile updates topic for MapService.
func (a *App) GridUpdates() *Topic[TileData] {
return &a.gridUpdates
}
// MergeUpdates returns the merge updates topic for MapService.
func (a *App) MergeUpdates() *Topic[Merge] {
return &a.mergeUpdates
}
// GetCharacters returns a copy of all characters (for MapService).
func (a *App) GetCharacters() []Character {
a.chmu.RLock()
defer a.chmu.RUnlock()
chars := make([]Character, 0, len(a.characters))
for _, v := range a.characters {
chars = append(chars, v)
}
return chars
}
// NewApp creates an App with the given storage paths and database.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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