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/node_modules
|
||||||
frontend-nuxt/.nuxt
|
frontend-nuxt/.nuxt
|
||||||
frontend-nuxt/.output
|
frontend-nuxt/.output
|
||||||
|
node_modules
|
||||||
|
|
||||||
# Old Vue 2 frontend (if present)
|
# Old Vue 2 frontend (if present)
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
@@ -22,5 +23,6 @@ frontend/dist
|
|||||||
|
|
||||||
# OS / IDE
|
# OS / IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursor/
|
.cursor/*
|
||||||
|
!.cursor/rules/
|
||||||
.vscode/
|
.vscode/
|
||||||
28
AGENTS.md
Normal file
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.
|
See also [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow.
|
||||||
|
|
||||||
|
In production the app serves static assets from the `frontend/` directory; that directory is the **build output** of the app in `frontend-nuxt/` (see [docs/development.md](docs/development.md)).
|
||||||
|
|
||||||
Only other thing you need to do is setup users and set your zero grid.
|
Only other thing you need to do is setup users and set your zero grid.
|
||||||
|
|
||||||
First login: username **admin**, password from `HNHMAP_BOOTSTRAP_PASSWORD` (in dev Compose it defaults to `admin`). Go to the admin portal and hit "ADD USER". Don't forget to toggle on all the roles (you'll need admin, at least)
|
First login: username **admin**, password from `HNHMAP_BOOTSTRAP_PASSWORD` (in dev Compose it defaults to `admin`). Go to the admin portal and hit "ADD USER". Don't forget to toggle on all the roles (you'll need admin, at least)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/andyleap/hnh-map/internal/app"
|
"github.com/andyleap/hnh-map/internal/app"
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/handlers"
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/services"
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
@@ -54,12 +57,22 @@ func main() {
|
|||||||
|
|
||||||
go a.CleanChars()
|
go a.CleanChars()
|
||||||
|
|
||||||
a.RegisterRoutes()
|
// Phase 3: store, services, handlers layers
|
||||||
|
st := store.New(db)
|
||||||
|
authSvc := services.NewAuthService(st)
|
||||||
|
mapSvc := services.NewMapService(services.MapServiceDeps{
|
||||||
|
Store: st,
|
||||||
|
GridStorage: a.GridStorage(),
|
||||||
|
GridUpdates: a.GridUpdates(),
|
||||||
|
MergeUpdates: a.MergeUpdates(),
|
||||||
|
GetChars: a.GetCharacters,
|
||||||
|
})
|
||||||
|
adminSvc := services.NewAdminService(st)
|
||||||
|
h := handlers.New(a, authSvc, mapSvc, adminSvc)
|
||||||
|
|
||||||
// Static assets under /js/ (e.g. from public/)
|
|
||||||
publicDir := filepath.Join(workDir, "public")
|
publicDir := filepath.Join(workDir, "public")
|
||||||
http.Handle("/js/", http.FileServer(http.Dir(publicDir)))
|
r := a.Router(publicDir, h)
|
||||||
|
|
||||||
log.Printf("Listening on port %d", *port)
|
log.Printf("Listening on port %d", *port)
|
||||||
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
|
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), r))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
// Global error handling: on API auth failure, redirect to login
|
// Global error handling: on API auth failure, redirect to login
|
||||||
const { onApiError } = useMapApi()
|
const { onApiError } = useMapApi()
|
||||||
const { fullUrl } = useAppPaths()
|
const { fullUrl } = useAppPaths()
|
||||||
onApiError(() => {
|
const unsubscribe = onApiError(() => {
|
||||||
if (import.meta.client) window.location.href = fullUrl('/login')
|
if (import.meta.client) window.location.href = fullUrl('/login')
|
||||||
})
|
})
|
||||||
|
onUnmounted(() => unsubscribe())
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="absolute inset-0">
|
<div class="absolute inset-0">
|
||||||
<ClientOnly>
|
<slot />
|
||||||
<div class="absolute inset-0">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="h-screen flex flex-col items-center justify-center gap-4 bg-base-200">
|
|
||||||
<span class="loading loading-spinner loading-lg text-primary" />
|
|
||||||
<p class="text-base-content/80 font-medium">Loading map…</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="w-24 h-3 rounded bg-base-300 animate-pulse" />
|
|
||||||
<div class="w-32 h-3 rounded bg-base-300 animate-pulse" />
|
|
||||||
<div class="w-20 h-3 rounded bg-base-300 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative h-full w-full" @click="contextMenu.tile.show = false; contextMenu.marker.show = false">
|
<div class="relative h-full w-full" @click="mapLogic.closeContextMenus()">
|
||||||
<div
|
<div
|
||||||
v-if="mapsLoaded && maps.length === 0"
|
v-if="mapsLoaded && maps.length === 0"
|
||||||
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
|
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
|
||||||
@@ -14,160 +14,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="mapRef" class="map h-full w-full" />
|
<div ref="mapRef" class="map h-full w-full" />
|
||||||
<!-- Grid coords & zoom (bottom-right) -->
|
<MapMapCoordsDisplay
|
||||||
<div
|
:mapid="mapLogic.state.mapid"
|
||||||
v-if="displayCoords"
|
:display-coords="mapLogic.state.displayCoords"
|
||||||
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow"
|
/>
|
||||||
aria-label="Current grid position and zoom"
|
<MapControls
|
||||||
title="mapId · x, y · zoom"
|
:show-grid-coordinates="mapLogic.state.showGridCoordinates"
|
||||||
>
|
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
|
||||||
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
|
:hide-markers="mapLogic.state.hideMarkers"
|
||||||
</div>
|
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
|
||||||
<!-- Control panel -->
|
:selected-map-id="mapLogic.state.selectedMapId.value"
|
||||||
<div
|
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
|
||||||
class="absolute left-3 top-[10%] z-[502] flex transition-all duration-300 ease-out"
|
:overlay-map-id="mapLogic.state.overlayMapId.value"
|
||||||
:class="panelCollapsed ? 'w-12' : 'w-64'"
|
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
|
||||||
>
|
:selected-marker-id="mapLogic.state.selectedMarkerId.value"
|
||||||
<div
|
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
|
||||||
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden transition-all duration-300 flex flex-col"
|
:selected-player-id="mapLogic.state.selectedPlayerId.value"
|
||||||
:class="panelCollapsed ? 'w-12 items-center py-2' : 'w-56'"
|
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
|
||||||
>
|
:maps="maps"
|
||||||
<div v-show="!panelCollapsed" class="flex flex-col p-4 gap-4 flex-1 min-w-0">
|
:quest-givers="questGivers"
|
||||||
<!-- Zoom -->
|
:players="players"
|
||||||
<section class="flex flex-col gap-2">
|
@zoom-in="mapLogic.zoomIn(map)"
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Zoom</h3>
|
@zoom-out="mapLogic.zoomOutControl(map)"
|
||||||
<div class="flex items-center gap-2">
|
@reset-view="mapLogic.resetView(map)"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<MapMapContextMenu
|
||||||
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
|
:context-menu="mapLogic.contextMenu"
|
||||||
title="Zoom in"
|
@wipe-tile="onWipeTile"
|
||||||
aria-label="Zoom in"
|
@rewrite-coords="onRewriteCoords"
|
||||||
@click="zoomIn"
|
@hide-marker="onHideMarker"
|
||||||
>
|
/>
|
||||||
<icons-icon-zoom-in />
|
<MapMapCoordSetModal
|
||||||
</button>
|
:coord-set-from="mapLogic.coordSetFrom"
|
||||||
<button
|
:coord-set="mapLogic.coordSet"
|
||||||
type="button"
|
:open="mapLogic.coordSetModalOpen"
|
||||||
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
|
@close="mapLogic.closeCoordSetModal()"
|
||||||
title="Zoom out"
|
@submit="onSubmitCoordSet"
|
||||||
aria-label="Zoom out"
|
/>
|
||||||
@click="zoomOutControl"
|
|
||||||
>
|
|
||||||
<icons-icon-zoom-out />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
|
|
||||||
title="Reset view — center map and minimum zoom"
|
|
||||||
aria-label="Reset view"
|
|
||||||
@click="zoomOut"
|
|
||||||
>
|
|
||||||
<icons-icon-home />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<!-- Display -->
|
|
||||||
<section class="flex flex-col gap-2">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
|
|
||||||
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
|
|
||||||
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
|
|
||||||
<span class="label-text">Show grid coordinates</span>
|
|
||||||
</label>
|
|
||||||
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
|
|
||||||
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
|
|
||||||
<span class="label-text">Hide markers</span>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
<!-- Navigation -->
|
|
||||||
<section class="flex flex-col gap-3">
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
|
|
||||||
<select v-model="selectedMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
|
||||||
<option :value="null">Select map</option>
|
|
||||||
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
|
|
||||||
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
|
||||||
<option :value="-1">None</option>
|
|
||||||
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
|
|
||||||
<select v-model="selectedMarkerId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
|
||||||
<option :value="null">Select quest giver</option>
|
|
||||||
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
|
|
||||||
<select v-model="selectedPlayerId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
|
||||||
<option :value="null">Select player</option>
|
|
||||||
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105"
|
|
||||||
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
|
||||||
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
|
||||||
@click="panelCollapsed = !panelCollapsed"
|
|
||||||
>
|
|
||||||
<icons-icon-chevron-right v-if="panelCollapsed" />
|
|
||||||
<icons-icon-panel-left v-else />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Context menu (tile) -->
|
|
||||||
<div
|
|
||||||
v-show="contextMenu.tile.show"
|
|
||||||
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
|
|
||||||
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
|
|
||||||
>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.tile.data && (wipeTile(contextMenu.tile.data), (contextMenu.tile.show = false))">
|
|
||||||
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.tile.data && (openCoordSet(contextMenu.tile.data), (contextMenu.tile.show = false))">
|
|
||||||
Rewrite tile coords
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Context menu (marker) -->
|
|
||||||
<div
|
|
||||||
v-show="contextMenu.marker.show"
|
|
||||||
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
|
|
||||||
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
|
|
||||||
>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.marker.data?.id != null && (hideMarkerById(contextMenu.marker.data.id), (contextMenu.marker.show = false))">
|
|
||||||
Hide marker {{ contextMenu.marker.data?.name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Coord-set modal: close via Cancel, backdrop click, or Escape -->
|
|
||||||
<dialog ref="coordSetModal" class="modal" @cancel="closeCoordSetModal">
|
|
||||||
<div class="modal-box transition-all duration-200" @click.stop>
|
|
||||||
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
|
|
||||||
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input v-model.number="coordSet.x" type="number" class="input input-bordered flex-1" placeholder="X" />
|
|
||||||
<input v-model.number="coordSet.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
|
|
||||||
</div>
|
|
||||||
<div class="modal-action">
|
|
||||||
<form method="dialog" @submit="submitCoordSet">
|
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
|
||||||
<button type="button" class="btn" @click="closeCoordSetModal">Cancel</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="closeCoordSetModal" />
|
|
||||||
</dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import MapControls from '~/components/map/MapControls.vue'
|
||||||
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||||
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||||
import { Marker } from '~/lib/Marker'
|
import { Marker } from '~/lib/Marker'
|
||||||
@@ -187,31 +75,14 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const mapRef = ref<HTMLElement | null>(null)
|
const mapRef = ref<HTMLElement | null>(null)
|
||||||
const route = useRoute()
|
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
|
const mapLogic = useMapLogic()
|
||||||
|
|
||||||
const showGridCoordinates = ref(false)
|
|
||||||
const hideMarkers = ref(false)
|
|
||||||
const panelCollapsed = ref(false)
|
|
||||||
const trackingCharacterId = ref(-1)
|
|
||||||
const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
|
const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
|
||||||
const mapsLoaded = ref(false)
|
const mapsLoaded = ref(false)
|
||||||
const selectedMapId = ref<number | null>(null)
|
|
||||||
const overlayMapId = ref<number>(-1)
|
|
||||||
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
|
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
|
||||||
const players = ref<{ id: number; name: string }[]>([])
|
const players = ref<{ id: number; name: string }[]>([])
|
||||||
const selectedMarkerId = ref<number | null>(null)
|
|
||||||
const selectedPlayerId = ref<number | null>(null)
|
|
||||||
const auths = ref<string[]>([])
|
const auths = ref<string[]>([])
|
||||||
const coordSetFrom = ref({ x: 0, y: 0 })
|
|
||||||
const coordSet = ref({ x: 0, y: 0 })
|
|
||||||
const coordSetModal = ref<HTMLDialogElement | null>(null)
|
|
||||||
const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
|
|
||||||
|
|
||||||
const contextMenu = reactive({
|
|
||||||
tile: { show: false, x: 0, y: 0, data: null as { coords: { x: number; y: number } } | null },
|
|
||||||
marker: { show: false, x: 0, y: 0, data: null as { id: number; name: string } | null },
|
|
||||||
})
|
|
||||||
|
|
||||||
let map: L.Map | null = null
|
let map: L.Map | null = null
|
||||||
let layer: InstanceType<typeof SmartTileLayer> | null = null
|
let layer: InstanceType<typeof SmartTileLayer> | null = null
|
||||||
@@ -220,7 +91,6 @@ let coordLayer: InstanceType<typeof GridCoordLayer> | null = null
|
|||||||
let markerLayer: L.LayerGroup | null = null
|
let markerLayer: L.LayerGroup | null = null
|
||||||
let source: EventSource | null = null
|
let source: EventSource | null = null
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let mapid = 0
|
|
||||||
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
|
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
|
||||||
let characters: UniqueList<InstanceType<typeof Character>> | null = null
|
let characters: UniqueList<InstanceType<typeof Character>> | null = null
|
||||||
let markersHidden = false
|
let markersHidden = false
|
||||||
@@ -231,10 +101,11 @@ function toLatLng(x: number, y: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changeMap(id: number) {
|
function changeMap(id: number) {
|
||||||
if (id === mapid) return
|
if (id === mapLogic.state.mapid.value) return
|
||||||
mapid = id
|
mapLogic.state.mapid.value = id
|
||||||
|
mapLogic.state.selectedMapId.value = id
|
||||||
if (layer) {
|
if (layer) {
|
||||||
layer.map = mapid
|
layer.map = id
|
||||||
layer.redraw()
|
layer.redraw()
|
||||||
}
|
}
|
||||||
if (overlayLayer) {
|
if (overlayLayer) {
|
||||||
@@ -242,71 +113,50 @@ function changeMap(id: number) {
|
|||||||
overlayLayer.redraw()
|
overlayLayer.redraw()
|
||||||
}
|
}
|
||||||
if (markers && !markersHidden) {
|
if (markers && !markersHidden) {
|
||||||
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid }))
|
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid: id }))
|
||||||
markers.getElements().filter((it: any) => it.map === mapid).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid }))
|
markers.getElements().filter((it: any) => it.map === id).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid: id }))
|
||||||
}
|
}
|
||||||
if (characters) {
|
if (characters) {
|
||||||
characters.getElements().forEach((it: any) => {
|
characters.getElements().forEach((it: any) => {
|
||||||
it.remove({ map: map! })
|
it.remove({ map: map! })
|
||||||
it.add({ map: map!, mapid })
|
it.add({ map: map!, mapid: id })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomIn() {
|
function onWipeTile(coords: { x: number; y: number } | undefined) {
|
||||||
map?.zoomIn()
|
if (!coords) return
|
||||||
|
mapLogic.closeContextMenus()
|
||||||
|
api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomOutControl() {
|
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
|
||||||
map?.zoomOut()
|
if (!coords) return
|
||||||
|
mapLogic.closeContextMenus()
|
||||||
|
mapLogic.openCoordSet(coords)
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomOut() {
|
function onHideMarker(id: number | undefined) {
|
||||||
trackingCharacterId.value = -1
|
|
||||||
map?.setView([0, 0], HnHMinZoom, { animate: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
function wipeTile(data: { coords: { x: number; y: number } }) {
|
|
||||||
if (!data?.coords) return
|
|
||||||
api.adminWipeTile({ map: mapid, x: data.coords.x, y: data.coords.y })
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCoordSetModal() {
|
|
||||||
coordSetModal.value?.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCoordSet(data: { coords: { x: number; y: number } }) {
|
|
||||||
if (!data?.coords) return
|
|
||||||
coordSetFrom.value = { ...data.coords }
|
|
||||||
coordSet.value = { x: data.coords.x, y: data.coords.y }
|
|
||||||
coordSetModal.value?.showModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitCoordSet() {
|
|
||||||
api.adminSetCoords({
|
|
||||||
map: mapid,
|
|
||||||
fx: coordSetFrom.value.x,
|
|
||||||
fy: coordSetFrom.value.y,
|
|
||||||
tx: coordSet.value.x,
|
|
||||||
ty: coordSet.value.y,
|
|
||||||
})
|
|
||||||
coordSetModal.value?.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideMarkerById(id: number) {
|
|
||||||
if (id == null) return
|
if (id == null) return
|
||||||
|
mapLogic.closeContextMenus()
|
||||||
api.adminHideMarker({ id })
|
api.adminHideMarker({ id })
|
||||||
const m = markers?.byId(id)
|
const m = markers?.byId(id)
|
||||||
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid })
|
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeContextMenus() {
|
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
|
||||||
contextMenu.tile.show = false
|
api.adminSetCoords({
|
||||||
contextMenu.marker.show = false
|
map: mapLogic.state.mapid.value,
|
||||||
|
fx: from.x,
|
||||||
|
fy: from.y,
|
||||||
|
tx: to.x,
|
||||||
|
ty: to.y,
|
||||||
|
})
|
||||||
|
mapLogic.closeCoordSetModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') closeContextMenus()
|
if (e.key === 'Escape') mapLogic.closeContextMenus()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -353,9 +203,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const initialMapId =
|
const initialMapId =
|
||||||
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
|
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
|
||||||
mapid = initialMapId
|
mapLogic.state.mapid.value = initialMapId
|
||||||
|
|
||||||
// Tiles are served at /map/grids/ (backend path, not SPA baseURL)
|
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
|
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
|
||||||
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
|
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
|
||||||
@@ -412,14 +261,10 @@ onMounted(async () => {
|
|||||||
if (auths.value.includes('admin')) {
|
if (auths.value.includes('admin')) {
|
||||||
const point = map!.project(mev.latlng, 6)
|
const point = map!.project(mev.latlng, 6)
|
||||||
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
|
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
|
||||||
contextMenu.tile.show = true
|
mapLogic.openTileContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, coords)
|
||||||
contextMenu.tile.x = mev.originalEvent.clientX
|
|
||||||
contextMenu.tile.y = mev.originalEvent.clientY
|
|
||||||
contextMenu.tile.data = { coords }
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// SSE is at /map/updates (backend path, not SPA baseURL). Same origin so it connects to correct host/port.
|
|
||||||
const updatesPath = `${backendBase}/updates`
|
const updatesPath = `${backendBase}/updates`
|
||||||
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
|
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
|
||||||
source = new EventSource(updatesUrl)
|
source = new EventSource(updatesUrl)
|
||||||
@@ -437,28 +282,26 @@ onMounted(async () => {
|
|||||||
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
|
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors (e.g. empty SSE message or non-JSON)
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
source.onerror = () => {
|
source.onerror = () => {}
|
||||||
// Connection lost or 401; avoid uncaught errors
|
|
||||||
}
|
|
||||||
source.addEventListener('merge', (e: MessageEvent) => {
|
source.addEventListener('merge', (e: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const merge = JSON.parse(e?.data ?? '{}')
|
const merge = JSON.parse(e?.data ?? '{}')
|
||||||
if (mapid === merge.From) {
|
if (mapLogic.state.mapid.value === merge.From) {
|
||||||
const mapTo = merge.To
|
const mapTo = merge.To
|
||||||
const point = map!.project(map!.getCenter(), 6)
|
const point = map!.project(map!.getCenter(), 6)
|
||||||
const coordinate = {
|
const coordinate = {
|
||||||
x: Math.floor(point.x / TileSize) + merge.Shift.x,
|
x: Math.floor(point.x / TileSize) + merge.Shift.x,
|
||||||
y: Math.floor(point.y / TileSize) + merge.Shift.y,
|
y: Math.floor(point.y / TileSize) + merge.Shift.y,
|
||||||
z: map!.getZoom(),
|
z: map!.getZoom(),
|
||||||
|
}
|
||||||
|
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
|
||||||
|
changeMap(mapTo)
|
||||||
|
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
|
||||||
|
map!.setView(latLng, map!.getZoom())
|
||||||
}
|
}
|
||||||
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
|
|
||||||
changeMap(mapTo)
|
|
||||||
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
|
|
||||||
map!.setView(latLng, map!.getZoom())
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore merge parse errors
|
// Ignore merge parse errors
|
||||||
}
|
}
|
||||||
@@ -470,22 +313,21 @@ onMounted(async () => {
|
|||||||
updateCharacters(charactersData as any[])
|
updateCharacters(charactersData as any[])
|
||||||
|
|
||||||
if (props.characterId !== undefined && props.characterId >= 0) {
|
if (props.characterId !== undefined && props.characterId >= 0) {
|
||||||
trackingCharacterId.value = props.characterId
|
mapLogic.state.trackingCharacterId.value = props.characterId
|
||||||
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
|
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
|
||||||
const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
|
const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
|
||||||
if (mapid !== props.mapId) changeMap(props.mapId)
|
if (mapLogic.state.mapid.value !== props.mapId) changeMap(props.mapId)
|
||||||
selectedMapId.value = props.mapId
|
mapLogic.state.selectedMapId.value = props.mapId
|
||||||
map.setView(latLng, props.zoom, { animate: false })
|
map.setView(latLng, props.zoom, { animate: false })
|
||||||
} else if (mapsList.length > 0) {
|
} else if (mapsList.length > 0) {
|
||||||
const first = mapsList[0]
|
const first = mapsList[0]
|
||||||
if (first) {
|
if (first) {
|
||||||
changeMap(first.ID)
|
changeMap(first.ID)
|
||||||
selectedMapId.value = first.ID
|
mapLogic.state.selectedMapId.value = first.ID
|
||||||
map.setView([0, 0], HnHDefaultZoom, { animate: false })
|
map.setView([0, 0], HnHDefaultZoom, { animate: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -503,18 +345,15 @@ onMounted(async () => {
|
|||||||
function updateMarkers(markersData: any[]) {
|
function updateMarkers(markersData: any[]) {
|
||||||
if (!markers || !map || !markerLayer) return
|
if (!markers || !map || !markerLayer) return
|
||||||
const list = Array.isArray(markersData) ? markersData : []
|
const list = Array.isArray(markersData) ? markersData : []
|
||||||
const ctx = { map, markerLayer, mapid, overlayLayer, auths: auths.value }
|
const ctx = { map, markerLayer, mapid: mapLogic.state.mapid.value, overlayLayer, auths: auths.value }
|
||||||
markers.update(
|
markers.update(
|
||||||
list.map((it) => new Marker(it)),
|
list.map((it) => new Marker(it)),
|
||||||
(marker: InstanceType<typeof Marker>) => {
|
(marker: InstanceType<typeof Marker>) => {
|
||||||
if (marker.map === mapid || marker.map === overlayLayer?.map) marker.add(ctx)
|
if (marker.map === mapLogic.state.mapid.value || marker.map === overlayLayer?.map) marker.add(ctx)
|
||||||
marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom))
|
marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom))
|
||||||
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
|
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
|
||||||
if (auths.value.includes('admin')) {
|
if (auths.value.includes('admin')) {
|
||||||
contextMenu.marker.show = true
|
mapLogic.openMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
|
||||||
contextMenu.marker.x = mev.originalEvent.clientX
|
|
||||||
contextMenu.marker.y = mev.originalEvent.clientY
|
|
||||||
contextMenu.marker.data = { id: marker.id, name: marker.name }
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -527,17 +366,17 @@ onMounted(async () => {
|
|||||||
function updateCharacters(charactersData: any[]) {
|
function updateCharacters(charactersData: any[]) {
|
||||||
if (!characters || !map) return
|
if (!characters || !map) return
|
||||||
const list = Array.isArray(charactersData) ? charactersData : []
|
const list = Array.isArray(charactersData) ? charactersData : []
|
||||||
const ctx = { map, mapid }
|
const ctx = { map, mapid: mapLogic.state.mapid.value }
|
||||||
characters.update(
|
characters.update(
|
||||||
list.map((it) => new Character(it)),
|
list.map((it) => new Character(it)),
|
||||||
(character: InstanceType<typeof Character>) => {
|
(character: InstanceType<typeof Character>) => {
|
||||||
character.add(ctx)
|
character.add(ctx)
|
||||||
character.setClickCallback(() => (trackingCharacterId.value = character.id))
|
character.setClickCallback(() => (mapLogic.state.trackingCharacterId.value = character.id))
|
||||||
},
|
},
|
||||||
(character: InstanceType<typeof Character>) => character.remove(ctx),
|
(character: InstanceType<typeof Character>) => character.remove(ctx),
|
||||||
(character: InstanceType<typeof Character>, updated: any) => {
|
(character: InstanceType<typeof Character>, updated: any) => {
|
||||||
if (trackingCharacterId.value === updated.id) {
|
if (mapLogic.state.trackingCharacterId.value === updated.id) {
|
||||||
if (mapid !== updated.map) changeMap(updated.map)
|
if (mapLogic.state.mapid.value !== updated.map) changeMap(updated.map)
|
||||||
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||||
map!.setView(latlng, HnHMaxZoom)
|
map!.setView(latlng, HnHMaxZoom)
|
||||||
}
|
}
|
||||||
@@ -547,7 +386,7 @@ onMounted(async () => {
|
|||||||
players.value = characters.getElements()
|
players.value = characters.getElements()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(showGridCoordinates, (v) => {
|
watch(mapLogic.state.showGridCoordinates, (v) => {
|
||||||
if (coordLayer) {
|
if (coordLayer) {
|
||||||
;(coordLayer.options as { visible?: boolean }).visible = v
|
;(coordLayer.options as { visible?: boolean }).visible = v
|
||||||
coordLayer.setOpacity(v ? 1 : 0)
|
coordLayer.setOpacity(v ? 1 : 0)
|
||||||
@@ -561,19 +400,19 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(hideMarkers, (v) => {
|
watch(mapLogic.state.hideMarkers, (v) => {
|
||||||
markersHidden = v
|
markersHidden = v
|
||||||
if (!markers) return
|
if (!markers) return
|
||||||
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer }
|
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
|
||||||
if (v) {
|
if (v) {
|
||||||
markers.getElements().forEach((it: any) => it.remove(ctx))
|
markers.getElements().forEach((it: any) => it.remove(ctx))
|
||||||
} else {
|
} else {
|
||||||
markers.getElements().forEach((it: any) => it.remove(ctx))
|
markers.getElements().forEach((it: any) => it.remove(ctx))
|
||||||
markers.getElements().filter((it: any) => it.map === mapid || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
|
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(trackingCharacterId, (value) => {
|
watch(mapLogic.state.trackingCharacterId, (value) => {
|
||||||
if (value === -1) return
|
if (value === -1) return
|
||||||
const character = characters?.byId(value)
|
const character = characters?.byId(value)
|
||||||
if (character) {
|
if (character) {
|
||||||
@@ -583,59 +422,49 @@ onMounted(async () => {
|
|||||||
autoMode = true
|
autoMode = true
|
||||||
} else {
|
} else {
|
||||||
map!.setView([0, 0], HnHMinZoom)
|
map!.setView([0, 0], HnHMinZoom)
|
||||||
trackingCharacterId.value = -1
|
mapLogic.state.trackingCharacterId.value = -1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedMapId, (value) => {
|
watch(mapLogic.state.selectedMapId, (value) => {
|
||||||
if (value == null) return
|
if (value == null) return
|
||||||
changeMap(value)
|
changeMap(value)
|
||||||
const zoom = map!.getZoom()
|
const zoom = map!.getZoom()
|
||||||
map!.setView([0, 0], zoom)
|
map!.setView([0, 0], zoom)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(overlayMapId, (value) => {
|
watch(mapLogic.state.overlayMapId, (value) => {
|
||||||
if (overlayLayer) overlayLayer.map = value ?? -1
|
if (overlayLayer) overlayLayer.map = value ?? -1
|
||||||
overlayLayer?.redraw()
|
overlayLayer?.redraw()
|
||||||
if (!markers) return
|
if (!markers) return
|
||||||
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer }
|
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
|
||||||
markers.getElements().forEach((it: any) => it.remove(ctx))
|
markers.getElements().forEach((it: any) => it.remove(ctx))
|
||||||
markers.getElements().filter((it: any) => it.map === mapid || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
|
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedMarkerId, (value) => {
|
watch(mapLogic.state.selectedMarkerId, (value) => {
|
||||||
if (value == null) return
|
if (value == null) return
|
||||||
const marker = markers?.byId(value)
|
const marker = markers?.byId(value)
|
||||||
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
|
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedPlayerId, (value) => {
|
watch(mapLogic.state.selectedPlayerId, (value) => {
|
||||||
if (value != null) trackingCharacterId.value = value
|
if (value != null) mapLogic.state.trackingCharacterId.value = value
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateDisplayCoords() {
|
map.on('moveend', () => mapLogic.updateDisplayCoords(map))
|
||||||
if (!map) return
|
mapLogic.updateDisplayCoords(map)
|
||||||
const point = map.project(map.getCenter(), 6)
|
|
||||||
displayCoords.value = {
|
|
||||||
x: Math.floor(point.x / TileSize),
|
|
||||||
y: Math.floor(point.y / TileSize),
|
|
||||||
z: map.getZoom(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
map.on('moveend', updateDisplayCoords)
|
|
||||||
updateDisplayCoords()
|
|
||||||
map.on('zoomend', () => {
|
map.on('zoomend', () => {
|
||||||
if (map) map.invalidateSize()
|
if (map) map.invalidateSize()
|
||||||
})
|
})
|
||||||
map.on('drag', () => {
|
map.on('drag', () => {
|
||||||
trackingCharacterId.value = -1
|
mapLogic.state.trackingCharacterId.value = -1
|
||||||
})
|
})
|
||||||
map.on('zoom', () => {
|
map.on('zoom', () => {
|
||||||
if (autoMode) {
|
if (autoMode) {
|
||||||
autoMode = false
|
autoMode = false
|
||||||
} else {
|
} else {
|
||||||
trackingCharacterId.value = -1
|
mapLogic.state.trackingCharacterId.value = -1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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)
|
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
|
||||||
const onApiErrorCallbacks: (() => void)[] = []
|
const onApiErrorCallbacks = new Map<symbol, () => void>()
|
||||||
|
|
||||||
export function useMapApi() {
|
export function useMapApi() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const apiBase = config.public.apiBase as string
|
const apiBase = config.public.apiBase as string
|
||||||
|
|
||||||
function onApiError(cb: () => void) {
|
/** Subscribe to API auth errors (401). Returns unsubscribe function. */
|
||||||
onApiErrorCallbacks.push(cb)
|
function onApiError(cb: () => void): () => void {
|
||||||
|
const id = Symbol()
|
||||||
|
onApiErrorCallbacks.set(id, cb)
|
||||||
|
return () => onApiErrorCallbacks.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
|
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||||
@@ -34,11 +45,11 @@ export function useMapApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getCharacters() {
|
async function getCharacters() {
|
||||||
return request<unknown[]>('v1/characters')
|
return request<Character[]>('v1/characters')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMarkers() {
|
async function getMarkers() {
|
||||||
return request<unknown[]>('v1/markers')
|
return request<Marker[]>('v1/markers')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMaps() {
|
async function getMaps() {
|
||||||
|
|||||||
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
|
Name: string
|
||||||
size?: number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Character {
|
||||||
|
name: string
|
||||||
|
id: number
|
||||||
|
map: number
|
||||||
|
position: { x: number; y: number }
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Marker {
|
||||||
|
name: string
|
||||||
|
id: number
|
||||||
|
map: number
|
||||||
|
position: { x: number; y: number }
|
||||||
|
image: string
|
||||||
|
hidden: boolean
|
||||||
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ module github.com/andyleap/hnh-map
|
|||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
go.etcd.io/bbolt v1.3.3
|
go.etcd.io/bbolt v1.3.3
|
||||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
|
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073
|
||||||
golang.org/x/image v0.0.0-20200119044424-58c23975cae1
|
golang.org/x/image v0.0.0-20200119044424-58c23975cae1
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,4 +1,6 @@
|
|||||||
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
|
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,11 +36,11 @@ func (a *App) export(rw http.ResponseWriter, req *http.Request) {
|
|||||||
maps := map[int]mapData{}
|
maps := map[int]mapData{}
|
||||||
gridMap := map[string]int{}
|
gridMap := map[string]int{}
|
||||||
|
|
||||||
grids := tx.Bucket([]byte("grids"))
|
grids := tx.Bucket(store.BucketGrids)
|
||||||
if grids == nil {
|
if grids == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tiles := tx.Bucket([]byte("tiles"))
|
tiles := tx.Bucket(store.BucketTiles)
|
||||||
if tiles == nil {
|
if tiles == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -94,11 +95,11 @@ func (a *App) export(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = func() error {
|
err = func() error {
|
||||||
markersb := tx.Bucket([]byte("markers"))
|
markersb := tx.Bucket(store.BucketMarkers)
|
||||||
if markersb == nil {
|
if markersb == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
markersgrid := markersb.Bucket([]byte("grid"))
|
markersgrid := markersb.Bucket(store.BucketMarkersGrid)
|
||||||
if markersgrid == nil {
|
if markersgrid == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,25 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) hideMarker(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) HideMarker(rw http.ResponseWriter, req *http.Request) {
|
||||||
if a.requireAdmin(rw, req) == nil {
|
if a.requireAdmin(rw, req) == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
mb, err := tx.CreateBucketIfNotExists([]byte("markers"))
|
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
grid, err := mb.CreateBucketIfNotExists([]byte("grid"))
|
grid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
|
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,27 +49,27 @@ func (a *App) merge(rw http.ResponseWriter, req *http.Request) {
|
|||||||
newTiles := map[string]struct{}{}
|
newTiles := map[string]struct{}{}
|
||||||
|
|
||||||
err = a.db.Update(func(tx *bbolt.Tx) error {
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mb, err := tx.CreateBucketIfNotExists([]byte("markers"))
|
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mgrid, err := mb.CreateBucketIfNotExists([]byte("grid"))
|
mgrid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
|
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configb, err := tx.CreateBucketIfNotExists([]byte("config"))
|
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ func (a *App) merge(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mapB, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ func (a *App) doRebuildZooms() {
|
|||||||
saveGrid := map[zoomproc]string{}
|
saveGrid := map[zoomproc]string{}
|
||||||
|
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket([]byte("grids"))
|
b := tx.Bucket(store.BucketGrids)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,7 @@ func (a *App) doRebuildZooms() {
|
|||||||
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
tx.DeleteBucket([]byte("tiles"))
|
tx.DeleteBucket(store.BucketTiles)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) WipeTile(rw http.ResponseWriter, req *http.Request) {
|
||||||
if a.requireAdmin(rw, req) == nil {
|
if a.requireAdmin(rw, req) == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,7 @@ func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
grids := tx.Bucket([]byte("grids"))
|
grids := tx.Bucket(store.BucketGrids)
|
||||||
if grids == nil {
|
if grids == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,7 @@ func (a *App) wipeTile(rw http.ResponseWriter, req *http.Request) {
|
|||||||
rw.WriteHeader(200)
|
rw.WriteHeader(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) setCoords(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) SetCoords(rw http.ResponseWriter, req *http.Request) {
|
||||||
if a.requireAdmin(rw, req) == nil {
|
if a.requireAdmin(rw, req) == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -121,11 +122,11 @@ func (a *App) setCoords(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
tds := []*TileData{}
|
tds := []*TileData{}
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
grids := tx.Bucket([]byte("grids"))
|
grids := tx.Bucket(store.BucketGrids)
|
||||||
if grids == nil {
|
if grids == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tiles := tx.Bucket([]byte("tiles"))
|
tiles := tx.Bucket(store.BucketTiles)
|
||||||
if tiles == nil {
|
if tiles == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -52,7 +53,7 @@ func (a *App) apiLogin(rw http.ResponseWriter, req *http.Request) {
|
|||||||
if bootstrap != "" && body.Pass == bootstrap {
|
if bootstrap != "" && body.Pass == bootstrap {
|
||||||
var created bool
|
var created bool
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ func (a *App) apiMe(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
out := meResponse{Username: s.Username, Auths: s.Auths}
|
out := meResponse{Username: s.Username, Auths: s.Auths}
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
ub := tx.Bucket([]byte("users"))
|
ub := tx.Bucket(store.BucketUsers)
|
||||||
if ub != nil {
|
if ub != nil {
|
||||||
uRaw := ub.Get([]byte(s.Username))
|
uRaw := ub.Get([]byte(s.Username))
|
||||||
if uRaw != nil {
|
if uRaw != nil {
|
||||||
@@ -144,7 +145,7 @@ func (a *App) apiMe(rw http.ResponseWriter, req *http.Request) {
|
|||||||
out.Tokens = u.Tokens
|
out.Tokens = u.Tokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
config := tx.Bucket([]byte("config"))
|
config := tx.Bucket(store.BucketConfig)
|
||||||
if config != nil {
|
if config != nil {
|
||||||
out.Prefix = string(config.Get([]byte("prefix")))
|
out.Prefix = string(config.Get([]byte("prefix")))
|
||||||
}
|
}
|
||||||
@@ -214,7 +215,7 @@ func (a *App) generateTokenForUser(username string) []string {
|
|||||||
token := hex.EncodeToString(tokenRaw)
|
token := hex.EncodeToString(tokenRaw)
|
||||||
var tokens []string
|
var tokens []string
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
ub, _ := tx.CreateBucketIfNotExists([]byte("users"))
|
ub, _ := tx.CreateBucketIfNotExists(store.BucketUsers)
|
||||||
uRaw := ub.Get([]byte(username))
|
uRaw := ub.Get([]byte(username))
|
||||||
u := User{}
|
u := User{}
|
||||||
if uRaw != nil {
|
if uRaw != nil {
|
||||||
@@ -224,7 +225,7 @@ func (a *App) generateTokenForUser(username string) []string {
|
|||||||
tokens = u.Tokens
|
tokens = u.Tokens
|
||||||
buf, _ := json.Marshal(u)
|
buf, _ := json.Marshal(u)
|
||||||
ub.Put([]byte(username), buf)
|
ub.Put([]byte(username), buf)
|
||||||
tb, _ := tx.CreateBucketIfNotExists([]byte("tokens"))
|
tb, _ := tx.CreateBucketIfNotExists(store.BucketTokens)
|
||||||
return tb.Put([]byte(token), []byte(username))
|
return tb.Put([]byte(token), []byte(username))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,7 +240,7 @@ func (a *App) setUserPassword(username, pass string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return a.db.Update(func(tx *bbolt.Tx) error {
|
return a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -266,7 +267,7 @@ func (a *App) apiAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
var list []string
|
var list []string
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket([]byte("users"))
|
b := tx.Bucket(store.BucketUsers)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -293,7 +294,7 @@ func (a *App) apiAdminUserByName(rw http.ResponseWriter, req *http.Request, name
|
|||||||
}
|
}
|
||||||
out.Username = name
|
out.Username = name
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket([]byte("users"))
|
b := tx.Bucket(store.BucketUsers)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -331,7 +332,7 @@ func (a *App) apiAdminUserPost(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
tempAdmin := false
|
tempAdmin := false
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -373,13 +374,13 @@ func (a *App) apiAdminUserDelete(rw http.ResponseWriter, req *http.Request, name
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
users, _ := tx.CreateBucketIfNotExists([]byte("users"))
|
users, _ := tx.CreateBucketIfNotExists(store.BucketUsers)
|
||||||
u := User{}
|
u := User{}
|
||||||
raw := users.Get([]byte(name))
|
raw := users.Get([]byte(name))
|
||||||
if raw != nil {
|
if raw != nil {
|
||||||
json.Unmarshal(raw, &u)
|
json.Unmarshal(raw, &u)
|
||||||
}
|
}
|
||||||
tokens, _ := tx.CreateBucketIfNotExists([]byte("tokens"))
|
tokens, _ := tx.CreateBucketIfNotExists(store.BucketTokens)
|
||||||
for _, tok := range u.Tokens {
|
for _, tok := range u.Tokens {
|
||||||
tokens.Delete([]byte(tok))
|
tokens.Delete([]byte(tok))
|
||||||
}
|
}
|
||||||
@@ -407,7 +408,7 @@ func (a *App) apiAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
out := settingsResponse{}
|
out := settingsResponse{}
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
c := tx.Bucket([]byte("config"))
|
c := tx.Bucket(store.BucketConfig)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -440,7 +441,7 @@ func (a *App) apiAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte("config"))
|
b, err := tx.CreateBucketIfNotExists(store.BucketConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -483,7 +484,7 @@ func (a *App) apiAdminMaps(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
var maps []mapInfoJSON
|
var maps []mapInfoJSON
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
mapB := tx.Bucket([]byte("maps"))
|
mapB := tx.Bucket(store.BucketMaps)
|
||||||
if mapB == nil {
|
if mapB == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -529,7 +530,7 @@ func (a *App) apiAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr s
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
maps, err := tx.CreateBucketIfNotExists(store.BucketMaps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -570,7 +571,7 @@ func (a *App) apiAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request,
|
|||||||
}
|
}
|
||||||
var mi MapInfo
|
var mi MapInfo
|
||||||
err = a.db.Update(func(tx *bbolt.Tx) error {
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
maps, err := tx.CreateBucketIfNotExists(store.BucketMaps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -605,9 +606,9 @@ func (a *App) apiAdminWipe(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
for _, bname := range []string{"grids", "markers", "tiles", "maps"} {
|
for _, b := range [][]byte{store.BucketGrids, store.BucketMarkers, store.BucketTiles, store.BucketMaps} {
|
||||||
if tx.Bucket([]byte(bname)) != nil {
|
if tx.Bucket(b) != nil {
|
||||||
if err := tx.DeleteBucket([]byte(bname)); err != nil {
|
if err := tx.DeleteBucket(b); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -621,7 +622,7 @@ func (a *App) apiAdminWipe(rw http.ResponseWriter, req *http.Request) {
|
|||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) apiAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -633,7 +634,7 @@ func (a *App) apiAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
|||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) apiAdminExport(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodGet {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -644,7 +645,7 @@ func (a *App) apiAdminExport(rw http.ResponseWriter, req *http.Request) {
|
|||||||
a.export(rw, req)
|
a.export(rw, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) apiAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -679,18 +680,18 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
|
|||||||
if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" {
|
if path == "admin/wipeTile" || path == "admin/setCoords" || path == "admin/hideMarker" {
|
||||||
switch path {
|
switch path {
|
||||||
case "admin/wipeTile":
|
case "admin/wipeTile":
|
||||||
a.wipeTile(rw, req)
|
a.WipeTile(rw, req)
|
||||||
case "admin/setCoords":
|
case "admin/setCoords":
|
||||||
a.setCoords(rw, req)
|
a.SetCoords(rw, req)
|
||||||
case "admin/hideMarker":
|
case "admin/hideMarker":
|
||||||
a.hideMarker(rw, req)
|
a.HideMarker(rw, req)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case path == "oauth/providers":
|
case path == "oauth/providers":
|
||||||
a.apiOAuthProviders(rw, req)
|
a.APIOAuthProviders(rw, req)
|
||||||
return
|
return
|
||||||
case strings.HasPrefix(path, "oauth/"):
|
case strings.HasPrefix(path, "oauth/"):
|
||||||
rest := strings.TrimPrefix(path, "oauth/")
|
rest := strings.TrimPrefix(path, "oauth/")
|
||||||
@@ -703,9 +704,9 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
|
|||||||
action := parts[1]
|
action := parts[1]
|
||||||
switch action {
|
switch action {
|
||||||
case "login":
|
case "login":
|
||||||
a.oauthLogin(rw, req, provider)
|
a.OAuthLogin(rw, req, provider)
|
||||||
case "callback":
|
case "callback":
|
||||||
a.oauthCallback(rw, req, provider)
|
a.OAuthCallback(rw, req, provider)
|
||||||
default:
|
default:
|
||||||
http.Error(rw, "not found", http.StatusNotFound)
|
http.Error(rw, "not found", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -775,13 +776,13 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
|
|||||||
a.apiAdminWipe(rw, req)
|
a.apiAdminWipe(rw, req)
|
||||||
return
|
return
|
||||||
case path == "admin/rebuildZooms":
|
case path == "admin/rebuildZooms":
|
||||||
a.apiAdminRebuildZooms(rw, req)
|
a.APIAdminRebuildZooms(rw, req)
|
||||||
return
|
return
|
||||||
case path == "admin/export":
|
case path == "admin/export":
|
||||||
a.apiAdminExport(rw, req)
|
a.APIAdminExport(rw, req)
|
||||||
return
|
return
|
||||||
case path == "admin/merge":
|
case path == "admin/merge":
|
||||||
a.apiAdminMerge(rw, req)
|
a.APIAdminMerge(rw, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,34 @@ type App struct {
|
|||||||
characters map[string]Character
|
characters map[string]Character
|
||||||
chmu sync.RWMutex
|
chmu sync.RWMutex
|
||||||
|
|
||||||
gridUpdates topic
|
gridUpdates Topic[TileData]
|
||||||
mergeUpdates mergeTopic
|
mergeUpdates Topic[Merge]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridStorage returns the grid storage path.
|
||||||
|
func (a *App) GridStorage() string {
|
||||||
|
return a.gridStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridUpdates returns the tile updates topic for MapService.
|
||||||
|
func (a *App) GridUpdates() *Topic[TileData] {
|
||||||
|
return &a.gridUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeUpdates returns the merge updates topic for MapService.
|
||||||
|
func (a *App) MergeUpdates() *Topic[Merge] {
|
||||||
|
return &a.mergeUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCharacters returns a copy of all characters (for MapService).
|
||||||
|
func (a *App) GetCharacters() []Character {
|
||||||
|
a.chmu.RLock()
|
||||||
|
defer a.chmu.RUnlock()
|
||||||
|
chars := make([]Character, 0, len(a.characters))
|
||||||
|
for _, v := range a.characters {
|
||||||
|
chars = append(chars, v)
|
||||||
|
}
|
||||||
|
return chars
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates an App with the given storage paths and database.
|
// NewApp creates an App with the given storage paths and database.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/response"
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -17,7 +19,7 @@ func (a *App) getSession(req *http.Request) *Session {
|
|||||||
}
|
}
|
||||||
var s *Session
|
var s *Session
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
sessions := tx.Bucket([]byte("sessions"))
|
sessions := tx.Bucket(store.BucketSessions)
|
||||||
if sessions == nil {
|
if sessions == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,7 @@ func (a *App) getSession(req *http.Request) *Session {
|
|||||||
s.Auths = Auths{AUTH_ADMIN}
|
s.Auths = Auths{AUTH_ADMIN}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
users := tx.Bucket([]byte("users"))
|
users := tx.Bucket(store.BucketUsers)
|
||||||
if users == nil {
|
if users == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -56,7 +58,7 @@ func (a *App) getSession(req *http.Request) *Session {
|
|||||||
|
|
||||||
func (a *App) deleteSession(s *Session) {
|
func (a *App) deleteSession(s *Session) {
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
sessions, err := tx.CreateBucketIfNotExists([]byte("sessions"))
|
sessions, err := tx.CreateBucketIfNotExists(store.BucketSessions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ func (a *App) deleteSession(s *Session) {
|
|||||||
|
|
||||||
func (a *App) saveSession(s *Session) {
|
func (a *App) saveSession(s *Session) {
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
sessions, err := tx.CreateBucketIfNotExists([]byte("sessions"))
|
sessions, err := tx.CreateBucketIfNotExists(store.BucketSessions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,7 @@ func (a *App) saveSession(s *Session) {
|
|||||||
func (a *App) getPage(req *http.Request) Page {
|
func (a *App) getPage(req *http.Request) Page {
|
||||||
p := Page{}
|
p := Page{}
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
c := tx.Bucket([]byte("config"))
|
c := tx.Bucket(store.BucketConfig)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ func (a *App) getPage(req *http.Request) Page {
|
|||||||
|
|
||||||
func (a *App) getUser(user, pass string) (u *User) {
|
func (a *App) getUser(user, pass string) (u *User) {
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
users := tx.Bucket([]byte("users"))
|
users := tx.Bucket(store.BucketUsers)
|
||||||
if users == nil {
|
if users == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -134,7 +136,7 @@ func (a *App) createSession(username string, tempAdmin bool) string {
|
|||||||
func (a *App) setupRequired() bool {
|
func (a *App) setupRequired() bool {
|
||||||
var required bool
|
var required bool
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
ub := tx.Bucket([]byte("users"))
|
ub := tx.Bucket(store.BucketUsers)
|
||||||
if ub == nil {
|
if ub == nil {
|
||||||
required = true
|
required = true
|
||||||
return nil
|
return nil
|
||||||
@@ -151,7 +153,7 @@ func (a *App) setupRequired() bool {
|
|||||||
func (a *App) requireAdmin(rw http.ResponseWriter, req *http.Request) *Session {
|
func (a *App) requireAdmin(rw http.ResponseWriter, req *http.Request) *Session {
|
||||||
s := a.getSession(req)
|
s := a.getSession(req)
|
||||||
if s == nil || !s.Auths.Has(AUTH_ADMIN) {
|
if s == nil || !s.Auths.Has(AUTH_ADMIN) {
|
||||||
rw.WriteHeader(http.StatusUnauthorized)
|
response.JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
|
|||||||
auth := false
|
auth := false
|
||||||
user := ""
|
user := ""
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
tb := tx.Bucket([]byte("tokens"))
|
tb := tx.Bucket(store.BucketTokens)
|
||||||
if tb == nil {
|
if tb == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -33,7 +34,7 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
|
|||||||
if userName == nil {
|
if userName == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ub := tx.Bucket([]byte("users"))
|
ub := tx.Bucket(store.BucketUsers)
|
||||||
if ub == nil {
|
if ub == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -84,7 +85,7 @@ func (a *App) client(rw http.ResponseWriter, req *http.Request) {
|
|||||||
func (a *App) locate(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) locate(rw http.ResponseWriter, req *http.Request) {
|
||||||
grid := req.FormValue("gridID")
|
grid := req.FormValue("gridID")
|
||||||
err := a.db.View(func(tx *bbolt.Tx) error {
|
err := a.db.View(func(tx *bbolt.Tx) error {
|
||||||
grids := tx.Bucket([]byte("grids"))
|
grids := tx.Bucket(store.BucketGrids)
|
||||||
if grids == nil {
|
if grids == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
)
|
)
|
||||||
@@ -53,21 +54,21 @@ func (a *App) gridUpdate(rw http.ResponseWriter, req *http.Request) {
|
|||||||
greq := GridRequest{}
|
greq := GridRequest{}
|
||||||
|
|
||||||
err = a.db.Update(func(tx *bbolt.Tx) error {
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
mapB, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
configb, err := tx.CreateBucketIfNotExists([]byte("config"))
|
configb, err := tx.CreateBucketIfNotExists(store.BucketConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -269,7 +270,7 @@ func (a *App) gridUpload(rw http.ResponseWriter, req *http.Request) {
|
|||||||
if ed.Season == 3 {
|
if ed.Season == 3 {
|
||||||
needTile := false
|
needTile := false
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
b, err := tx.CreateBucketIfNotExists(store.BucketGrids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -283,7 +284,7 @@ func (a *App) gridUpload(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -346,7 +347,7 @@ func (a *App) gridUpload(rw http.ResponseWriter, req *http.Request) {
|
|||||||
mapid := 0
|
mapid := 0
|
||||||
|
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
b, err := tx.CreateBucketIfNotExists(store.BucketGrids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,15 +34,15 @@ func (a *App) uploadMarkers(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = a.db.Update(func(tx *bbolt.Tx) error {
|
err = a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
mb, err := tx.CreateBucketIfNotExists([]byte("markers"))
|
mb, err := tx.CreateBucketIfNotExists(store.BucketMarkers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
grid, err := mb.CreateBucketIfNotExists([]byte("grid"))
|
grid, err := mb.CreateBucketIfNotExists(store.BucketMarkersGrid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
idB, err := mb.CreateBucketIfNotExists([]byte("id"))
|
idB, err := mb.CreateBucketIfNotExists(store.BucketMarkersID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ func (a *App) updatePositions(rw http.ResponseWriter, req *http.Request) {
|
|||||||
// Avoid holding db.View and chmu simultaneously to prevent deadlock.
|
// Avoid holding db.View and chmu simultaneously to prevent deadlock.
|
||||||
gridDataByID := make(map[string]GridData)
|
gridDataByID := make(map[string]GridData)
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
grids := tx.Bucket([]byte("grids"))
|
grids := tx.Bucket(store.BucketGrids)
|
||||||
if grids == nil {
|
if grids == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,15 +52,15 @@ func (a *App) getMarkers(rw http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
markers := []FrontendMarker{}
|
markers := []FrontendMarker{}
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket([]byte("markers"))
|
b := tx.Bucket(store.BucketMarkers)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
grid := b.Bucket([]byte("grid"))
|
grid := b.Bucket(store.BucketMarkersGrid)
|
||||||
if grid == nil {
|
if grid == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
grids := tx.Bucket([]byte("grids"))
|
grids := tx.Bucket(store.BucketGrids)
|
||||||
if grids == nil {
|
if grids == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -99,7 +100,7 @@ func (a *App) getMaps(rw http.ResponseWriter, req *http.Request) {
|
|||||||
showHidden := s.Auths.Has(AUTH_ADMIN)
|
showHidden := s.Auths.Has(AUTH_ADMIN)
|
||||||
maps := map[int]*MapInfo{}
|
maps := map[int]*MapInfo{}
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
mapB := tx.Bucket([]byte("maps"))
|
mapB := tx.Bucket(store.BucketMaps)
|
||||||
if mapB == nil {
|
if mapB == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,7 @@ func (a *App) config(rw http.ResponseWriter, req *http.Request) {
|
|||||||
Auths: s.Auths,
|
Auths: s.Auths,
|
||||||
}
|
}
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket([]byte("config"))
|
b := tx.Bucket(store.BucketConfig)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var migrations = []func(tx *bbolt.Tx) error{
|
var migrations = []func(tx *bbolt.Tx) error{
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
if tx.Bucket([]byte("markers")) != nil {
|
if tx.Bucket(store.BucketMarkers) != nil {
|
||||||
return tx.DeleteBucket([]byte("markers"))
|
return tx.DeleteBucket(store.BucketMarkers)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -51,22 +52,20 @@ var migrations = []func(tx *bbolt.Tx) error{
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte("config"))
|
b, err := tx.CreateBucketIfNotExists(store.BucketConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return b.Put([]byte("title"), []byte("HnH Automapper Server"))
|
return b.Put([]byte("title"), []byte("HnH Automapper Server"))
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
if tx.Bucket([]byte("markers")) != nil {
|
// No-op: markers deletion already in migration 0
|
||||||
return tx.DeleteBucket([]byte("markers"))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
if tx.Bucket([]byte("tiles")) != nil {
|
if tx.Bucket(store.BucketTiles) != nil {
|
||||||
allTiles := map[string]map[string]TileData{}
|
allTiles := map[string]map[string]TileData{}
|
||||||
tiles := tx.Bucket([]byte("tiles"))
|
tiles := tx.Bucket(store.BucketTiles)
|
||||||
err := tiles.ForEach(func(k, v []byte) error {
|
err := tiles.ForEach(func(k, v []byte) error {
|
||||||
zoom := tiles.Bucket(k)
|
zoom := tiles.Bucket(k)
|
||||||
zoomTiles := map[string]TileData{}
|
zoomTiles := map[string]TileData{}
|
||||||
@@ -82,11 +81,11 @@ var migrations = []func(tx *bbolt.Tx) error{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = tx.DeleteBucket([]byte("tiles"))
|
err = tx.DeleteBucket(store.BucketTiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tiles, err = tx.CreateBucket([]byte("tiles"))
|
tiles, err = tx.CreateBucket(store.BucketTiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -115,18 +114,16 @@ var migrations = []func(tx *bbolt.Tx) error{
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
if tx.Bucket([]byte("markers")) != nil {
|
// No-op: markers deletion already in migration 0
|
||||||
return tx.DeleteBucket([]byte("markers"))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
highest := uint64(0)
|
highest := uint64(0)
|
||||||
maps, err := tx.CreateBucketIfNotExists([]byte("maps"))
|
maps, err := tx.CreateBucketIfNotExists(store.BucketMaps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
grids, err := tx.CreateBucketIfNotExists([]byte("grids"))
|
grids, err := tx.CreateBucketIfNotExists(store.BucketGrids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -138,6 +135,7 @@ var migrations = []func(tx *bbolt.Tx) error{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, ok := mapsFound[gd.Map]; !ok {
|
if _, ok := mapsFound[gd.Map]; !ok {
|
||||||
|
mapsFound[gd.Map] = struct{}{}
|
||||||
if uint64(gd.Map) > highest {
|
if uint64(gd.Map) > highest {
|
||||||
highest = uint64(gd.Map)
|
highest = uint64(gd.Map)
|
||||||
}
|
}
|
||||||
@@ -157,7 +155,7 @@ var migrations = []func(tx *bbolt.Tx) error{
|
|||||||
return maps.SetSequence(highest + 1)
|
return maps.SetSequence(highest + 1)
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
users := tx.Bucket([]byte("users"))
|
users := tx.Bucket(store.BucketUsers)
|
||||||
if users == nil {
|
if users == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -176,7 +174,7 @@ var migrations = []func(tx *bbolt.Tx) error{
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
func(tx *bbolt.Tx) error {
|
func(tx *bbolt.Tx) error {
|
||||||
_, err := tx.CreateBucketIfNotExists([]byte("oauth_states"))
|
_, err := tx.CreateBucketIfNotExists(store.BucketOAuthStates)
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -184,7 +182,7 @@ var migrations = []func(tx *bbolt.Tx) error{
|
|||||||
// RunMigrations runs all pending migrations on the database.
|
// RunMigrations runs all pending migrations on the database.
|
||||||
func RunMigrations(db *bbolt.DB) error {
|
func RunMigrations(db *bbolt.DB) error {
|
||||||
return db.Update(func(tx *bbolt.Tx) error {
|
return db.Update(func(tx *bbolt.Tx) error {
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte("config"))
|
b, err := tx.CreateBucketIfNotExists(store.BucketConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/google"
|
"golang.org/x/oauth2/google"
|
||||||
@@ -63,7 +64,7 @@ func (a *App) baseURL(req *http.Request) string {
|
|||||||
return scheme + "://" + host
|
return scheme + "://" + host
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) oauthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
|
func (a *App) OAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodGet {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -88,7 +89,7 @@ func (a *App) oauthLogin(rw http.ResponseWriter, req *http.Request, provider str
|
|||||||
}
|
}
|
||||||
stRaw, _ := json.Marshal(st)
|
stRaw, _ := json.Marshal(st)
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
b, err := tx.CreateBucketIfNotExists([]byte("oauth_states"))
|
b, err := tx.CreateBucketIfNotExists(store.BucketOAuthStates)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ type googleUserInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) oauthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
|
func (a *App) OAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodGet {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -127,7 +128,7 @@ func (a *App) oauthCallback(rw http.ResponseWriter, req *http.Request, provider
|
|||||||
}
|
}
|
||||||
var st oauthState
|
var st oauthState
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket([]byte("oauth_states"))
|
b := tx.Bucket(store.BucketOAuthStates)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -224,7 +225,7 @@ func (a *App) googleUserInfo(accessToken string) (sub, email string, err error)
|
|||||||
func (a *App) findOrCreateOAuthUser(provider, sub, email string) (string, *User) {
|
func (a *App) findOrCreateOAuthUser(provider, sub, email string) (string, *User) {
|
||||||
var username string
|
var username string
|
||||||
err := a.db.Update(func(tx *bbolt.Tx) error {
|
err := a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
users, err := tx.CreateBucketIfNotExists([]byte("users"))
|
users, err := tx.CreateBucketIfNotExists(store.BucketUsers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -283,7 +284,7 @@ func (a *App) findOrCreateOAuthUser(provider, sub, email string) (string, *User)
|
|||||||
func (a *App) getUserByUsername(username string) *User {
|
func (a *App) getUserByUsername(username string) *User {
|
||||||
var u *User
|
var u *User
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
users := tx.Bucket([]byte("users"))
|
users := tx.Bucket(store.BucketUsers)
|
||||||
if users == nil {
|
if users == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -297,7 +298,7 @@ func (a *App) getUserByUsername(username string) *User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiOAuthProviders returns list of configured OAuth providers.
|
// apiOAuthProviders returns list of configured OAuth providers.
|
||||||
func (a *App) apiOAuthProviders(rw http.ResponseWriter, req *http.Request) {
|
func (a *App) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodGet {
|
if req.Method != http.MethodGet {
|
||||||
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
|
|||||||
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"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ type TileData struct {
|
|||||||
|
|
||||||
func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
|
func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
tiles := tx.Bucket([]byte("tiles"))
|
tiles := tx.Bucket(store.BucketTiles)
|
||||||
if tiles == nil {
|
if tiles == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ func (a *App) GetTile(mapid int, c Coord, z int) (td *TileData) {
|
|||||||
|
|
||||||
func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
|
func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
|
||||||
a.db.Update(func(tx *bbolt.Tx) error {
|
a.db.Update(func(tx *bbolt.Tx) error {
|
||||||
tiles, err := tx.CreateBucketIfNotExists([]byte("tiles"))
|
tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -82,14 +83,14 @@ func (a *App) SaveTile(mapid int, c Coord, z int, f string, t int64) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.gridUpdates.send(td)
|
a.gridUpdates.Send(td)
|
||||||
return zoom.Put([]byte(c.Name()), raw)
|
return zoom.Put([]byte(c.Name()), raw)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) reportMerge(from, to int, shift Coord) {
|
func (a *App) reportMerge(from, to int, shift Coord) {
|
||||||
a.mergeUpdates.send(&Merge{
|
a.mergeUpdates.Send(&Merge{
|
||||||
From: from,
|
From: from,
|
||||||
To: to,
|
To: to,
|
||||||
Shift: shift,
|
Shift: shift,
|
||||||
@@ -121,13 +122,13 @@ func (a *App) watchGridUpdates(rw http.ResponseWriter, req *http.Request) {
|
|||||||
c := make(chan *TileData, 1000)
|
c := make(chan *TileData, 1000)
|
||||||
mc := make(chan *Merge, 5)
|
mc := make(chan *Merge, 5)
|
||||||
|
|
||||||
a.gridUpdates.watch(c)
|
a.gridUpdates.Watch(c)
|
||||||
a.mergeUpdates.watch(mc)
|
a.mergeUpdates.Watch(mc)
|
||||||
|
|
||||||
tileCache := make([]TileCache, 0, 100)
|
tileCache := make([]TileCache, 0, 100)
|
||||||
|
|
||||||
a.db.View(func(tx *bbolt.Tx) error {
|
a.db.View(func(tx *bbolt.Tx) error {
|
||||||
tiles := tx.Bucket([]byte("tiles"))
|
tiles := tx.Bucket(store.BucketTiles)
|
||||||
if tiles == nil {
|
if tiles == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ package app
|
|||||||
|
|
||||||
import "sync"
|
import "sync"
|
||||||
|
|
||||||
type topic struct {
|
// Topic is a generic pub/sub for broadcasting updates.
|
||||||
c []chan *TileData
|
type Topic[T any] struct {
|
||||||
|
c []chan *T
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *topic) watch(c chan *TileData) {
|
// Watch subscribes a channel to receive updates.
|
||||||
|
func (t *Topic[T]) Watch(c chan *T) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
t.c = append(t.c, c)
|
t.c = append(t.c, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *topic) send(b *TileData) {
|
// Send broadcasts to all subscribers.
|
||||||
|
func (t *Topic[T]) Send(b *T) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
for i := 0; i < len(t.c); i++ {
|
for i := 0; i < len(t.c); i++ {
|
||||||
@@ -27,7 +30,8 @@ func (t *topic) send(b *TileData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *topic) close() {
|
// Close closes all subscriber channels.
|
||||||
|
func (t *Topic[T]) Close() {
|
||||||
for _, c := range t.c {
|
for _, c := range t.c {
|
||||||
close(c)
|
close(c)
|
||||||
}
|
}
|
||||||
@@ -38,35 +42,3 @@ type Merge struct {
|
|||||||
From, To int
|
From, To int
|
||||||
Shift Coord
|
Shift Coord
|
||||||
}
|
}
|
||||||
|
|
||||||
type mergeTopic struct {
|
|
||||||
c []chan *Merge
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *mergeTopic) watch(c chan *Merge) {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
t.c = append(t.c, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *mergeTopic) send(b *Merge) {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
for i := 0; i < len(t.c); i++ {
|
|
||||||
select {
|
|
||||||
case t.c[i] <- b:
|
|
||||||
default:
|
|
||||||
close(t.c[i])
|
|
||||||
t.c[i] = t.c[len(t.c)-1]
|
|
||||||
t.c = t.c[:len(t.c)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *mergeTopic) close() {
|
|
||||||
for _, c := range t.c {
|
|
||||||
close(c)
|
|
||||||
}
|
|
||||||
t.c = t.c[:0]
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user