Implement HTTP timeout configurations and enhance API documentation
- Added optional HTTP server timeout configurations (`HNHMAP_READ_TIMEOUT`, `HNHMAP_WRITE_TIMEOUT`, `HNHMAP_IDLE_TIMEOUT`) to `.env.example` and updated the server initialization in `main.go` to utilize these settings. - Enhanced API documentation for the `rebuildZooms` endpoint to clarify its background processing and polling mechanism for status updates. - Updated `configuration.md` to include new timeout environment variables for better configuration guidance. - Improved error handling in the client for large request bodies, ensuring appropriate responses for oversized payloads.
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
# Backend (Go)
|
# Backend (Go)
|
||||||
# HNHMAP_PORT=8080
|
# HNHMAP_PORT=8080
|
||||||
|
# HNHMAP_READ_TIMEOUT=30s # HTTP read timeout (optional)
|
||||||
|
# HNHMAP_WRITE_TIMEOUT=60s # HTTP write timeout (optional)
|
||||||
|
# HNHMAP_IDLE_TIMEOUT=120s # HTTP idle timeout (optional)
|
||||||
# HNHMAP_BOOTSTRAP_PASSWORD= # Set once for first run: login as admin with this password to create the first admin user (then unset or leave empty)
|
# HNHMAP_BOOTSTRAP_PASSWORD= # Set once for first run: login as admin with this password to create the first admin user (then unset or leave empty)
|
||||||
# Grids directory (default: grids); in Docker often /map
|
# Grids directory (default: grids); in Docker often /map
|
||||||
# HNHMAP_GRIDS=grids
|
# HNHMAP_GRIDS=grids
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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/handlers"
|
||||||
@@ -79,6 +80,32 @@ func main() {
|
|||||||
publicDir := filepath.Join(workDir, "public")
|
publicDir := filepath.Join(workDir, "public")
|
||||||
r := a.Router(publicDir, h)
|
r := a.Router(publicDir, h)
|
||||||
|
|
||||||
|
readTimeout := 30 * time.Second
|
||||||
|
if v := os.Getenv("HNHMAP_READ_TIMEOUT"); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||||
|
readTimeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeTimeout := 60 * time.Second
|
||||||
|
if v := os.Getenv("HNHMAP_WRITE_TIMEOUT"); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||||
|
writeTimeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idleTimeout := 120 * time.Second
|
||||||
|
if v := os.Getenv("HNHMAP_IDLE_TIMEOUT"); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||||
|
idleTimeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", *port),
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
}
|
||||||
log.Printf("Listening on port %d", *port)
|
log.Printf("Listening on port %d", *port)
|
||||||
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), r))
|
log.Fatal(srv.ListenAndServe())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica
|
|||||||
- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
|
- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
|
||||||
- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
|
- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
|
||||||
- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
|
- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
|
||||||
- **POST /map/api/admin/rebuildZooms** — rebuild tile zoom levels from base tiles. The operation can take a long time (minutes) when there are many grids; the client should allow for request timeouts or show appropriate loading state. On success returns 200; on failure (e.g. store error) returns 500.
|
- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map.
|
||||||
|
- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress.
|
||||||
- **GET /map/api/admin/export** — download data export (ZIP).
|
- **GET /map/api/admin/export** — download data export (ZIP).
|
||||||
- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
|
- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
|
||||||
- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.
|
- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
|-----------------|-------------|---------|
|
|-----------------|-------------|---------|
|
||||||
| `HNHMAP_PORT` | HTTP server port | 8080 |
|
| `HNHMAP_PORT` | HTTP server port | 8080 |
|
||||||
| `-port` | Same (command-line flag) | value of `HNHMAP_PORT` or 8080 |
|
| `-port` | Same (command-line flag) | value of `HNHMAP_PORT` or 8080 |
|
||||||
|
| `HNHMAP_READ_TIMEOUT` | HTTP server read timeout (e.g. `30s`) | 30s |
|
||||||
|
| `HNHMAP_WRITE_TIMEOUT` | HTTP server write timeout (e.g. `60s`) | 60s |
|
||||||
|
| `HNHMAP_IDLE_TIMEOUT` | HTTP server idle timeout (e.g. `120s`) | 120s |
|
||||||
| `HNHMAP_BOOTSTRAP_PASSWORD` | Password for initial setup: when no users exist, logging in as `admin` with this password creates the first admin user | — |
|
| `HNHMAP_BOOTSTRAP_PASSWORD` | Password for initial setup: when no users exist, logging in as `admin` with this password creates the first admin user | — |
|
||||||
| `HNHMAP_BASE_URL` | Full application URL for OAuth redirect_uri (e.g. `https://map.example.com`). If not set, derived from `Host` and `X-Forwarded-*` headers | — |
|
| `HNHMAP_BASE_URL` | Full application URL for OAuth redirect_uri (e.g. `https://map.example.com`). If not set, derived from `Host` and `X-Forwarded-*` headers | — |
|
||||||
| `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` | Google OAuth Client ID | — |
|
| `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` | Google OAuth Client ID | — |
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ function updateSelectedMarkerForBookmark() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
let characterPollFirstTickId: ReturnType<typeof setTimeout> | null = null
|
||||||
let autoMode = false
|
let autoMode = false
|
||||||
let mapContainer: HTMLElement | null = null
|
let mapContainer: HTMLElement | null = null
|
||||||
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
|
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
|
||||||
@@ -554,13 +555,20 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startCharacterPoll() {
|
function startCharacterPoll() {
|
||||||
|
if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId)
|
||||||
|
characterPollFirstTickId = null
|
||||||
if (intervalId) clearInterval(intervalId)
|
if (intervalId) clearInterval(intervalId)
|
||||||
|
intervalId = null
|
||||||
const ms =
|
const ms =
|
||||||
typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
||||||
? CHARACTER_POLL_MS_HIDDEN
|
? CHARACTER_POLL_MS_HIDDEN
|
||||||
: CHARACTER_POLL_MS
|
: CHARACTER_POLL_MS
|
||||||
pollCharacters()
|
// First tick after delay to avoid double-fetch with initial getCharacters() in Promise.all
|
||||||
intervalId = setInterval(pollCharacters, ms)
|
characterPollFirstTickId = setTimeout(() => {
|
||||||
|
characterPollFirstTickId = null
|
||||||
|
pollCharacters()
|
||||||
|
intervalId = setInterval(pollCharacters, ms)
|
||||||
|
}, ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
startCharacterPoll()
|
startCharacterPoll()
|
||||||
@@ -703,6 +711,7 @@ onBeforeUnmount(() => {
|
|||||||
if (contextMenuHandler) {
|
if (contextMenuHandler) {
|
||||||
document.removeEventListener('contextmenu', contextMenuHandler, true)
|
document.removeEventListener('contextmenu', contextMenuHandler, true)
|
||||||
}
|
}
|
||||||
|
if (characterPollFirstTickId) clearTimeout(characterPollFirstTickId)
|
||||||
if (intervalId) clearInterval(intervalId)
|
if (intervalId) clearInterval(intervalId)
|
||||||
updatesHandle?.cleanup()
|
updatesHandle?.cleanup()
|
||||||
if (leafletMap) leafletMap.remove()
|
if (leafletMap) leafletMap.remove()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
:src="useGravatarUrl(email, size)"
|
:src="useGravatarUrl(email, size)"
|
||||||
alt=""
|
alt=""
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
@error="gravatarError = true"
|
@error="gravatarError = true"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ const recent = useRecentLocations()
|
|||||||
|
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
|
const queryDebounced = ref('')
|
||||||
|
let queryDebounceId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const SEARCH_DEBOUNCE_MS = 180
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
(v) => {
|
||||||
|
if (queryDebounceId) clearTimeout(queryDebounceId)
|
||||||
|
queryDebounceId = setTimeout(() => {
|
||||||
|
queryDebounced.value = v
|
||||||
|
queryDebounceId = null
|
||||||
|
}, SEARCH_DEBOUNCE_MS)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
const showDropdown = ref(false)
|
const showDropdown = ref(false)
|
||||||
const highlightIndex = ref(0)
|
const highlightIndex = ref(0)
|
||||||
let closeDropdownTimer: ReturnType<typeof setTimeout> | null = null
|
let closeDropdownTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -113,7 +127,7 @@ interface Suggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suggestions = computed<Suggestion[]>(() => {
|
const suggestions = computed<Suggestion[]>(() => {
|
||||||
const q = query.value.trim().toLowerCase()
|
const q = queryDebounced.value.trim().toLowerCase()
|
||||||
if (!q) return []
|
if (!q) return []
|
||||||
|
|
||||||
const coordMatch = q.match(/^\s*(-?\d+)\s*[,;\s]\s*(-?\d+)\s*$/)
|
const coordMatch = q.match(/^\s*(-?\d+)\s*[,;\s]\s*(-?\d+)\s*$/)
|
||||||
@@ -193,10 +207,18 @@ const emit = defineEmits<{
|
|||||||
'jump-to-marker': [id: number]
|
'jump-to-marker': [id: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
watch(suggestions, () => {
|
||||||
|
highlightIndex.value = 0
|
||||||
|
}, { flush: 'sync' })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
useMapNavigate().setFocusSearch(() => inputRef.value?.focus())
|
useMapNavigate().setFocusSearch(() => inputRef.value?.focus())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (queryDebounceId) clearTimeout(queryDebounceId)
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (closeDropdownTimer) clearTimeout(closeDropdownTimer)
|
if (closeDropdownTimer) clearTimeout(closeDropdownTimer)
|
||||||
useMapNavigate().setFocusSearch(null)
|
useMapNavigate().setFocusSearch(null)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import md5 from 'md5'
|
import md5 from 'md5'
|
||||||
|
|
||||||
|
const gravatarCache = new Map<string, string>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns Gravatar avatar URL for the given email, or empty string if no email.
|
* Returns Gravatar avatar URL for the given email, or empty string if no email.
|
||||||
* Gravatar expects: trim, lowercase, then MD5 hex. Use empty string to show a placeholder (e.g. initial letter) in the UI.
|
* Gravatar expects: trim, lowercase, then MD5 hex. Use empty string to show a placeholder (e.g. initial letter) in the UI.
|
||||||
|
* Results are memoized by (email, size) to avoid recomputing MD5 on every render.
|
||||||
*
|
*
|
||||||
* @param email - User email (optional)
|
* @param email - User email (optional)
|
||||||
* @param size - Avatar size in pixels (default 64; navbar 32, drawer 40, profile 64–80)
|
* @param size - Avatar size in pixels (default 64; navbar 32, drawer 40, profile 64–80)
|
||||||
@@ -10,7 +13,12 @@ import md5 from 'md5'
|
|||||||
export function useGravatarUrl(email: string | undefined, size?: number): string {
|
export function useGravatarUrl(email: string | undefined, size?: number): string {
|
||||||
const normalized = email?.trim().toLowerCase()
|
const normalized = email?.trim().toLowerCase()
|
||||||
if (!normalized) return ''
|
if (!normalized) return ''
|
||||||
const hash = md5(normalized)
|
|
||||||
const s = size ?? 64
|
const s = size ?? 64
|
||||||
return `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon`
|
const key = `${normalized}\n${s}`
|
||||||
|
const cached = gravatarCache.get(key)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const hash = md5(normalized)
|
||||||
|
const url = `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon`
|
||||||
|
gravatarCache.set(key, url)
|
||||||
|
return url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeRespon
|
|||||||
// 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 = new Map<symbol, () => void>()
|
const onApiErrorCallbacks = new Map<symbol, () => void>()
|
||||||
|
|
||||||
|
// In-flight dedup: one me() request at a time; concurrent callers share the same promise.
|
||||||
|
let mePromise: Promise<MeResponse> | null = null
|
||||||
|
|
||||||
|
// In-flight dedup for GET endpoints: same path + method shares one request across all callers.
|
||||||
|
const inFlightByKey = new Map<string, Promise<unknown>>()
|
||||||
|
|
||||||
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
|
||||||
@@ -40,20 +46,31 @@ export function useMapApi() {
|
|||||||
return undefined as T
|
return undefined as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDeduped<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||||
|
const key = path + (opts?.method ?? 'GET')
|
||||||
|
const existing = inFlightByKey.get(key)
|
||||||
|
if (existing) return existing as Promise<T>
|
||||||
|
const p = request<T>(path, opts).finally(() => {
|
||||||
|
inFlightByKey.delete(key)
|
||||||
|
})
|
||||||
|
inFlightByKey.set(key, p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
async function getConfig() {
|
async function getConfig() {
|
||||||
return request<ConfigResponse>('config')
|
return requestDeduped<ConfigResponse>('config')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCharacters() {
|
async function getCharacters() {
|
||||||
return request<Character[]>('v1/characters')
|
return requestDeduped<Character[]>('v1/characters')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMarkers() {
|
async function getMarkers() {
|
||||||
return request<Marker[]>('v1/markers')
|
return requestDeduped<Marker[]>('v1/markers')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMaps() {
|
async function getMaps() {
|
||||||
return request<Record<string, MapInfo>>('maps')
|
return requestDeduped<Record<string, MapInfo>>('maps')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
@@ -92,11 +109,17 @@ export function useMapApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
mePromise = null
|
||||||
|
inFlightByKey.clear()
|
||||||
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
|
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function me() {
|
async function me() {
|
||||||
return request<MeResponse>('me')
|
if (mePromise) return mePromise
|
||||||
|
mePromise = request<MeResponse>('me').finally(() => {
|
||||||
|
mePromise = null
|
||||||
|
})
|
||||||
|
return mePromise
|
||||||
}
|
}
|
||||||
|
|
||||||
async function meUpdate(body: { email?: string }) {
|
async function meUpdate(body: { email?: string }) {
|
||||||
@@ -182,7 +205,19 @@ export function useMapApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function adminRebuildZooms() {
|
async function adminRebuildZooms() {
|
||||||
await request('admin/rebuildZooms', { method: 'POST' })
|
const res = await fetch(`${apiBase}/admin/rebuildZooms`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
onApiErrorCallbacks.forEach((cb) => cb())
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> {
|
||||||
|
return request<{ running: boolean }>('admin/rebuildZooms/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminExportUrl() {
|
function adminExportUrl() {
|
||||||
@@ -250,6 +285,7 @@ export function useMapApi() {
|
|||||||
adminMapToggleHidden,
|
adminMapToggleHidden,
|
||||||
adminWipe,
|
adminWipe,
|
||||||
adminRebuildZooms,
|
adminRebuildZooms,
|
||||||
|
adminRebuildZoomsStatus,
|
||||||
adminExportUrl,
|
adminExportUrl,
|
||||||
adminMerge,
|
adminMerge,
|
||||||
adminWipeTile,
|
adminWipeTile,
|
||||||
|
|||||||
@@ -60,11 +60,11 @@
|
|||||||
aria-label="Search users"
|
aria-label="Search users"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-for="u in filteredUsers"
|
v-for="u in filteredUsers"
|
||||||
:key="u"
|
:key="u"
|
||||||
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
|
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="avatar avatar-placeholder">
|
<div class="avatar avatar-placeholder">
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
aria-label="Search maps"
|
aria-label="Search maps"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto max-h-[60vh] overflow-y-auto">
|
||||||
<table class="table table-sm table-zebra min-w-[32rem]">
|
<table class="table table-sm table-zebra min-w-[32rem]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm min-h-11 touch-manipulation">
|
<a :href="exportUrl" target="_blank" rel="noopener" class="btn btn-sm min-h-11 touch-manipulation">
|
||||||
Export zip
|
Export zip
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,6 +284,7 @@ definePageMeta({ middleware: 'admin' })
|
|||||||
useHead({ title: 'Admin – HnH Map' })
|
useHead({ title: 'Admin – HnH Map' })
|
||||||
|
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
|
const exportUrl = computed(() => api.adminExportUrl())
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const users = ref<string[]>([])
|
const users = ref<string[]>([])
|
||||||
const maps = ref<MapInfoAdmin[]>([])
|
const maps = ref<MapInfoAdmin[]>([])
|
||||||
@@ -391,6 +392,15 @@ async function doRebuildZooms() {
|
|||||||
rebuilding.value = true
|
rebuilding.value = true
|
||||||
try {
|
try {
|
||||||
await api.adminRebuildZooms()
|
await api.adminRebuildZooms()
|
||||||
|
// Rebuild runs in background; poll status until done
|
||||||
|
const poll = async (): Promise<void> => {
|
||||||
|
const { running } = await api.adminRebuildZoomsStatus()
|
||||||
|
if (running) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000))
|
||||||
|
return poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await poll()
|
||||||
markRebuildDone()
|
markRebuildDone()
|
||||||
showRebuildModal.value = false
|
showRebuildModal.value = false
|
||||||
toast.success('Zooms rebuilt.')
|
toast.success('Zooms rebuilt.')
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
|
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
|
||||||
|
// It starts the rebuild in the background and returns 202 Accepted immediately.
|
||||||
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
||||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||||
return
|
return
|
||||||
@@ -328,11 +329,22 @@ func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Reques
|
|||||||
if h.requireAdmin(rw, req) == nil {
|
if h.requireAdmin(rw, req) == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.Admin.RebuildZooms(req.Context()); err != nil {
|
h.Admin.StartRebuildZooms()
|
||||||
HandleServiceError(rw, err)
|
rw.WriteHeader(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminRebuildZoomsStatus handles GET /map/api/admin/rebuildZooms/status.
|
||||||
|
// Returns {"running": true|false} so the client can poll until the rebuild finishes.
|
||||||
|
func (h *Handlers) APIAdminRebuildZoomsStatus(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rw.WriteHeader(http.StatusOK)
|
if h.requireAdmin(rw, req) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running := h.Admin.RebuildZoomsRunning()
|
||||||
|
JSON(rw, http.StatusOK, map[string]bool{"running": running})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIAdminExport handles GET /map/api/admin/export.
|
// APIAdminExport handles GET /map/api/admin/export.
|
||||||
@@ -426,6 +438,8 @@ func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path
|
|||||||
h.APIAdminWipe(rw, req)
|
h.APIAdminWipe(rw, req)
|
||||||
case path == "rebuildZooms":
|
case path == "rebuildZooms":
|
||||||
h.APIAdminRebuildZooms(rw, req)
|
h.APIAdminRebuildZooms(rw, req)
|
||||||
|
case path == "rebuildZooms/status":
|
||||||
|
h.APIAdminRebuildZoomsStatus(rw, req)
|
||||||
case path == "export":
|
case path == "export":
|
||||||
h.APIAdminExport(rw, req)
|
h.APIAdminExport(rw, req)
|
||||||
case path == "merge":
|
case path == "merge":
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"github.com/andyleap/hnh-map/internal/app/services"
|
"github.com/andyleap/hnh-map/internal/app/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxClientBodySize is the maximum size for position and marker update request bodies.
|
||||||
|
const maxClientBodySize = 2 * 1024 * 1024 // 2 MB
|
||||||
|
|
||||||
var clientPath = regexp.MustCompile(`client/([^/]+)/(.*)`)
|
var clientPath = regexp.MustCompile(`client/([^/]+)/(.*)`)
|
||||||
|
|
||||||
// ClientRouter handles /client/* requests with token-based auth.
|
// ClientRouter handles /client/* requests with token-based auth.
|
||||||
@@ -112,12 +115,16 @@ func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Request) {
|
func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||||
defer req.Body.Close()
|
defer req.Body.Close()
|
||||||
buf, err := io.ReadAll(req.Body)
|
buf, err := io.ReadAll(io.LimitReader(req.Body, maxClientBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("error reading position update", "error", err)
|
slog.Error("error reading position update", "error", err)
|
||||||
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
|
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(buf) > maxClientBodySize {
|
||||||
|
JSONError(rw, http.StatusRequestEntityTooLarge, "request body too large", "PAYLOAD_TOO_LARGE")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.Client.UpdatePositions(req.Context(), buf); err != nil {
|
if err := h.Client.UpdatePositions(req.Context(), buf); err != nil {
|
||||||
slog.Error("position update failed", "error", err)
|
slog.Error("position update failed", "error", err)
|
||||||
HandleServiceError(rw, err)
|
HandleServiceError(rw, err)
|
||||||
@@ -126,12 +133,16 @@ func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Reques
|
|||||||
|
|
||||||
func (h *Handlers) clientMarkerUpdate(rw http.ResponseWriter, req *http.Request) {
|
func (h *Handlers) clientMarkerUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||||
defer req.Body.Close()
|
defer req.Body.Close()
|
||||||
buf, err := io.ReadAll(req.Body)
|
buf, err := io.ReadAll(io.LimitReader(req.Body, maxClientBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("error reading marker update", "error", err)
|
slog.Error("error reading marker update", "error", err)
|
||||||
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
|
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(buf) > maxClientBodySize {
|
||||||
|
JSONError(rw, http.StatusRequestEntityTooLarge, "request body too large", "PAYLOAD_TOO_LARGE")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.Client.UploadMarkers(req.Context(), buf); err != nil {
|
if err := h.Client.UploadMarkers(req.Context(), buf); err != nil {
|
||||||
slog.Error("marker update failed", "error", err)
|
slog.Error("marker update failed", "error", err)
|
||||||
HandleServiceError(rw, err)
|
HandleServiceError(rw, err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -677,3 +678,38 @@ func TestClientRouter_InvalidToken(t *testing.T) {
|
|||||||
t.Fatalf("expected 401, got %d", rr.Code)
|
t.Fatalf("expected 401, got %d", rr.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientRouter_PositionUpdate_BodyTooLarge(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
|
||||||
|
tokens := env.auth.GenerateTokenForUser(context.Background(), "alice")
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
t.Fatal("expected token")
|
||||||
|
}
|
||||||
|
// Body larger than maxClientBodySize (2MB)
|
||||||
|
bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/positionUpdate", bytes.NewReader(bigBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
env.h.ClientRouter(rr, req)
|
||||||
|
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRouter_MarkerUpdate_BodyTooLarge(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
|
||||||
|
tokens := env.auth.GenerateTokenForUser(context.Background(), "alice")
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
t.Fatal("expected token")
|
||||||
|
}
|
||||||
|
bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/markerUpdate", bytes.NewReader(bigBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
env.h.ClientRouter(rr, req)
|
||||||
|
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/andyleap/hnh-map/internal/app"
|
"github.com/andyleap/hnh-map/internal/app"
|
||||||
"github.com/andyleap/hnh-map/internal/app/store"
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
@@ -17,6 +18,9 @@ import (
|
|||||||
type AdminService struct {
|
type AdminService struct {
|
||||||
st *store.Store
|
st *store.Store
|
||||||
mapSvc *MapService
|
mapSvc *MapService
|
||||||
|
|
||||||
|
rebuildMu sync.Mutex
|
||||||
|
rebuildRunning bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminService creates an AdminService with the given store and map service.
|
// NewAdminService creates an AdminService with the given store and map service.
|
||||||
@@ -372,3 +376,32 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
|
|||||||
func (s *AdminService) RebuildZooms(ctx context.Context) error {
|
func (s *AdminService) RebuildZooms(ctx context.Context) error {
|
||||||
return s.mapSvc.RebuildZooms(ctx)
|
return s.mapSvc.RebuildZooms(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately.
|
||||||
|
// RebuildZoomsRunning returns true while the rebuild is in progress.
|
||||||
|
func (s *AdminService) StartRebuildZooms() {
|
||||||
|
s.rebuildMu.Lock()
|
||||||
|
if s.rebuildRunning {
|
||||||
|
s.rebuildMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.rebuildRunning = true
|
||||||
|
s.rebuildMu.Unlock()
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
s.rebuildMu.Lock()
|
||||||
|
s.rebuildRunning = false
|
||||||
|
s.rebuildMu.Unlock()
|
||||||
|
}()
|
||||||
|
if err := s.mapSvc.RebuildZooms(context.Background()); err != nil {
|
||||||
|
slog.Error("RebuildZooms background failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildZoomsRunning returns true if a rebuild is currently in progress.
|
||||||
|
func (s *AdminService) RebuildZoomsRunning() bool {
|
||||||
|
s.rebuildMu.Lock()
|
||||||
|
defer s.rebuildMu.Unlock()
|
||||||
|
return s.rebuildRunning
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,14 +35,23 @@ func NewExportService(st *store.Store, mapSvc *MapService) *ExportService {
|
|||||||
return &ExportService{st: st, mapSvc: mapSvc}
|
return &ExportService{st: st, mapSvc: mapSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export writes all map data as a ZIP archive to the given writer.
|
// exportEntry describes a single grid PNG to copy into the ZIP (collected inside a read-only View).
|
||||||
func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
type exportEntry struct {
|
||||||
zw := zip.NewWriter(w)
|
ZipPath string // e.g. "1/grid1.png"
|
||||||
defer zw.Close()
|
FilePath string // absolute path on disk
|
||||||
|
}
|
||||||
|
|
||||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
// Export writes all map data as a ZIP archive to the given writer.
|
||||||
maps := map[int]mapData{}
|
// It uses a read-only View to collect data, then builds the ZIP outside the transaction
|
||||||
gridMap := map[string]int{}
|
// so that the write lock is not held during file I/O.
|
||||||
|
func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
||||||
|
var maps map[int]mapData
|
||||||
|
var gridMap map[string]int
|
||||||
|
var filesToCopy []exportEntry
|
||||||
|
|
||||||
|
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||||
|
maps = map[int]mapData{}
|
||||||
|
gridMap = map[string]int{}
|
||||||
|
|
||||||
grids := tx.Bucket(store.BucketGrids)
|
grids := tx.Bucket(store.BucketGrids)
|
||||||
if grids == nil {
|
if grids == nil {
|
||||||
@@ -54,6 +63,11 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := grids.ForEach(func(k, v []byte) error {
|
if err := grids.ForEach(func(k, v []byte) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
gd := app.GridData{}
|
gd := app.GridData{}
|
||||||
if err := json.Unmarshal(v, &gd); err != nil {
|
if err := json.Unmarshal(v, &gd); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -84,17 +98,11 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
|||||||
if err := json.Unmarshal(tdraw, &td); err != nil {
|
if err := json.Unmarshal(tdraw, &td); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fw, err := zw.Create(fmt.Sprintf("%d/%s.png", gd.Map, gd.ID))
|
filesToCopy = append(filesToCopy, exportEntry{
|
||||||
if err != nil {
|
ZipPath: fmt.Sprintf("%d/%s.png", gd.Map, gd.ID),
|
||||||
return err
|
FilePath: filepath.Join(s.mapSvc.GridStorage(), td.File),
|
||||||
}
|
})
|
||||||
f, err := os.Open(filepath.Join(s.mapSvc.GridStorage(), td.File))
|
return nil
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = io.Copy(fw, f)
|
|
||||||
f.Close()
|
|
||||||
return err
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -104,6 +112,11 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
|||||||
markersgrid := markersb.Bucket(store.BucketMarkersGrid)
|
markersgrid := markersb.Bucket(store.BucketMarkersGrid)
|
||||||
if markersgrid != nil {
|
if markersgrid != nil {
|
||||||
markersgrid.ForEach(func(k, v []byte) error {
|
markersgrid.ForEach(func(k, v []byte) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
marker := app.Marker{}
|
marker := app.Marker{}
|
||||||
if json.Unmarshal(v, &marker) != nil {
|
if json.Unmarshal(v, &marker) != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -115,16 +128,41 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for mapid, md := range maps {
|
|
||||||
fw, err := zw.Create(fmt.Sprintf("%d/grids.json", mapid))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
json.NewEncoder(fw).Encode(md)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ZIP outside the transaction so the write lock is not held during file I/O.
|
||||||
|
zw := zip.NewWriter(w)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
for _, e := range filesToCopy {
|
||||||
|
fw, err := zw.Create(e.ZipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Open(e.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(fw, f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for mapid, md := range maps {
|
||||||
|
fw, err := zw.Create(fmt.Sprintf("%d/grids.json", mapid))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(fw).Encode(md); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge imports map data from a ZIP file.
|
// Merge imports map data from a ZIP file.
|
||||||
|
|||||||
@@ -184,6 +184,38 @@ func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom i
|
|||||||
return td
|
return td
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
|
||||||
|
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
|
||||||
|
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
|
||||||
|
coords := []app.Coord{
|
||||||
|
{X: c.X*2 + 0, Y: c.Y*2 + 0},
|
||||||
|
{X: c.X*2 + 1, Y: c.Y*2 + 0},
|
||||||
|
{X: c.X*2 + 0, Y: c.Y*2 + 1},
|
||||||
|
{X: c.X*2 + 1, Y: c.Y*2 + 1},
|
||||||
|
}
|
||||||
|
keys := make([]string, len(coords))
|
||||||
|
for i := range coords {
|
||||||
|
keys[i] = coords[i].Name()
|
||||||
|
}
|
||||||
|
var rawMap map[string][]byte
|
||||||
|
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||||
|
rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*app.TileData, 4)
|
||||||
|
for i, k := range keys {
|
||||||
|
if raw, ok := rawMap[k]; ok && len(raw) > 0 {
|
||||||
|
td := &app.TileData{}
|
||||||
|
if json.Unmarshal(raw, td) == nil {
|
||||||
|
result[i] = td
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// SaveTile persists a tile and broadcasts the update.
|
// SaveTile persists a tile and broadcasts the update.
|
||||||
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
|
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
|
||||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||||
@@ -203,32 +235,28 @@ func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles.
|
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
|
||||||
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
|
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
|
||||||
|
subTiles := s.getSubTiles(ctx, mapid, c, z)
|
||||||
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
||||||
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
||||||
for x := 0; x <= 1; x++ {
|
for i := 0; i < 4; i++ {
|
||||||
for y := 0; y <= 1; y++ {
|
td := subTiles[i]
|
||||||
subC := c
|
if td == nil || td.File == "" {
|
||||||
subC.X *= 2
|
continue
|
||||||
subC.Y *= 2
|
|
||||||
subC.X += x
|
|
||||||
subC.Y += y
|
|
||||||
td := s.GetTile(ctx, mapid, subC, z-1)
|
|
||||||
if td == nil || td.File == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subimg, _, err := image.Decode(subf)
|
|
||||||
subf.Close()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
|
|
||||||
}
|
}
|
||||||
|
x := i % 2
|
||||||
|
y := i / 2
|
||||||
|
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subimg, _, err := image.Decode(subf)
|
||||||
|
subf.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
|
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
|
||||||
slog.Error("failed to create zoom dir", "error", err)
|
slog.Error("failed to create zoom dir", "error", err)
|
||||||
@@ -266,6 +294,11 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
b.ForEach(func(k, v []byte) error {
|
b.ForEach(func(k, v []byte) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
grid := app.GridData{}
|
grid := app.GridData{}
|
||||||
if err := json.Unmarshal(v, &grid); err != nil {
|
if err := json.Unmarshal(v, &grid); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -282,6 +315,9 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for g, id := range saveGrid {
|
for g, id := range saveGrid {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
|
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
|
||||||
if _, err := os.Stat(f); err != nil {
|
if _, err := os.Stat(f); err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -289,6 +325,9 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
|
|||||||
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
process := needProcess
|
process := needProcess
|
||||||
needProcess = map[zoomproc]struct{}{}
|
needProcess = map[zoomproc]struct{}{}
|
||||||
for p := range process {
|
for p := range process {
|
||||||
@@ -327,6 +366,11 @@ func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
|||||||
var cache []TileCache
|
var cache []TileCache
|
||||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||||
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
td := app.TileData{}
|
td := app.TileData{}
|
||||||
if err := json.Unmarshal(v, &td); err != nil {
|
if err := json.Unmarshal(v, &td); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ func New(db *bbolt.DB) *Store {
|
|||||||
return &Store{db: db}
|
return &Store{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// View runs fn in a read-only transaction. Checks context before starting.
|
// View runs fn in a read-only transaction. It checks context before starting.
|
||||||
|
// Long-running callbacks (e.g. large ForEach or ForEachTile) should check ctx.Done()
|
||||||
|
// periodically and return ctx.Err() to abort early when the context is cancelled.
|
||||||
func (s *Store) View(ctx context.Context, fn func(tx *bbolt.Tx) error) error {
|
func (s *Store) View(ctx context.Context, fn func(tx *bbolt.Tx) error) error {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -27,7 +29,8 @@ func (s *Store) View(ctx context.Context, fn func(tx *bbolt.Tx) error) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update runs fn in a read-write transaction. Checks context before starting.
|
// Update runs fn in a read-write transaction. It checks context before starting.
|
||||||
|
// Long-running callbacks should check ctx.Done() periodically and return ctx.Err() to abort.
|
||||||
func (s *Store) Update(ctx context.Context, fn func(tx *bbolt.Tx) error) error {
|
func (s *Store) Update(ctx context.Context, fn func(tx *bbolt.Tx) error) error {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -284,6 +287,30 @@ func (s *Store) GetTile(tx *bbolt.Tx, mapID, zoom int, coordKey string) []byte {
|
|||||||
return zoomB.Get([]byte(coordKey))
|
return zoomB.Get([]byte(coordKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTiles returns raw JSON for multiple tiles in the same map/zoom in one transaction.
|
||||||
|
// Keys that are not found are omitted from the result. Coord keys are in the form "x_y".
|
||||||
|
func (s *Store) GetTiles(tx *bbolt.Tx, mapID, zoom int, coordKeys []string) map[string][]byte {
|
||||||
|
out := make(map[string][]byte, len(coordKeys))
|
||||||
|
tiles := tx.Bucket(BucketTiles)
|
||||||
|
if tiles == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
mapB := tiles.Bucket([]byte(strconv.Itoa(mapID)))
|
||||||
|
if mapB == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
zoomB := mapB.Bucket([]byte(strconv.Itoa(zoom)))
|
||||||
|
if zoomB == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, k := range coordKeys {
|
||||||
|
if v := zoomB.Get([]byte(k)); v != nil {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// PutTile stores a tile entry (creates nested buckets as needed).
|
// PutTile stores a tile entry (creates nested buckets as needed).
|
||||||
func (s *Store) PutTile(tx *bbolt.Tx, mapID, zoom int, coordKey string, raw []byte) error {
|
func (s *Store) PutTile(tx *bbolt.Tx, mapID, zoom int, coordKey string, raw []byte) error {
|
||||||
tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
|
tiles, err := tx.CreateBucketIfNotExists(BucketTiles)
|
||||||
|
|||||||
Reference in New Issue
Block a user