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:
11
.cursor/rules/backend-go.mdc
Normal file
11
.cursor/rules/backend-go.mdc
Normal 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.
|
||||
11
.cursor/rules/frontend-nuxt.mdc
Normal file
11
.cursor/rules/frontend-nuxt.mdc
Normal 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).
|
||||
12
.cursor/rules/project-conventions.mdc
Normal file
12
.cursor/rules/project-conventions.mdc
Normal 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
4
.gitignore
vendored
@@ -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
28
AGENTS.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
63
frontend-nuxt/components/map/MapContextMenu.vue
Normal file
63
frontend-nuxt/components/map/MapContextMenu.vue
Normal 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>
|
||||
167
frontend-nuxt/components/map/MapControls.vue
Normal file
167
frontend-nuxt/components/map/MapControls.vue
Normal 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>
|
||||
61
frontend-nuxt/components/map/MapCoordSetModal.vue
Normal file
61
frontend-nuxt/components/map/MapCoordSetModal.vue
Normal 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>
|
||||
17
frontend-nuxt/components/map/MapCoordsDisplay.vue
Normal file
17
frontend-nuxt/components/map/MapCoordsDisplay.vue
Normal 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>
|
||||
23
frontend-nuxt/composables/useAdminApi.ts
Normal file
23
frontend-nuxt/composables/useAdminApi.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
15
frontend-nuxt/composables/useAuth.ts
Normal file
15
frontend-nuxt/composables/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
157
frontend-nuxt/composables/useMapLogic.ts
Normal file
157
frontend-nuxt/composables/useMapLogic.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
1
go.mod
@@ -3,6 +3,7 @@ module github.com/andyleap/hnh-map
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
go.etcd.io/bbolt v1.3.3
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
|
||||
golang.org/x/image v0.0.0-20200119044424-58c23975cae1
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,4 +1,6 @@
|
||||
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
584
internal/app/handlers/api.go
Normal file
584
internal/app/handlers/api.go
Normal 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")
|
||||
}
|
||||
36
internal/app/handlers/handlers.go
Normal file
36
internal/app/handlers/handlers.go
Normal 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))
|
||||
}
|
||||
17
internal/app/handlers/response.go
Normal file
17
internal/app/handlers/response.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
25
internal/app/response/response.go
Normal file
25
internal/app/response/response.go
Normal 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
37
internal/app/router.go
Normal 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
|
||||
}
|
||||
216
internal/app/services/admin.go
Normal file
216
internal/app/services/admin.go
Normal 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
|
||||
})
|
||||
}
|
||||
242
internal/app/services/auth.go
Normal file
242
internal/app/services/auth.go
Normal 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)
|
||||
})
|
||||
}
|
||||
174
internal/app/services/map.go
Normal file
174
internal/app/services/map.go
Normal 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,
|
||||
})
|
||||
}
|
||||
20
internal/app/store/buckets.go
Normal file
20
internal/app/store/buckets.go
Normal 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
444
internal/app/store/db.go
Normal 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user