Refactor Dockerignore and enhance Leaflet styles for improved map functionality
- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded. - Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups. - Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
This commit is contained in:
@@ -1,27 +1,27 @@
|
||||
# Git and IDE
|
||||
.git
|
||||
.gitignore
|
||||
.cursor
|
||||
.cursorignore
|
||||
*.md
|
||||
*.plan.md
|
||||
|
||||
# Old Vue 2 frontend (not used in build)
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
# Nuxt (built in frontendbuilder stage)
|
||||
frontend-nuxt/node_modules
|
||||
frontend-nuxt/.nuxt
|
||||
frontend-nuxt/.output
|
||||
|
||||
# Runtime data (mounted at run time, not needed for build)
|
||||
grids
|
||||
|
||||
# Backup dir often has root-only permissions; exclude from build context
|
||||
backup
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.env*
|
||||
.DS_Store
|
||||
# Git and IDE
|
||||
.git
|
||||
.gitignore
|
||||
.cursor
|
||||
.cursorignore
|
||||
*.md
|
||||
*.plan.md
|
||||
|
||||
# Old Vue 2 frontend (not used in build)
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
# Nuxt (built in frontendbuilder stage)
|
||||
frontend-nuxt/node_modules
|
||||
frontend-nuxt/.nuxt
|
||||
frontend-nuxt/.output
|
||||
|
||||
# Runtime data (mounted at run time, not needed for build)
|
||||
grids
|
||||
|
||||
# Backup dir often has root-only permissions; exclude from build context
|
||||
backup
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.env*
|
||||
.DS_Store
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
/* Map container background from theme (DaisyUI base-200) */
|
||||
.leaflet-container {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied
|
||||
(e.g. due to cache, Nuxt hydration, or load event order). */
|
||||
.leaflet-tile {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Subtle highlight when a tile is updated via SSE (reduced intensity to limit flicker). */
|
||||
@keyframes tile-fresh-glow {
|
||||
0% {
|
||||
opacity: 0.92;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.leaflet-tile.tile-fresh {
|
||||
animation: tile-fresh-glow 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Leaflet tooltip: use theme colors (dark/light) */
|
||||
.leaflet-tooltip {
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
border-color: var(--color-base-300);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
border-top-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
border-bottom-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
border-left-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
border-right-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* Leaflet popup: use theme colors (dark/light) */
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Map container background from theme (DaisyUI base-200) */
|
||||
.leaflet-container {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied
|
||||
(e.g. due to cache, Nuxt hydration, or load event order). */
|
||||
.leaflet-tile {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Subtle highlight when a tile is updated via SSE (reduced intensity to limit flicker). */
|
||||
@keyframes tile-fresh-glow {
|
||||
0% {
|
||||
opacity: 0.92;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.leaflet-tile.tile-fresh {
|
||||
animation: tile-fresh-glow 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Leaflet tooltip: use theme colors (dark/light) */
|
||||
.leaflet-tooltip {
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
border-color: var(--color-base-300);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
border-top-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
border-bottom-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
border-left-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
border-right-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* Leaflet popup: use theme colors (dark/light) */
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1,284 +1,284 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { useMapApi } from '../useMapApi'
|
||||
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
app: { baseURL: '/' },
|
||||
public: { apiBase: '/map/api' },
|
||||
}))
|
||||
|
||||
function mockFetch(status: number, body: unknown, contentType = 'application/json') {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers({ 'content-type': contentType }),
|
||||
json: () => Promise.resolve(body),
|
||||
} as Response)
|
||||
}
|
||||
|
||||
describe('useMapApi', () => {
|
||||
let originalFetch: typeof globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('fetches config from API', async () => {
|
||||
const data = { title: 'Test', auths: ['map'] }
|
||||
globalThis.fetch = mockFetch(200, data)
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
const result = await getConfig()
|
||||
|
||||
expect(result).toEqual(data)
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('/map/api/config', expect.objectContaining({ credentials: 'include' }))
|
||||
})
|
||||
|
||||
it('throws on 401', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
await expect(getConfig()).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('throws on 403', async () => {
|
||||
globalThis.fetch = mockFetch(403, { error: 'Forbidden' })
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
await expect(getConfig()).rejects.toThrow('Forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCharacters', () => {
|
||||
it('fetches characters', async () => {
|
||||
const chars = [{ name: 'Hero', id: 1, map: 1, position: { x: 0, y: 0 }, type: 'player' }]
|
||||
globalThis.fetch = mockFetch(200, chars)
|
||||
|
||||
const { getCharacters } = useMapApi()
|
||||
const result = await getCharacters()
|
||||
expect(result).toEqual(chars)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarkers', () => {
|
||||
it('fetches markers', async () => {
|
||||
const markers = [{ name: 'Tower', id: 1, map: 1, position: { x: 10, y: 20 }, image: 'gfx/terobjs/mm/tower', hidden: false }]
|
||||
globalThis.fetch = mockFetch(200, markers)
|
||||
|
||||
const { getMarkers } = useMapApi()
|
||||
const result = await getMarkers()
|
||||
expect(result).toEqual(markers)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaps', () => {
|
||||
it('fetches maps', async () => {
|
||||
const maps = { '1': { ID: 1, Name: 'world' } }
|
||||
globalThis.fetch = mockFetch(200, maps)
|
||||
|
||||
const { getMaps } = useMapApi()
|
||||
const result = await getMaps()
|
||||
expect(result).toEqual(maps)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('sends credentials and returns me response', async () => {
|
||||
const meResp = { username: 'alice', auths: ['map'] }
|
||||
globalThis.fetch = mockFetch(200, meResp)
|
||||
|
||||
const { login } = useMapApi()
|
||||
const result = await login('alice', 'secret')
|
||||
|
||||
expect(result).toEqual(meResp)
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user: 'alice', pass: 'secret' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on 401 with error message', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ error: 'Invalid credentials' }),
|
||||
})
|
||||
|
||||
const { login } = useMapApi()
|
||||
await expect(login('alice', 'wrong')).rejects.toThrow('Invalid credentials')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('sends POST to logout', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 })
|
||||
|
||||
const { logout } = useMapApi()
|
||||
await logout()
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/logout',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('me', () => {
|
||||
it('fetches current user', async () => {
|
||||
const meResp = { username: 'alice', auths: ['map', 'upload'], tokens: ['tok1'], prefix: 'pfx' }
|
||||
globalThis.fetch = mockFetch(200, meResp)
|
||||
|
||||
const { me } = useMapApi()
|
||||
const result = await me()
|
||||
expect(result).toEqual(meResp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupRequired', () => {
|
||||
it('checks setup status', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ setupRequired: true }),
|
||||
})
|
||||
|
||||
const { setupRequired } = useMapApi()
|
||||
const result = await setupRequired()
|
||||
expect(result).toEqual({ setupRequired: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('oauthProviders', () => {
|
||||
it('returns providers list', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(['google']),
|
||||
})
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual(['google'])
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'))
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array on non-ok', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('oauthLoginUrl', () => {
|
||||
it('builds OAuth login URL', () => {
|
||||
// happy-dom needs an absolute URL for `new URL()`. The source code
|
||||
// creates `new URL(apiBase + path)` which is relative.
|
||||
// Verify the underlying apiBase and path construction instead.
|
||||
const { apiBase } = useMapApi()
|
||||
const expected = `${apiBase}/oauth/google/login`
|
||||
expect(expected).toBe('/map/api/oauth/google/login')
|
||||
})
|
||||
|
||||
it('oauthLoginUrl is a function', () => {
|
||||
const { oauthLoginUrl } = useMapApi()
|
||||
expect(typeof oauthLoginUrl).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onApiError', () => {
|
||||
it('fires callback on 401', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
const callback = vi.fn()
|
||||
|
||||
const { onApiError, getConfig } = useMapApi()
|
||||
onApiError(callback)
|
||||
|
||||
await expect(getConfig()).rejects.toThrow()
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unsubscribe function', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
const callback = vi.fn()
|
||||
|
||||
const { onApiError, getConfig } = useMapApi()
|
||||
const unsub = onApiError(callback)
|
||||
unsub()
|
||||
|
||||
await expect(getConfig()).rejects.toThrow()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin endpoints', () => {
|
||||
it('adminExportUrl returns correct path', () => {
|
||||
const { adminExportUrl } = useMapApi()
|
||||
expect(adminExportUrl()).toBe('/map/api/admin/export')
|
||||
})
|
||||
|
||||
it('adminUsers fetches user list', async () => {
|
||||
globalThis.fetch = mockFetch(200, ['alice', 'bob'])
|
||||
|
||||
const { adminUsers } = useMapApi()
|
||||
const result = await adminUsers()
|
||||
expect(result).toEqual(['alice', 'bob'])
|
||||
})
|
||||
|
||||
it('adminSettings fetches settings', async () => {
|
||||
const settings = { prefix: 'pfx', defaultHide: false, title: 'Map' }
|
||||
globalThis.fetch = mockFetch(200, settings)
|
||||
|
||||
const { adminSettings } = useMapApi()
|
||||
const result = await adminSettings()
|
||||
expect(result).toEqual(settings)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meTokens', () => {
|
||||
it('generates and returns tokens', async () => {
|
||||
globalThis.fetch = mockFetch(200, { tokens: ['tok1', 'tok2'] })
|
||||
|
||||
const { meTokens } = useMapApi()
|
||||
const result = await meTokens()
|
||||
expect(result).toEqual(['tok1', 'tok2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mePassword', () => {
|
||||
it('sends password change', async () => {
|
||||
globalThis.fetch = mockFetch(200, undefined, 'text/plain')
|
||||
|
||||
const { mePassword } = useMapApi()
|
||||
await mePassword('newpass')
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/me/password',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pass: 'newpass' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { useMapApi } from '../useMapApi'
|
||||
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
app: { baseURL: '/' },
|
||||
public: { apiBase: '/map/api' },
|
||||
}))
|
||||
|
||||
function mockFetch(status: number, body: unknown, contentType = 'application/json') {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers({ 'content-type': contentType }),
|
||||
json: () => Promise.resolve(body),
|
||||
} as Response)
|
||||
}
|
||||
|
||||
describe('useMapApi', () => {
|
||||
let originalFetch: typeof globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('fetches config from API', async () => {
|
||||
const data = { title: 'Test', auths: ['map'] }
|
||||
globalThis.fetch = mockFetch(200, data)
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
const result = await getConfig()
|
||||
|
||||
expect(result).toEqual(data)
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('/map/api/config', expect.objectContaining({ credentials: 'include' }))
|
||||
})
|
||||
|
||||
it('throws on 401', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
await expect(getConfig()).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('throws on 403', async () => {
|
||||
globalThis.fetch = mockFetch(403, { error: 'Forbidden' })
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
await expect(getConfig()).rejects.toThrow('Forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCharacters', () => {
|
||||
it('fetches characters', async () => {
|
||||
const chars = [{ name: 'Hero', id: 1, map: 1, position: { x: 0, y: 0 }, type: 'player' }]
|
||||
globalThis.fetch = mockFetch(200, chars)
|
||||
|
||||
const { getCharacters } = useMapApi()
|
||||
const result = await getCharacters()
|
||||
expect(result).toEqual(chars)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarkers', () => {
|
||||
it('fetches markers', async () => {
|
||||
const markers = [{ name: 'Tower', id: 1, map: 1, position: { x: 10, y: 20 }, image: 'gfx/terobjs/mm/tower', hidden: false }]
|
||||
globalThis.fetch = mockFetch(200, markers)
|
||||
|
||||
const { getMarkers } = useMapApi()
|
||||
const result = await getMarkers()
|
||||
expect(result).toEqual(markers)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaps', () => {
|
||||
it('fetches maps', async () => {
|
||||
const maps = { '1': { ID: 1, Name: 'world' } }
|
||||
globalThis.fetch = mockFetch(200, maps)
|
||||
|
||||
const { getMaps } = useMapApi()
|
||||
const result = await getMaps()
|
||||
expect(result).toEqual(maps)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('sends credentials and returns me response', async () => {
|
||||
const meResp = { username: 'alice', auths: ['map'] }
|
||||
globalThis.fetch = mockFetch(200, meResp)
|
||||
|
||||
const { login } = useMapApi()
|
||||
const result = await login('alice', 'secret')
|
||||
|
||||
expect(result).toEqual(meResp)
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user: 'alice', pass: 'secret' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on 401 with error message', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ error: 'Invalid credentials' }),
|
||||
})
|
||||
|
||||
const { login } = useMapApi()
|
||||
await expect(login('alice', 'wrong')).rejects.toThrow('Invalid credentials')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('sends POST to logout', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 })
|
||||
|
||||
const { logout } = useMapApi()
|
||||
await logout()
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/logout',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('me', () => {
|
||||
it('fetches current user', async () => {
|
||||
const meResp = { username: 'alice', auths: ['map', 'upload'], tokens: ['tok1'], prefix: 'pfx' }
|
||||
globalThis.fetch = mockFetch(200, meResp)
|
||||
|
||||
const { me } = useMapApi()
|
||||
const result = await me()
|
||||
expect(result).toEqual(meResp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupRequired', () => {
|
||||
it('checks setup status', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ setupRequired: true }),
|
||||
})
|
||||
|
||||
const { setupRequired } = useMapApi()
|
||||
const result = await setupRequired()
|
||||
expect(result).toEqual({ setupRequired: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('oauthProviders', () => {
|
||||
it('returns providers list', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(['google']),
|
||||
})
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual(['google'])
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'))
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array on non-ok', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('oauthLoginUrl', () => {
|
||||
it('builds OAuth login URL', () => {
|
||||
// happy-dom needs an absolute URL for `new URL()`. The source code
|
||||
// creates `new URL(apiBase + path)` which is relative.
|
||||
// Verify the underlying apiBase and path construction instead.
|
||||
const { apiBase } = useMapApi()
|
||||
const expected = `${apiBase}/oauth/google/login`
|
||||
expect(expected).toBe('/map/api/oauth/google/login')
|
||||
})
|
||||
|
||||
it('oauthLoginUrl is a function', () => {
|
||||
const { oauthLoginUrl } = useMapApi()
|
||||
expect(typeof oauthLoginUrl).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onApiError', () => {
|
||||
it('fires callback on 401', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
const callback = vi.fn()
|
||||
|
||||
const { onApiError, getConfig } = useMapApi()
|
||||
onApiError(callback)
|
||||
|
||||
await expect(getConfig()).rejects.toThrow()
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unsubscribe function', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
const callback = vi.fn()
|
||||
|
||||
const { onApiError, getConfig } = useMapApi()
|
||||
const unsub = onApiError(callback)
|
||||
unsub()
|
||||
|
||||
await expect(getConfig()).rejects.toThrow()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin endpoints', () => {
|
||||
it('adminExportUrl returns correct path', () => {
|
||||
const { adminExportUrl } = useMapApi()
|
||||
expect(adminExportUrl()).toBe('/map/api/admin/export')
|
||||
})
|
||||
|
||||
it('adminUsers fetches user list', async () => {
|
||||
globalThis.fetch = mockFetch(200, ['alice', 'bob'])
|
||||
|
||||
const { adminUsers } = useMapApi()
|
||||
const result = await adminUsers()
|
||||
expect(result).toEqual(['alice', 'bob'])
|
||||
})
|
||||
|
||||
it('adminSettings fetches settings', async () => {
|
||||
const settings = { prefix: 'pfx', defaultHide: false, title: 'Map' }
|
||||
globalThis.fetch = mockFetch(200, settings)
|
||||
|
||||
const { adminSettings } = useMapApi()
|
||||
const result = await adminSettings()
|
||||
expect(result).toEqual(settings)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meTokens', () => {
|
||||
it('generates and returns tokens', async () => {
|
||||
globalThis.fetch = mockFetch(200, { tokens: ['tok1', 'tok2'] })
|
||||
|
||||
const { meTokens } = useMapApi()
|
||||
const result = await meTokens()
|
||||
expect(result).toEqual(['tok1', 'tok2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mePassword', () => {
|
||||
it('sends password change', async () => {
|
||||
globalThis.fetch = mockFetch(200, undefined, 'text/plain')
|
||||
|
||||
const { mePassword } = useMapApi()
|
||||
await mePassword('newpass')
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/me/password',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pass: 'newpass' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,295 +1,295 @@
|
||||
import type {
|
||||
Character,
|
||||
ConfigResponse,
|
||||
MapInfo,
|
||||
MapInfoAdmin,
|
||||
Marker,
|
||||
MeResponse,
|
||||
SettingsResponse,
|
||||
} from '~/types/api'
|
||||
|
||||
export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse }
|
||||
|
||||
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
|
||||
const onApiErrorCallbacks = 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() {
|
||||
const config = useRuntimeConfig()
|
||||
const apiBase = config.public.apiBase as string
|
||||
|
||||
/** Subscribe to API auth errors (401). Returns unsubscribe function. */
|
||||
function onApiError(cb: () => void): () => void {
|
||||
const id = Symbol()
|
||||
onApiErrorCallbacks.set(id, cb)
|
||||
return () => onApiErrorCallbacks.delete(id)
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}`
|
||||
const res = await fetch(url, { credentials: 'include', ...opts })
|
||||
// Only redirect to login on 401 (session invalid); 403 = forbidden (no permission)
|
||||
if (res.status === 401) {
|
||||
onApiErrorCallbacks.forEach((cb) => cb())
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (res.status === 403) throw new Error('Forbidden')
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||
return res.json() as Promise<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() {
|
||||
return requestDeduped<ConfigResponse>('config')
|
||||
}
|
||||
|
||||
async function getCharacters() {
|
||||
return requestDeduped<Character[]>('v1/characters')
|
||||
}
|
||||
|
||||
async function getMarkers() {
|
||||
return requestDeduped<Marker[]>('v1/markers')
|
||||
}
|
||||
|
||||
async function getMaps() {
|
||||
return requestDeduped<Record<string, MapInfo>>('maps')
|
||||
}
|
||||
|
||||
// Auth
|
||||
async function login(user: string, pass: string) {
|
||||
const res = await fetch(`${apiBase}/login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user, pass }),
|
||||
})
|
||||
if (res.status === 401) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(data.error || 'Unauthorized')
|
||||
}
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
return res.json() as Promise<MeResponse>
|
||||
}
|
||||
|
||||
/** OAuth login URL for redirect (full page navigation). */
|
||||
function oauthLoginUrl(provider: string, redirect?: string): string {
|
||||
const url = new URL(`${apiBase}/oauth/${provider}/login`)
|
||||
if (redirect) url.searchParams.set('redirect', redirect)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/** List of configured OAuth providers. */
|
||||
async function oauthProviders(): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return Array.isArray(data) ? data : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
mePromise = null
|
||||
inFlightByKey.clear()
|
||||
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
|
||||
}
|
||||
|
||||
async function me() {
|
||||
if (mePromise) return mePromise
|
||||
mePromise = request<MeResponse>('me').finally(() => {
|
||||
mePromise = null
|
||||
})
|
||||
return mePromise
|
||||
}
|
||||
|
||||
async function meUpdate(body: { email?: string }) {
|
||||
await request('me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Public: whether first-time setup (no users) is required. */
|
||||
async function setupRequired(): Promise<{ setupRequired: boolean }> {
|
||||
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
return res.json() as Promise<{ setupRequired: boolean }>
|
||||
}
|
||||
|
||||
// Profile
|
||||
async function meTokens() {
|
||||
const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' })
|
||||
return data?.tokens ?? []
|
||||
}
|
||||
|
||||
async function mePassword(pass: string) {
|
||||
await request('me/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pass }),
|
||||
})
|
||||
}
|
||||
|
||||
// Admin
|
||||
async function adminUsers() {
|
||||
return request<string[]>('admin/users')
|
||||
}
|
||||
|
||||
async function adminUserByName(name: string) {
|
||||
return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) {
|
||||
await request('admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
async function adminUserDelete(name: string) {
|
||||
await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async function adminSettings() {
|
||||
return request<SettingsResponse>('admin/settings')
|
||||
}
|
||||
|
||||
async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) {
|
||||
await request('admin/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
async function adminMaps() {
|
||||
return request<MapInfoAdmin[]>('admin/maps')
|
||||
}
|
||||
|
||||
async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) {
|
||||
await request(`admin/maps/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
async function adminMapToggleHidden(id: number) {
|
||||
return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' })
|
||||
}
|
||||
|
||||
async function adminWipe() {
|
||||
await request('admin/wipe', { method: 'POST' })
|
||||
}
|
||||
|
||||
async function adminRebuildZooms() {
|
||||
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() {
|
||||
return `${apiBase}/admin/export`
|
||||
}
|
||||
|
||||
async function adminMerge(formData: FormData) {
|
||||
const res = await fetch(`${apiBase}/admin/merge`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
onApiErrorCallbacks.forEach((cb) => cb())
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
}
|
||||
|
||||
async function adminWipeTile(params: { map: number; x: number; y: number }) {
|
||||
const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) })
|
||||
return request(`admin/wipeTile?${qs}`)
|
||||
}
|
||||
|
||||
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
|
||||
const qs = new URLSearchParams({
|
||||
map: String(params.map),
|
||||
fx: String(params.fx),
|
||||
fy: String(params.fy),
|
||||
tx: String(params.tx),
|
||||
ty: String(params.ty),
|
||||
})
|
||||
return request(`admin/setCoords?${qs}`)
|
||||
}
|
||||
|
||||
async function adminHideMarker(params: { id: number }) {
|
||||
const qs = new URLSearchParams({ id: String(params.id) })
|
||||
return request(`admin/hideMarker?${qs}`)
|
||||
}
|
||||
|
||||
return {
|
||||
apiBase,
|
||||
onApiError,
|
||||
getConfig,
|
||||
getCharacters,
|
||||
getMarkers,
|
||||
getMaps,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
meUpdate,
|
||||
oauthLoginUrl,
|
||||
oauthProviders,
|
||||
setupRequired,
|
||||
meTokens,
|
||||
mePassword,
|
||||
adminUsers,
|
||||
adminUserByName,
|
||||
adminUserPost,
|
||||
adminUserDelete,
|
||||
adminSettings,
|
||||
adminSettingsPost,
|
||||
adminMaps,
|
||||
adminMapPost,
|
||||
adminMapToggleHidden,
|
||||
adminWipe,
|
||||
adminRebuildZooms,
|
||||
adminRebuildZoomsStatus,
|
||||
adminExportUrl,
|
||||
adminMerge,
|
||||
adminWipeTile,
|
||||
adminSetCoords,
|
||||
adminHideMarker,
|
||||
}
|
||||
}
|
||||
import type {
|
||||
Character,
|
||||
ConfigResponse,
|
||||
MapInfo,
|
||||
MapInfoAdmin,
|
||||
Marker,
|
||||
MeResponse,
|
||||
SettingsResponse,
|
||||
} from '~/types/api'
|
||||
|
||||
export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse }
|
||||
|
||||
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
|
||||
const onApiErrorCallbacks = 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() {
|
||||
const config = useRuntimeConfig()
|
||||
const apiBase = config.public.apiBase as string
|
||||
|
||||
/** Subscribe to API auth errors (401). Returns unsubscribe function. */
|
||||
function onApiError(cb: () => void): () => void {
|
||||
const id = Symbol()
|
||||
onApiErrorCallbacks.set(id, cb)
|
||||
return () => onApiErrorCallbacks.delete(id)
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}`
|
||||
const res = await fetch(url, { credentials: 'include', ...opts })
|
||||
// Only redirect to login on 401 (session invalid); 403 = forbidden (no permission)
|
||||
if (res.status === 401) {
|
||||
onApiErrorCallbacks.forEach((cb) => cb())
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (res.status === 403) throw new Error('Forbidden')
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||
return res.json() as Promise<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() {
|
||||
return requestDeduped<ConfigResponse>('config')
|
||||
}
|
||||
|
||||
async function getCharacters() {
|
||||
return requestDeduped<Character[]>('v1/characters')
|
||||
}
|
||||
|
||||
async function getMarkers() {
|
||||
return requestDeduped<Marker[]>('v1/markers')
|
||||
}
|
||||
|
||||
async function getMaps() {
|
||||
return requestDeduped<Record<string, MapInfo>>('maps')
|
||||
}
|
||||
|
||||
// Auth
|
||||
async function login(user: string, pass: string) {
|
||||
const res = await fetch(`${apiBase}/login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user, pass }),
|
||||
})
|
||||
if (res.status === 401) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(data.error || 'Unauthorized')
|
||||
}
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
return res.json() as Promise<MeResponse>
|
||||
}
|
||||
|
||||
/** OAuth login URL for redirect (full page navigation). */
|
||||
function oauthLoginUrl(provider: string, redirect?: string): string {
|
||||
const url = new URL(`${apiBase}/oauth/${provider}/login`)
|
||||
if (redirect) url.searchParams.set('redirect', redirect)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/** List of configured OAuth providers. */
|
||||
async function oauthProviders(): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return Array.isArray(data) ? data : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
mePromise = null
|
||||
inFlightByKey.clear()
|
||||
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
|
||||
}
|
||||
|
||||
async function me() {
|
||||
if (mePromise) return mePromise
|
||||
mePromise = request<MeResponse>('me').finally(() => {
|
||||
mePromise = null
|
||||
})
|
||||
return mePromise
|
||||
}
|
||||
|
||||
async function meUpdate(body: { email?: string }) {
|
||||
await request('me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Public: whether first-time setup (no users) is required. */
|
||||
async function setupRequired(): Promise<{ setupRequired: boolean }> {
|
||||
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
return res.json() as Promise<{ setupRequired: boolean }>
|
||||
}
|
||||
|
||||
// Profile
|
||||
async function meTokens() {
|
||||
const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' })
|
||||
return data?.tokens ?? []
|
||||
}
|
||||
|
||||
async function mePassword(pass: string) {
|
||||
await request('me/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pass }),
|
||||
})
|
||||
}
|
||||
|
||||
// Admin
|
||||
async function adminUsers() {
|
||||
return request<string[]>('admin/users')
|
||||
}
|
||||
|
||||
async function adminUserByName(name: string) {
|
||||
return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) {
|
||||
await request('admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
async function adminUserDelete(name: string) {
|
||||
await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async function adminSettings() {
|
||||
return request<SettingsResponse>('admin/settings')
|
||||
}
|
||||
|
||||
async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) {
|
||||
await request('admin/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
async function adminMaps() {
|
||||
return request<MapInfoAdmin[]>('admin/maps')
|
||||
}
|
||||
|
||||
async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) {
|
||||
await request(`admin/maps/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
async function adminMapToggleHidden(id: number) {
|
||||
return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' })
|
||||
}
|
||||
|
||||
async function adminWipe() {
|
||||
await request('admin/wipe', { method: 'POST' })
|
||||
}
|
||||
|
||||
async function adminRebuildZooms() {
|
||||
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() {
|
||||
return `${apiBase}/admin/export`
|
||||
}
|
||||
|
||||
async function adminMerge(formData: FormData) {
|
||||
const res = await fetch(`${apiBase}/admin/merge`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
onApiErrorCallbacks.forEach((cb) => cb())
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) throw new Error(`API ${res.status}`)
|
||||
}
|
||||
|
||||
async function adminWipeTile(params: { map: number; x: number; y: number }) {
|
||||
const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) })
|
||||
return request(`admin/wipeTile?${qs}`)
|
||||
}
|
||||
|
||||
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
|
||||
const qs = new URLSearchParams({
|
||||
map: String(params.map),
|
||||
fx: String(params.fx),
|
||||
fy: String(params.fy),
|
||||
tx: String(params.tx),
|
||||
ty: String(params.ty),
|
||||
})
|
||||
return request(`admin/setCoords?${qs}`)
|
||||
}
|
||||
|
||||
async function adminHideMarker(params: { id: number }) {
|
||||
const qs = new URLSearchParams({ id: String(params.id) })
|
||||
return request(`admin/hideMarker?${qs}`)
|
||||
}
|
||||
|
||||
return {
|
||||
apiBase,
|
||||
onApiError,
|
||||
getConfig,
|
||||
getCharacters,
|
||||
getMarkers,
|
||||
getMaps,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
meUpdate,
|
||||
oauthLoginUrl,
|
||||
oauthProviders,
|
||||
setupRequired,
|
||||
meTokens,
|
||||
mePassword,
|
||||
adminUsers,
|
||||
adminUserByName,
|
||||
adminUserPost,
|
||||
adminUserDelete,
|
||||
adminSettings,
|
||||
adminSettingsPost,
|
||||
adminMaps,
|
||||
adminMapPost,
|
||||
adminMapToggleHidden,
|
||||
adminWipe,
|
||||
adminRebuildZooms,
|
||||
adminRebuildZoomsStatus,
|
||||
adminExportUrl,
|
||||
adminMerge,
|
||||
adminWipeTile,
|
||||
adminSetCoords,
|
||||
adminHideMarker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
import type L from 'leaflet'
|
||||
import { HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import type { MapInfo } from '~/types/api'
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
/** Known marker icon paths (without .png) to preload so markers render without broken images. */
|
||||
const MARKER_ICON_PATHS = [
|
||||
'gfx/terobjs/mm/custom',
|
||||
'gfx/terobjs/mm/tower',
|
||||
'gfx/terobjs/mm/village',
|
||||
'gfx/terobjs/mm/dungeon',
|
||||
'gfx/terobjs/mm/cave',
|
||||
'gfx/terobjs/mm/settlement',
|
||||
'gfx/invobjs/small/bush',
|
||||
'gfx/invobjs/small/bumling',
|
||||
]
|
||||
|
||||
/**
|
||||
* Preloads marker icon images so they are in the browser cache before markers render.
|
||||
* Call from client only. resolvePath should produce absolute URLs for static assets.
|
||||
*/
|
||||
export function preloadMarkerIcons(resolvePath: (path: string) => string): void {
|
||||
if (import.meta.server) return
|
||||
for (const base of MARKER_ICON_PATHS) {
|
||||
const url = resolvePath(`${base}.png`)
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
}
|
||||
}
|
||||
|
||||
export interface MapInitResult {
|
||||
map: L.Map
|
||||
layer: SmartTileLayerInstance
|
||||
overlayLayer: SmartTileLayerInstance
|
||||
markerLayer: L.LayerGroup
|
||||
backendBase: string
|
||||
}
|
||||
|
||||
export async function initLeafletMap(
|
||||
element: HTMLElement,
|
||||
mapsList: MapInfo[],
|
||||
initialMapId: number
|
||||
): Promise<MapInitResult> {
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
const map = L.map(element, {
|
||||
minZoom: HnHMinZoom,
|
||||
maxZoom: HnHMaxZoom,
|
||||
crs: HnHCRS,
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
inertia: true,
|
||||
zoomAnimation: true,
|
||||
fadeAnimation: true,
|
||||
markerZoomAnimation: true,
|
||||
})
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
|
||||
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
|
||||
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
|
||||
|
||||
const layer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
maxNativeZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 4,
|
||||
})
|
||||
layer.map = initialMapId
|
||||
layer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
|
||||
layer.addTo(map)
|
||||
|
||||
const overlayLayer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
maxNativeZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
opacity: 0.5,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 4,
|
||||
})
|
||||
overlayLayer.map = -1
|
||||
overlayLayer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
overlayLayer.addTo(map)
|
||||
|
||||
const markerLayer = L.layerGroup()
|
||||
markerLayer.addTo(map)
|
||||
markerLayer.setZIndex(600)
|
||||
|
||||
const baseURL = useRuntimeConfig().app.baseURL ?? '/'
|
||||
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
|
||||
L.Icon.Default.imagePath = markerIconPath
|
||||
|
||||
const resolvePath = (path: string) => {
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return baseURL === '/' ? p : `${baseURL.replace(/\/$/, '')}${p}`
|
||||
}
|
||||
preloadMarkerIcons(resolvePath)
|
||||
|
||||
return { map, layer, overlayLayer, markerLayer, backendBase }
|
||||
}
|
||||
import type L from 'leaflet'
|
||||
import { HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import type { MapInfo } from '~/types/api'
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
/** Known marker icon paths (without .png) to preload so markers render without broken images. */
|
||||
const MARKER_ICON_PATHS = [
|
||||
'gfx/terobjs/mm/custom',
|
||||
'gfx/terobjs/mm/tower',
|
||||
'gfx/terobjs/mm/village',
|
||||
'gfx/terobjs/mm/dungeon',
|
||||
'gfx/terobjs/mm/cave',
|
||||
'gfx/terobjs/mm/settlement',
|
||||
'gfx/invobjs/small/bush',
|
||||
'gfx/invobjs/small/bumling',
|
||||
]
|
||||
|
||||
/**
|
||||
* Preloads marker icon images so they are in the browser cache before markers render.
|
||||
* Call from client only. resolvePath should produce absolute URLs for static assets.
|
||||
*/
|
||||
export function preloadMarkerIcons(resolvePath: (path: string) => string): void {
|
||||
if (import.meta.server) return
|
||||
for (const base of MARKER_ICON_PATHS) {
|
||||
const url = resolvePath(`${base}.png`)
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
}
|
||||
}
|
||||
|
||||
export interface MapInitResult {
|
||||
map: L.Map
|
||||
layer: SmartTileLayerInstance
|
||||
overlayLayer: SmartTileLayerInstance
|
||||
markerLayer: L.LayerGroup
|
||||
backendBase: string
|
||||
}
|
||||
|
||||
export async function initLeafletMap(
|
||||
element: HTMLElement,
|
||||
mapsList: MapInfo[],
|
||||
initialMapId: number
|
||||
): Promise<MapInitResult> {
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
const map = L.map(element, {
|
||||
minZoom: HnHMinZoom,
|
||||
maxZoom: HnHMaxZoom,
|
||||
crs: HnHCRS,
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
inertia: true,
|
||||
zoomAnimation: true,
|
||||
fadeAnimation: true,
|
||||
markerZoomAnimation: true,
|
||||
})
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
|
||||
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
|
||||
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
|
||||
|
||||
const layer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
maxNativeZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 4,
|
||||
})
|
||||
layer.map = initialMapId
|
||||
layer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
|
||||
layer.addTo(map)
|
||||
|
||||
const overlayLayer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
maxNativeZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
opacity: 0.5,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 4,
|
||||
})
|
||||
overlayLayer.map = -1
|
||||
overlayLayer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
overlayLayer.addTo(map)
|
||||
|
||||
const markerLayer = L.layerGroup()
|
||||
markerLayer.addTo(map)
|
||||
markerLayer.setZIndex(600)
|
||||
|
||||
const baseURL = useRuntimeConfig().app.baseURL ?? '/'
|
||||
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
|
||||
L.Icon.Default.imagePath = markerIconPath
|
||||
|
||||
const resolvePath = (path: string) => {
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return baseURL === '/' ? p : `${baseURL.replace(/\/$/, '')}${p}`
|
||||
}
|
||||
preloadMarkerIcons(resolvePath)
|
||||
|
||||
return { map, layer, overlayLayer, markerLayer, backendBase }
|
||||
}
|
||||
|
||||
@@ -1,171 +1,171 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import { TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import type L from 'leaflet'
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
export type SseConnectionState = 'connecting' | 'open' | 'error'
|
||||
|
||||
interface TileUpdate {
|
||||
M: number
|
||||
X: number
|
||||
Y: number
|
||||
Z: number
|
||||
T: number
|
||||
}
|
||||
|
||||
interface MergeEvent {
|
||||
From: number
|
||||
To: number
|
||||
Shift: { x: number; y: number }
|
||||
}
|
||||
|
||||
export interface UseMapUpdatesOptions {
|
||||
backendBase: string
|
||||
layer: SmartTileLayerInstance
|
||||
overlayLayer: SmartTileLayerInstance
|
||||
map: L.Map
|
||||
getCurrentMapId: () => number
|
||||
onMerge: (mapTo: number, shift: { x: number; y: number }) => void
|
||||
/** Optional ref updated with SSE connection state for reconnection indicator. */
|
||||
connectionStateRef?: Ref<SseConnectionState>
|
||||
}
|
||||
|
||||
export interface UseMapUpdatesReturn {
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1000
|
||||
const RECONNECT_MAX_MS = 30000
|
||||
|
||||
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
|
||||
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
|
||||
|
||||
const updatesPath = `${backendBase}/updates`
|
||||
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
|
||||
|
||||
const BATCH_MS = 50
|
||||
let batch: TileUpdate[] = []
|
||||
let batchScheduled = false
|
||||
let source: EventSource | null = null
|
||||
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectDelayMs = RECONNECT_INITIAL_MS
|
||||
let destroyed = false
|
||||
|
||||
const VISIBLE_TILE_BUFFER = 1
|
||||
|
||||
function getVisibleTileBounds() {
|
||||
const zoom = map.getZoom()
|
||||
const px = map.getPixelBounds()
|
||||
if (!px) return null
|
||||
return {
|
||||
zoom,
|
||||
minX: Math.floor(px.min.x / TileSize) - VISIBLE_TILE_BUFFER,
|
||||
maxX: Math.ceil(px.max.x / TileSize) + VISIBLE_TILE_BUFFER,
|
||||
minY: Math.floor(px.min.y / TileSize) - VISIBLE_TILE_BUFFER,
|
||||
maxY: Math.ceil(px.max.y / TileSize) + VISIBLE_TILE_BUFFER,
|
||||
}
|
||||
}
|
||||
|
||||
function applyBatch() {
|
||||
batchScheduled = false
|
||||
if (batch.length === 0) return
|
||||
const updates = batch
|
||||
batch = []
|
||||
for (const u of updates) {
|
||||
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
|
||||
layer.cache[key] = u.T
|
||||
overlayLayer.cache[key] = u.T
|
||||
}
|
||||
const visible = getVisibleTileBounds()
|
||||
for (const u of updates) {
|
||||
if (visible && u.Z !== visible.zoom) continue
|
||||
if (
|
||||
visible &&
|
||||
(u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY)
|
||||
)
|
||||
continue
|
||||
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z)
|
||||
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBatch() {
|
||||
if (batchScheduled) return
|
||||
batchScheduled = true
|
||||
setTimeout(applyBatch, BATCH_MS)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed || !import.meta.client) return
|
||||
source = new EventSource(updatesUrl)
|
||||
if (connectionStateRef) connectionStateRef.value = 'connecting'
|
||||
|
||||
source.onopen = () => {
|
||||
if (connectionStateRef) connectionStateRef.value = 'open'
|
||||
reconnectDelayMs = RECONNECT_INITIAL_MS
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (destroyed || !source) return
|
||||
if (connectionStateRef) connectionStateRef.value = 'error'
|
||||
source.close()
|
||||
source = null
|
||||
if (destroyed) return
|
||||
reconnectTimeoutId = setTimeout(() => {
|
||||
reconnectTimeoutId = null
|
||||
connect()
|
||||
reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS)
|
||||
}, reconnectDelayMs)
|
||||
}
|
||||
|
||||
source.onmessage = (event: MessageEvent) => {
|
||||
if (connectionStateRef) connectionStateRef.value = 'open'
|
||||
try {
|
||||
const raw: unknown = event?.data
|
||||
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
|
||||
const updates: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(updates)) return
|
||||
for (const u of updates as TileUpdate[]) {
|
||||
batch.push(u)
|
||||
}
|
||||
scheduleBatch()
|
||||
} catch {
|
||||
// Ignore parse errors from SSE
|
||||
}
|
||||
}
|
||||
|
||||
source.addEventListener('merge', (e: MessageEvent) => {
|
||||
try {
|
||||
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
|
||||
if (getCurrentMapId() === merge.From) {
|
||||
const point = map.project(map.getCenter(), 6)
|
||||
const shift = {
|
||||
x: Math.floor(point.x / TileSize) + merge.Shift.x,
|
||||
y: Math.floor(point.y / TileSize) + merge.Shift.y,
|
||||
}
|
||||
onMerge(merge.To, shift)
|
||||
}
|
||||
} catch {
|
||||
// Ignore merge parse errors
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
function cleanup() {
|
||||
destroyed = true
|
||||
if (reconnectTimeoutId != null) {
|
||||
clearTimeout(reconnectTimeoutId)
|
||||
reconnectTimeoutId = null
|
||||
}
|
||||
if (source) {
|
||||
source.close()
|
||||
source = null
|
||||
}
|
||||
}
|
||||
|
||||
return { cleanup }
|
||||
}
|
||||
import type { Ref } from 'vue'
|
||||
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import { TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import type L from 'leaflet'
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
export type SseConnectionState = 'connecting' | 'open' | 'error'
|
||||
|
||||
interface TileUpdate {
|
||||
M: number
|
||||
X: number
|
||||
Y: number
|
||||
Z: number
|
||||
T: number
|
||||
}
|
||||
|
||||
interface MergeEvent {
|
||||
From: number
|
||||
To: number
|
||||
Shift: { x: number; y: number }
|
||||
}
|
||||
|
||||
export interface UseMapUpdatesOptions {
|
||||
backendBase: string
|
||||
layer: SmartTileLayerInstance
|
||||
overlayLayer: SmartTileLayerInstance
|
||||
map: L.Map
|
||||
getCurrentMapId: () => number
|
||||
onMerge: (mapTo: number, shift: { x: number; y: number }) => void
|
||||
/** Optional ref updated with SSE connection state for reconnection indicator. */
|
||||
connectionStateRef?: Ref<SseConnectionState>
|
||||
}
|
||||
|
||||
export interface UseMapUpdatesReturn {
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1000
|
||||
const RECONNECT_MAX_MS = 30000
|
||||
|
||||
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
|
||||
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
|
||||
|
||||
const updatesPath = `${backendBase}/updates`
|
||||
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
|
||||
|
||||
const BATCH_MS = 50
|
||||
let batch: TileUpdate[] = []
|
||||
let batchScheduled = false
|
||||
let source: EventSource | null = null
|
||||
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectDelayMs = RECONNECT_INITIAL_MS
|
||||
let destroyed = false
|
||||
|
||||
const VISIBLE_TILE_BUFFER = 1
|
||||
|
||||
function getVisibleTileBounds() {
|
||||
const zoom = map.getZoom()
|
||||
const px = map.getPixelBounds()
|
||||
if (!px) return null
|
||||
return {
|
||||
zoom,
|
||||
minX: Math.floor(px.min.x / TileSize) - VISIBLE_TILE_BUFFER,
|
||||
maxX: Math.ceil(px.max.x / TileSize) + VISIBLE_TILE_BUFFER,
|
||||
minY: Math.floor(px.min.y / TileSize) - VISIBLE_TILE_BUFFER,
|
||||
maxY: Math.ceil(px.max.y / TileSize) + VISIBLE_TILE_BUFFER,
|
||||
}
|
||||
}
|
||||
|
||||
function applyBatch() {
|
||||
batchScheduled = false
|
||||
if (batch.length === 0) return
|
||||
const updates = batch
|
||||
batch = []
|
||||
for (const u of updates) {
|
||||
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
|
||||
layer.cache[key] = u.T
|
||||
overlayLayer.cache[key] = u.T
|
||||
}
|
||||
const visible = getVisibleTileBounds()
|
||||
for (const u of updates) {
|
||||
if (visible && u.Z !== visible.zoom) continue
|
||||
if (
|
||||
visible &&
|
||||
(u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY)
|
||||
)
|
||||
continue
|
||||
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z)
|
||||
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBatch() {
|
||||
if (batchScheduled) return
|
||||
batchScheduled = true
|
||||
setTimeout(applyBatch, BATCH_MS)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (destroyed || !import.meta.client) return
|
||||
source = new EventSource(updatesUrl)
|
||||
if (connectionStateRef) connectionStateRef.value = 'connecting'
|
||||
|
||||
source.onopen = () => {
|
||||
if (connectionStateRef) connectionStateRef.value = 'open'
|
||||
reconnectDelayMs = RECONNECT_INITIAL_MS
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (destroyed || !source) return
|
||||
if (connectionStateRef) connectionStateRef.value = 'error'
|
||||
source.close()
|
||||
source = null
|
||||
if (destroyed) return
|
||||
reconnectTimeoutId = setTimeout(() => {
|
||||
reconnectTimeoutId = null
|
||||
connect()
|
||||
reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS)
|
||||
}, reconnectDelayMs)
|
||||
}
|
||||
|
||||
source.onmessage = (event: MessageEvent) => {
|
||||
if (connectionStateRef) connectionStateRef.value = 'open'
|
||||
try {
|
||||
const raw: unknown = event?.data
|
||||
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
|
||||
const updates: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(updates)) return
|
||||
for (const u of updates as TileUpdate[]) {
|
||||
batch.push(u)
|
||||
}
|
||||
scheduleBatch()
|
||||
} catch {
|
||||
// Ignore parse errors from SSE
|
||||
}
|
||||
}
|
||||
|
||||
source.addEventListener('merge', (e: MessageEvent) => {
|
||||
try {
|
||||
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
|
||||
if (getCurrentMapId() === merge.From) {
|
||||
const point = map.project(map.getCenter(), 6)
|
||||
const shift = {
|
||||
x: Math.floor(point.x / TileSize) + merge.Shift.x,
|
||||
y: Math.floor(point.y / TileSize) + merge.Shift.y,
|
||||
}
|
||||
onMerge(merge.To, shift)
|
||||
}
|
||||
} catch {
|
||||
// Ignore merge parse errors
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
function cleanup() {
|
||||
destroyed = true
|
||||
if (reconnectTimeoutId != null) {
|
||||
clearTimeout(reconnectTimeoutId)
|
||||
reconnectTimeoutId = null
|
||||
}
|
||||
if (source) {
|
||||
source.close()
|
||||
source = null
|
||||
}
|
||||
}
|
||||
|
||||
return { cleanup }
|
||||
}
|
||||
|
||||
@@ -1,192 +1,192 @@
|
||||
import type L from 'leaflet'
|
||||
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
|
||||
import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
|
||||
export type LeafletApi = L
|
||||
|
||||
function buildCharacterIconUrl(colors: CharacterColors): string {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' +
|
||||
`<path fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>` +
|
||||
'<circle cx="12" cy="8" r="2.5" fill="white"/>' +
|
||||
'</svg>'
|
||||
return 'data:image/svg+xml,' + encodeURIComponent(svg)
|
||||
}
|
||||
|
||||
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
|
||||
return new L.Icon({
|
||||
iconUrl: buildCharacterIconUrl(colors),
|
||||
iconSize: [25, 32],
|
||||
iconAnchor: [12, 17],
|
||||
popupAnchor: [0, -32],
|
||||
tooltipAnchor: [12, 0],
|
||||
})
|
||||
}
|
||||
|
||||
export interface CharacterData {
|
||||
name: string
|
||||
position: { x: number; y: number }
|
||||
type: string
|
||||
id: number
|
||||
map: number
|
||||
/** True when this character was last updated by one of the current user's tokens. */
|
||||
ownedByMe?: boolean
|
||||
}
|
||||
|
||||
export interface CharacterMapViewRef {
|
||||
map: L.Map
|
||||
mapid: number
|
||||
markerLayer?: L.LayerGroup
|
||||
}
|
||||
|
||||
export interface MapCharacter {
|
||||
id: number
|
||||
name: string
|
||||
position: { x: number; y: number }
|
||||
type: string
|
||||
map: number
|
||||
text: string
|
||||
value: number
|
||||
ownedByMe?: boolean
|
||||
leafletMarker: L.Marker | null
|
||||
remove: (mapview: CharacterMapViewRef) => void
|
||||
add: (mapview: CharacterMapViewRef) => void
|
||||
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
|
||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
}
|
||||
|
||||
const CHARACTER_MOVE_DURATION_MS = 280
|
||||
|
||||
function easeOutQuad(t: number): number {
|
||||
return t * (2 - t)
|
||||
}
|
||||
|
||||
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
|
||||
let leafletMarker: L.Marker | null = null
|
||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
let ownedByMe = data.ownedByMe ?? false
|
||||
let animationFrameId: number | null = null
|
||||
const colors = getColorForCharacterId(data.id, { ownedByMe })
|
||||
let characterIcon = createCharacterIcon(L, colors)
|
||||
|
||||
const character: MapCharacter = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
position: { ...data.position },
|
||||
type: data.type,
|
||||
map: data.map,
|
||||
text: data.name,
|
||||
value: data.id,
|
||||
get ownedByMe() {
|
||||
return ownedByMe
|
||||
},
|
||||
set ownedByMe(v: boolean | undefined) {
|
||||
ownedByMe = v ?? false
|
||||
},
|
||||
|
||||
get leafletMarker() {
|
||||
return leafletMarker
|
||||
},
|
||||
|
||||
remove(mapview: CharacterMapViewRef): void {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
if (leafletMarker) {
|
||||
const layer = mapview.markerLayer ?? mapview.map
|
||||
layer.removeLayer(leafletMarker)
|
||||
leafletMarker = null
|
||||
}
|
||||
},
|
||||
|
||||
add(mapview: CharacterMapViewRef): void {
|
||||
if (character.map === mapview.mapid) {
|
||||
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { icon: characterIcon })
|
||||
const gridX = Math.floor(character.position.x / TileSize)
|
||||
const gridY = Math.floor(character.position.y / TileSize)
|
||||
const tooltipContent = `${character.name} · ${gridX}, ${gridY}`
|
||||
leafletMarker.bindTooltip(tooltipContent, {
|
||||
direction: 'top',
|
||||
permanent: false,
|
||||
offset: L.point(-10.5, -18),
|
||||
})
|
||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
const targetLayer = mapview.markerLayer ?? mapview.map
|
||||
leafletMarker.addTo(targetLayer)
|
||||
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
|
||||
if (markerEl) markerEl.setAttribute('aria-label', character.name)
|
||||
}
|
||||
},
|
||||
|
||||
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
|
||||
const updatedOwnedByMe = (updated as { ownedByMe?: boolean }).ownedByMe ?? false
|
||||
if (ownedByMe !== updatedOwnedByMe) {
|
||||
ownedByMe = updatedOwnedByMe
|
||||
characterIcon = createCharacterIcon(L, getColorForCharacterId(character.id, { ownedByMe }))
|
||||
if (leafletMarker) leafletMarker.setIcon(characterIcon)
|
||||
}
|
||||
if (character.map !== updated.map) {
|
||||
character.remove(mapview)
|
||||
}
|
||||
character.map = updated.map
|
||||
character.position = { ...updated.position }
|
||||
if (!leafletMarker && character.map === mapview.mapid) {
|
||||
character.add(mapview)
|
||||
return
|
||||
}
|
||||
if (!leafletMarker) return
|
||||
|
||||
const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
|
||||
const updateTooltip = (): void => {
|
||||
const gridX = Math.floor(character.position.x / TileSize)
|
||||
const gridY = Math.floor(character.position.y / TileSize)
|
||||
leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`)
|
||||
}
|
||||
|
||||
const from = leafletMarker.getLatLng()
|
||||
const latDelta = newLatLng.lat - from.lat
|
||||
const lngDelta = newLatLng.lng - from.lng
|
||||
const distSq = latDelta * latDelta + lngDelta * lngDelta
|
||||
if (distSq < 1e-12) {
|
||||
updateTooltip()
|
||||
return
|
||||
}
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
const duration = CHARACTER_MOVE_DURATION_MS
|
||||
|
||||
const tick = (): void => {
|
||||
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
|
||||
const t = Math.min(1, elapsed / duration)
|
||||
const eased = easeOutQuad(t)
|
||||
leafletMarker?.setLatLng({
|
||||
lat: from.lat + latDelta * eased,
|
||||
lng: from.lng + lngDelta * eased,
|
||||
})
|
||||
if (t >= 1) {
|
||||
animationFrameId = null
|
||||
leafletMarker?.setLatLng(newLatLng)
|
||||
updateTooltip()
|
||||
return
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
},
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onClick = callback
|
||||
},
|
||||
}
|
||||
|
||||
return character
|
||||
}
|
||||
import type L from 'leaflet'
|
||||
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
|
||||
import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
|
||||
export type LeafletApi = L
|
||||
|
||||
function buildCharacterIconUrl(colors: CharacterColors): string {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' +
|
||||
`<path fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>` +
|
||||
'<circle cx="12" cy="8" r="2.5" fill="white"/>' +
|
||||
'</svg>'
|
||||
return 'data:image/svg+xml,' + encodeURIComponent(svg)
|
||||
}
|
||||
|
||||
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
|
||||
return new L.Icon({
|
||||
iconUrl: buildCharacterIconUrl(colors),
|
||||
iconSize: [25, 32],
|
||||
iconAnchor: [12, 17],
|
||||
popupAnchor: [0, -32],
|
||||
tooltipAnchor: [12, 0],
|
||||
})
|
||||
}
|
||||
|
||||
export interface CharacterData {
|
||||
name: string
|
||||
position: { x: number; y: number }
|
||||
type: string
|
||||
id: number
|
||||
map: number
|
||||
/** True when this character was last updated by one of the current user's tokens. */
|
||||
ownedByMe?: boolean
|
||||
}
|
||||
|
||||
export interface CharacterMapViewRef {
|
||||
map: L.Map
|
||||
mapid: number
|
||||
markerLayer?: L.LayerGroup
|
||||
}
|
||||
|
||||
export interface MapCharacter {
|
||||
id: number
|
||||
name: string
|
||||
position: { x: number; y: number }
|
||||
type: string
|
||||
map: number
|
||||
text: string
|
||||
value: number
|
||||
ownedByMe?: boolean
|
||||
leafletMarker: L.Marker | null
|
||||
remove: (mapview: CharacterMapViewRef) => void
|
||||
add: (mapview: CharacterMapViewRef) => void
|
||||
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
|
||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
}
|
||||
|
||||
const CHARACTER_MOVE_DURATION_MS = 280
|
||||
|
||||
function easeOutQuad(t: number): number {
|
||||
return t * (2 - t)
|
||||
}
|
||||
|
||||
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
|
||||
let leafletMarker: L.Marker | null = null
|
||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
let ownedByMe = data.ownedByMe ?? false
|
||||
let animationFrameId: number | null = null
|
||||
const colors = getColorForCharacterId(data.id, { ownedByMe })
|
||||
let characterIcon = createCharacterIcon(L, colors)
|
||||
|
||||
const character: MapCharacter = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
position: { ...data.position },
|
||||
type: data.type,
|
||||
map: data.map,
|
||||
text: data.name,
|
||||
value: data.id,
|
||||
get ownedByMe() {
|
||||
return ownedByMe
|
||||
},
|
||||
set ownedByMe(v: boolean | undefined) {
|
||||
ownedByMe = v ?? false
|
||||
},
|
||||
|
||||
get leafletMarker() {
|
||||
return leafletMarker
|
||||
},
|
||||
|
||||
remove(mapview: CharacterMapViewRef): void {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
if (leafletMarker) {
|
||||
const layer = mapview.markerLayer ?? mapview.map
|
||||
layer.removeLayer(leafletMarker)
|
||||
leafletMarker = null
|
||||
}
|
||||
},
|
||||
|
||||
add(mapview: CharacterMapViewRef): void {
|
||||
if (character.map === mapview.mapid) {
|
||||
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { icon: characterIcon })
|
||||
const gridX = Math.floor(character.position.x / TileSize)
|
||||
const gridY = Math.floor(character.position.y / TileSize)
|
||||
const tooltipContent = `${character.name} · ${gridX}, ${gridY}`
|
||||
leafletMarker.bindTooltip(tooltipContent, {
|
||||
direction: 'top',
|
||||
permanent: false,
|
||||
offset: L.point(-10.5, -18),
|
||||
})
|
||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
const targetLayer = mapview.markerLayer ?? mapview.map
|
||||
leafletMarker.addTo(targetLayer)
|
||||
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
|
||||
if (markerEl) markerEl.setAttribute('aria-label', character.name)
|
||||
}
|
||||
},
|
||||
|
||||
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
|
||||
const updatedOwnedByMe = (updated as { ownedByMe?: boolean }).ownedByMe ?? false
|
||||
if (ownedByMe !== updatedOwnedByMe) {
|
||||
ownedByMe = updatedOwnedByMe
|
||||
characterIcon = createCharacterIcon(L, getColorForCharacterId(character.id, { ownedByMe }))
|
||||
if (leafletMarker) leafletMarker.setIcon(characterIcon)
|
||||
}
|
||||
if (character.map !== updated.map) {
|
||||
character.remove(mapview)
|
||||
}
|
||||
character.map = updated.map
|
||||
character.position = { ...updated.position }
|
||||
if (!leafletMarker && character.map === mapview.mapid) {
|
||||
character.add(mapview)
|
||||
return
|
||||
}
|
||||
if (!leafletMarker) return
|
||||
|
||||
const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
|
||||
const updateTooltip = (): void => {
|
||||
const gridX = Math.floor(character.position.x / TileSize)
|
||||
const gridY = Math.floor(character.position.y / TileSize)
|
||||
leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`)
|
||||
}
|
||||
|
||||
const from = leafletMarker.getLatLng()
|
||||
const latDelta = newLatLng.lat - from.lat
|
||||
const lngDelta = newLatLng.lng - from.lng
|
||||
const distSq = latDelta * latDelta + lngDelta * lngDelta
|
||||
if (distSq < 1e-12) {
|
||||
updateTooltip()
|
||||
return
|
||||
}
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
const duration = CHARACTER_MOVE_DURATION_MS
|
||||
|
||||
const tick = (): void => {
|
||||
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
|
||||
const t = Math.min(1, elapsed / duration)
|
||||
const eased = easeOutQuad(t)
|
||||
leafletMarker?.setLatLng({
|
||||
lat: from.lat + latDelta * eased,
|
||||
lng: from.lng + lngDelta * eased,
|
||||
})
|
||||
if (t >= 1) {
|
||||
animationFrameId = null
|
||||
leafletMarker?.setLatLng(newLatLng)
|
||||
updateTooltip()
|
||||
return
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
},
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onClick = callback
|
||||
},
|
||||
}
|
||||
|
||||
return character
|
||||
}
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import type L from 'leaflet'
|
||||
import type { Map, LayerGroup } from 'leaflet'
|
||||
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
|
||||
|
||||
const { leafletMock } = vi.hoisted(() => {
|
||||
const markerMock = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
setIcon: vi.fn().mockReturnThis(),
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
setTooltipContent: vi.fn().mockReturnThis(),
|
||||
getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }),
|
||||
}
|
||||
const Icon = vi.fn().mockImplementation(function (this: unknown) {
|
||||
return {}
|
||||
})
|
||||
const L = {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon,
|
||||
point: vi.fn((x: number, y: number) => ({ x, y })),
|
||||
}
|
||||
return { leafletMock: L }
|
||||
})
|
||||
|
||||
vi.mock('leaflet', () => ({
|
||||
__esModule: true,
|
||||
default: leafletMock,
|
||||
marker: leafletMock.marker,
|
||||
Icon: leafletMock.Icon,
|
||||
}))
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
TileSize: 100,
|
||||
}))
|
||||
|
||||
function getL(): L {
|
||||
return leafletMock as unknown as L
|
||||
}
|
||||
|
||||
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
|
||||
return {
|
||||
name: 'Hero',
|
||||
position: { x: 100, y: 200 },
|
||||
type: 'player',
|
||||
id: 1,
|
||||
map: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMapViewRef(mapid = 1): CharacterMapViewRef {
|
||||
return {
|
||||
map: {
|
||||
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
removeLayer: vi.fn(),
|
||||
} as unknown as Map,
|
||||
mapid,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createCharacter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates character with correct properties', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
expect(char.id).toBe(1)
|
||||
expect(char.name).toBe('Hero')
|
||||
expect(char.position).toEqual({ x: 100, y: 200 })
|
||||
expect(char.type).toBe('player')
|
||||
expect(char.map).toBe(1)
|
||||
expect(char.text).toBe('Hero')
|
||||
expect(char.value).toBe(1)
|
||||
})
|
||||
|
||||
it('starts with null leaflet marker', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates marker when character is on correct map', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('add creates marker without title and binds Leaflet tooltip', () => {
|
||||
const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(leafletMock.marker).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.not.objectContaining({ title: expect.anything() })
|
||||
)
|
||||
const marker = char.leafletMarker as { bindTooltip: ReturnType<typeof vi.fn> }
|
||||
expect(marker.bindTooltip).toHaveBeenCalledWith(
|
||||
'Hero · 1, 2',
|
||||
expect.objectContaining({ direction: 'top', permanent: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('add does not create marker for different map', () => {
|
||||
const char = createCharacter(makeCharData({ map: 2 }), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and map', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
|
||||
char.update(mapview, {
|
||||
...makeCharData(),
|
||||
position: { x: 300, y: 400 },
|
||||
map: 2,
|
||||
})
|
||||
|
||||
expect(char.position).toEqual({ x: 300, y: 400 })
|
||||
expect(char.map).toBe(2)
|
||||
})
|
||||
|
||||
it('remove on a character without leaflet marker does nothing', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.remove(mapview) // should not throw
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('setClickCallback works', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const cb = vi.fn()
|
||||
char.setClickCallback(cb)
|
||||
})
|
||||
|
||||
it('update with changed ownedByMe updates marker icon', () => {
|
||||
const char = createCharacter(makeCharData({ ownedByMe: false }), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
const marker = char.leafletMarker as { setIcon: ReturnType<typeof vi.fn> }
|
||||
expect(marker.setIcon).not.toHaveBeenCalled()
|
||||
char.update(mapview, makeCharData({ ownedByMe: true }))
|
||||
expect(marker.setIcon).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('update with position change updates tooltip content when marker exists', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
const marker = char.leafletMarker as { setTooltipContent: ReturnType<typeof vi.fn> }
|
||||
marker.setTooltipContent.mockClear()
|
||||
char.update(mapview, makeCharData({ position: { x: 350, y: 450 } }))
|
||||
expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4')
|
||||
})
|
||||
|
||||
it('remove cancels active position animation', () => {
|
||||
const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
let rafCallback: (() => void) | null = null
|
||||
vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => {
|
||||
rafCallback = typeof cb === 'function' ? cb : () => {}
|
||||
return 1
|
||||
})
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 }))
|
||||
char.add(mapview)
|
||||
const marker = char.leafletMarker as { getLatLng: ReturnType<typeof vi.fn> }
|
||||
marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 })
|
||||
char.update(mapview, makeCharData({ position: { x: 200, y: 200 } }))
|
||||
expect(rafCallback).not.toBeNull()
|
||||
cancelSpy.mockClear()
|
||||
char.remove(mapview)
|
||||
expect(cancelSpy).toHaveBeenCalledWith(1)
|
||||
cancelSpy.mockRestore()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import type L from 'leaflet'
|
||||
import type { Map, LayerGroup } from 'leaflet'
|
||||
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
|
||||
|
||||
const { leafletMock } = vi.hoisted(() => {
|
||||
const markerMock = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
setIcon: vi.fn().mockReturnThis(),
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
setTooltipContent: vi.fn().mockReturnThis(),
|
||||
getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }),
|
||||
}
|
||||
const Icon = vi.fn().mockImplementation(function (this: unknown) {
|
||||
return {}
|
||||
})
|
||||
const L = {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon,
|
||||
point: vi.fn((x: number, y: number) => ({ x, y })),
|
||||
}
|
||||
return { leafletMock: L }
|
||||
})
|
||||
|
||||
vi.mock('leaflet', () => ({
|
||||
__esModule: true,
|
||||
default: leafletMock,
|
||||
marker: leafletMock.marker,
|
||||
Icon: leafletMock.Icon,
|
||||
}))
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
TileSize: 100,
|
||||
}))
|
||||
|
||||
function getL(): L {
|
||||
return leafletMock as unknown as L
|
||||
}
|
||||
|
||||
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
|
||||
return {
|
||||
name: 'Hero',
|
||||
position: { x: 100, y: 200 },
|
||||
type: 'player',
|
||||
id: 1,
|
||||
map: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMapViewRef(mapid = 1): CharacterMapViewRef {
|
||||
return {
|
||||
map: {
|
||||
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
removeLayer: vi.fn(),
|
||||
} as unknown as Map,
|
||||
mapid,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createCharacter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates character with correct properties', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
expect(char.id).toBe(1)
|
||||
expect(char.name).toBe('Hero')
|
||||
expect(char.position).toEqual({ x: 100, y: 200 })
|
||||
expect(char.type).toBe('player')
|
||||
expect(char.map).toBe(1)
|
||||
expect(char.text).toBe('Hero')
|
||||
expect(char.value).toBe(1)
|
||||
})
|
||||
|
||||
it('starts with null leaflet marker', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates marker when character is on correct map', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('add creates marker without title and binds Leaflet tooltip', () => {
|
||||
const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(leafletMock.marker).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.not.objectContaining({ title: expect.anything() })
|
||||
)
|
||||
const marker = char.leafletMarker as { bindTooltip: ReturnType<typeof vi.fn> }
|
||||
expect(marker.bindTooltip).toHaveBeenCalledWith(
|
||||
'Hero · 1, 2',
|
||||
expect.objectContaining({ direction: 'top', permanent: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('add does not create marker for different map', () => {
|
||||
const char = createCharacter(makeCharData({ map: 2 }), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and map', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
|
||||
char.update(mapview, {
|
||||
...makeCharData(),
|
||||
position: { x: 300, y: 400 },
|
||||
map: 2,
|
||||
})
|
||||
|
||||
expect(char.position).toEqual({ x: 300, y: 400 })
|
||||
expect(char.map).toBe(2)
|
||||
})
|
||||
|
||||
it('remove on a character without leaflet marker does nothing', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.remove(mapview) // should not throw
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('setClickCallback works', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const cb = vi.fn()
|
||||
char.setClickCallback(cb)
|
||||
})
|
||||
|
||||
it('update with changed ownedByMe updates marker icon', () => {
|
||||
const char = createCharacter(makeCharData({ ownedByMe: false }), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
const marker = char.leafletMarker as { setIcon: ReturnType<typeof vi.fn> }
|
||||
expect(marker.setIcon).not.toHaveBeenCalled()
|
||||
char.update(mapview, makeCharData({ ownedByMe: true }))
|
||||
expect(marker.setIcon).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('update with position change updates tooltip content when marker exists', () => {
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
const marker = char.leafletMarker as { setTooltipContent: ReturnType<typeof vi.fn> }
|
||||
marker.setTooltipContent.mockClear()
|
||||
char.update(mapview, makeCharData({ position: { x: 350, y: 450 } }))
|
||||
expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4')
|
||||
})
|
||||
|
||||
it('remove cancels active position animation', () => {
|
||||
const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
let rafCallback: (() => void) | null = null
|
||||
vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => {
|
||||
rafCallback = typeof cb === 'function' ? cb : () => {}
|
||||
return 1
|
||||
})
|
||||
const char = createCharacter(makeCharData(), getL())
|
||||
const mapview = makeMapViewRef(1)
|
||||
mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 }))
|
||||
char.add(mapview)
|
||||
const marker = char.leafletMarker as { getLatLng: ReturnType<typeof vi.fn> }
|
||||
marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 })
|
||||
char.update(mapview, makeCharData({ position: { x: 200, y: 200 } }))
|
||||
expect(rafCallback).not.toBeNull()
|
||||
cancelSpy.mockClear()
|
||||
char.remove(mapview)
|
||||
expect(cancelSpy).toHaveBeenCalledWith(1)
|
||||
cancelSpy.mockRestore()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-2xl min-w-0">
|
||||
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
|
||||
|
||||
<form v-if="map" class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="name">Name</label>
|
||||
<input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required >
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.hidden" type="checkbox" class="checkbox" >
|
||||
<span>Hidden</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.priority" type="checkbox" class="checkbox" >
|
||||
<span>Priority</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading">
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm" />
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
<NuxtLink to="/admin" class="btn btn-ghost min-h-11 touch-manipulation">Back</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
<template v-else-if="mapsLoaded">
|
||||
<p class="text-base-content/70">Map not found.</p>
|
||||
<NuxtLink to="/admin" class="btn btn-ghost mt-2 min-h-11 touch-manipulation">Back to Admin</NuxtLink>
|
||||
</template>
|
||||
<p v-else class="text-base-content/70">Loading…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapInfoAdmin } from '~/types/api'
|
||||
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
useHead({ title: 'Edit map – HnH Map' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
const id = computed(() => parseInt(route.params.id as string, 10))
|
||||
const map = ref<MapInfoAdmin | null>(null)
|
||||
const mapsLoaded = ref(false)
|
||||
const form = ref({ name: '', hidden: false, priority: false })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const adminMapName = useState<string | null>('admin-breadcrumb-map-name', () => null)
|
||||
|
||||
onMounted(async () => {
|
||||
adminMapName.value = null
|
||||
try {
|
||||
const maps = await api.adminMaps()
|
||||
mapsLoaded.value = true
|
||||
const found = maps.find((m) => m.ID === id.value)
|
||||
if (found) {
|
||||
map.value = found
|
||||
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
|
||||
adminMapName.value = found.Name
|
||||
}
|
||||
} catch {
|
||||
mapsLoaded.value = true
|
||||
error.value = 'Failed to load map'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
adminMapName.value = null
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!map.value) return
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await api.adminMapPost(map.value.ID, form.value)
|
||||
await router.push('/admin')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-2xl min-w-0">
|
||||
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
|
||||
|
||||
<form v-if="map" class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="name">Name</label>
|
||||
<input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required >
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.hidden" type="checkbox" class="checkbox" >
|
||||
<span>Hidden</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.priority" type="checkbox" class="checkbox" >
|
||||
<span>Priority</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading">
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm" />
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
<NuxtLink to="/admin" class="btn btn-ghost min-h-11 touch-manipulation">Back</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
<template v-else-if="mapsLoaded">
|
||||
<p class="text-base-content/70">Map not found.</p>
|
||||
<NuxtLink to="/admin" class="btn btn-ghost mt-2 min-h-11 touch-manipulation">Back to Admin</NuxtLink>
|
||||
</template>
|
||||
<p v-else class="text-base-content/70">Loading…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapInfoAdmin } from '~/types/api'
|
||||
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
useHead({ title: 'Edit map – HnH Map' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
const id = computed(() => parseInt(route.params.id as string, 10))
|
||||
const map = ref<MapInfoAdmin | null>(null)
|
||||
const mapsLoaded = ref(false)
|
||||
const form = ref({ name: '', hidden: false, priority: false })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const adminMapName = useState<string | null>('admin-breadcrumb-map-name', () => null)
|
||||
|
||||
onMounted(async () => {
|
||||
adminMapName.value = null
|
||||
try {
|
||||
const maps = await api.adminMaps()
|
||||
mapsLoaded.value = true
|
||||
const found = maps.find((m) => m.ID === id.value)
|
||||
if (found) {
|
||||
map.value = found
|
||||
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
|
||||
adminMapName.value = found.Name
|
||||
}
|
||||
} catch {
|
||||
mapsLoaded.value = true
|
||||
error.value = 'Failed to load map'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
adminMapName.value = null
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!map.value) return
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await api.adminMapPost(map.value.ID, form.value)
|
||||
await router.push('/admin')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,450 +1,450 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
)
|
||||
|
||||
type mapInfoJSON struct {
|
||||
ID int `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Hidden bool `json:"Hidden"`
|
||||
Priority bool `json:"Priority"`
|
||||
}
|
||||
|
||||
// APIAdminUsers handles GET/POST /map/api/admin/users.
|
||||
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
if req.Method == http.MethodGet {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
list, err := h.Admin.ListUsers(ctx)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
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(ctx, body.User, body.Pass, body.Auths)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
if body.User == s.Username {
|
||||
s.Auths = body.Auths
|
||||
}
|
||||
if adminCreated && s.Username == "admin" {
|
||||
h.Auth.DeleteSession(ctx, 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 !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
auths, found, err := h.Admin.GetUser(req.Context(), name)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
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 !h.requireMethod(rw, req, http.MethodDelete) {
|
||||
return
|
||||
}
|
||||
s := h.requireAdmin(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
if err := h.Admin.DeleteUser(ctx, name); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
if name == s.Username {
|
||||
h.Auth.DeleteSession(ctx, s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminSettingsGet handles GET /map/api/admin/settings.
|
||||
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
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(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminMaps handles GET /map/api/admin/maps.
|
||||
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
maps, err := h.Admin.ListMaps(req.Context())
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
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(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.Wipe(req.Context()); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminWipeTile handles POST /map/api/admin/wipeTile.
|
||||
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mapid, err := strconv.Atoi(req.FormValue("map"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
x, err := strconv.Atoi(req.FormValue("x"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
y, err := strconv.Atoi(req.FormValue("y"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminSetCoords handles POST /map/api/admin/setCoords.
|
||||
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mapid, err := strconv.Atoi(req.FormValue("map"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
fx, err := strconv.Atoi(req.FormValue("fx"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
fy, err := strconv.Atoi(req.FormValue("fy"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
tx, err := strconv.Atoi(req.FormValue("tx"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
ty, err := strconv.Atoi(req.FormValue("ty"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminHideMarker handles POST /map/api/admin/hideMarker.
|
||||
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
markerID := req.FormValue("id")
|
||||
if markerID == "" {
|
||||
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
h.Admin.StartRebuildZooms()
|
||||
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
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "application/zip")
|
||||
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
|
||||
if err := h.Export.Export(req.Context(), rw); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
}
|
||||
}
|
||||
|
||||
// APIAdminMerge handles POST /map/api/admin/merge.
|
||||
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
mergef, hdr, err := req.FormFile("merge")
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
zr, err := zip.NewReader(mergef, hdr.Size)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Export.Merge(req.Context(), zr); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminRoute routes /map/api/admin/* sub-paths.
|
||||
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
|
||||
switch {
|
||||
case path == "wipeTile":
|
||||
h.APIAdminWipeTile(rw, req)
|
||||
case path == "setCoords":
|
||||
h.APIAdminSetCoords(rw, req)
|
||||
case path == "hideMarker":
|
||||
h.APIAdminHideMarker(rw, req)
|
||||
case path == "users":
|
||||
h.APIAdminUsers(rw, req)
|
||||
case strings.HasPrefix(path, "users/"):
|
||||
name := strings.TrimPrefix(path, "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)
|
||||
}
|
||||
case path == "settings":
|
||||
if req.Method == http.MethodGet {
|
||||
h.APIAdminSettingsGet(rw, req)
|
||||
} else {
|
||||
h.APIAdminSettingsPost(rw, req)
|
||||
}
|
||||
case path == "maps":
|
||||
h.APIAdminMaps(rw, req)
|
||||
case strings.HasPrefix(path, "maps/"):
|
||||
rest := strings.TrimPrefix(path, "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")
|
||||
case path == "wipe":
|
||||
h.APIAdminWipe(rw, req)
|
||||
case path == "rebuildZooms":
|
||||
h.APIAdminRebuildZooms(rw, req)
|
||||
case path == "rebuildZooms/status":
|
||||
h.APIAdminRebuildZoomsStatus(rw, req)
|
||||
case path == "export":
|
||||
h.APIAdminExport(rw, req)
|
||||
case path == "merge":
|
||||
h.APIAdminMerge(rw, req)
|
||||
default:
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
)
|
||||
|
||||
type mapInfoJSON struct {
|
||||
ID int `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Hidden bool `json:"Hidden"`
|
||||
Priority bool `json:"Priority"`
|
||||
}
|
||||
|
||||
// APIAdminUsers handles GET/POST /map/api/admin/users.
|
||||
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
if req.Method == http.MethodGet {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
list, err := h.Admin.ListUsers(ctx)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
JSON(rw, http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
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(ctx, body.User, body.Pass, body.Auths)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
if body.User == s.Username {
|
||||
s.Auths = body.Auths
|
||||
}
|
||||
if adminCreated && s.Username == "admin" {
|
||||
h.Auth.DeleteSession(ctx, 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 !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
auths, found, err := h.Admin.GetUser(req.Context(), name)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
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 !h.requireMethod(rw, req, http.MethodDelete) {
|
||||
return
|
||||
}
|
||||
s := h.requireAdmin(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
if err := h.Admin.DeleteUser(ctx, name); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
if name == s.Username {
|
||||
h.Auth.DeleteSession(ctx, s)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminSettingsGet handles GET /map/api/admin/settings.
|
||||
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
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(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminMaps handles GET /map/api/admin/maps.
|
||||
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
maps, err := h.Admin.ListMaps(req.Context())
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
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(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
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 !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.Wipe(req.Context()); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminWipeTile handles POST /map/api/admin/wipeTile.
|
||||
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mapid, err := strconv.Atoi(req.FormValue("map"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
x, err := strconv.Atoi(req.FormValue("x"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
y, err := strconv.Atoi(req.FormValue("y"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminSetCoords handles POST /map/api/admin/setCoords.
|
||||
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
mapid, err := strconv.Atoi(req.FormValue("map"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
fx, err := strconv.Atoi(req.FormValue("fx"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
fy, err := strconv.Atoi(req.FormValue("fy"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
tx, err := strconv.Atoi(req.FormValue("tx"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
ty, err := strconv.Atoi(req.FormValue("ty"))
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminHideMarker handles POST /map/api/admin/hideMarker.
|
||||
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
markerID := req.FormValue("id")
|
||||
if markerID == "" {
|
||||
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
h.Admin.StartRebuildZooms()
|
||||
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
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "application/zip")
|
||||
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
|
||||
if err := h.Export.Export(req.Context(), rw); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
}
|
||||
}
|
||||
|
||||
// APIAdminMerge handles POST /map/api/admin/merge.
|
||||
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
mergef, hdr, err := req.FormFile("merge")
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
zr, err := zip.NewReader(mergef, hdr.Size)
|
||||
if err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Export.Merge(req.Context(), zr); err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// APIAdminRoute routes /map/api/admin/* sub-paths.
|
||||
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
|
||||
switch {
|
||||
case path == "wipeTile":
|
||||
h.APIAdminWipeTile(rw, req)
|
||||
case path == "setCoords":
|
||||
h.APIAdminSetCoords(rw, req)
|
||||
case path == "hideMarker":
|
||||
h.APIAdminHideMarker(rw, req)
|
||||
case path == "users":
|
||||
h.APIAdminUsers(rw, req)
|
||||
case strings.HasPrefix(path, "users/"):
|
||||
name := strings.TrimPrefix(path, "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)
|
||||
}
|
||||
case path == "settings":
|
||||
if req.Method == http.MethodGet {
|
||||
h.APIAdminSettingsGet(rw, req)
|
||||
} else {
|
||||
h.APIAdminSettingsPost(rw, req)
|
||||
}
|
||||
case path == "maps":
|
||||
h.APIAdminMaps(rw, req)
|
||||
case strings.HasPrefix(path, "maps/"):
|
||||
rest := strings.TrimPrefix(path, "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")
|
||||
case path == "wipe":
|
||||
h.APIAdminWipe(rw, req)
|
||||
case path == "rebuildZooms":
|
||||
h.APIAdminRebuildZooms(rw, req)
|
||||
case path == "rebuildZooms/status":
|
||||
h.APIAdminRebuildZoomsStatus(rw, req)
|
||||
case path == "export":
|
||||
h.APIAdminExport(rw, req)
|
||||
case path == "merge":
|
||||
h.APIAdminMerge(rw, req)
|
||||
default:
|
||||
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,423 +1,423 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"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, tile ops).
|
||||
type AdminService struct {
|
||||
st *store.Store
|
||||
mapSvc *MapService
|
||||
|
||||
rebuildMu sync.Mutex
|
||||
rebuildRunning bool
|
||||
}
|
||||
|
||||
// NewAdminService creates an AdminService with the given store and map service.
|
||||
// Uses direct args (two dependencies) rather than a deps struct.
|
||||
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
|
||||
return &AdminService{st: st, mapSvc: mapSvc}
|
||||
}
|
||||
|
||||
// ListUsers returns all usernames.
|
||||
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
|
||||
var list []string
|
||||
err := s.st.View(ctx, 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 a user's permissions by username.
|
||||
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
|
||||
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
var u app.User
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
auths = u.Auths
|
||||
found = true
|
||||
return nil
|
||||
})
|
||||
return auths, found, err
|
||||
}
|
||||
|
||||
// CreateOrUpdateUser creates or updates a user.
|
||||
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
|
||||
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
|
||||
err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
existed := s.st.GetUser(tx, username) != nil
|
||||
u := app.User{}
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw != nil {
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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(ctx context.Context, username string) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
uRaw := s.st.GetUser(tx, username)
|
||||
if uRaw != nil {
|
||||
var u app.User
|
||||
if err := json.Unmarshal(uRaw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tok := range u.Tokens {
|
||||
if err := s.st.DeleteToken(tx, tok); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.st.DeleteUser(tx, username)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSettings returns the current server settings.
|
||||
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
|
||||
err = s.st.View(ctx, 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 the specified server settings (nil fields are skipped).
|
||||
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if prefix != nil {
|
||||
if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if defaultHide != nil {
|
||||
if *defaultHide {
|
||||
if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if title != nil {
|
||||
if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListMaps returns all maps for the admin panel.
|
||||
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
|
||||
var maps []app.MapInfo
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachMap(tx, func(k, v []byte) error {
|
||||
mi := app.MapInfo{}
|
||||
if err := json.Unmarshal(v, &mi); err != nil {
|
||||
return err
|
||||
}
|
||||
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(ctx context.Context, id int) (*app.MapInfo, bool, error) {
|
||||
var mi *app.MapInfo
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetMap(tx, id)
|
||||
if raw != nil {
|
||||
mi = &app.MapInfo{}
|
||||
return json.Unmarshal(raw, mi)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if mi != nil {
|
||||
mi.ID = id
|
||||
}
|
||||
return mi, mi != nil, nil
|
||||
}
|
||||
|
||||
// UpdateMap updates a map's name, hidden, and priority fields.
|
||||
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
mi := app.MapInfo{}
|
||||
raw := s.st.GetMap(tx, id)
|
||||
if raw != nil {
|
||||
if err := json.Unmarshal(raw, &mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mi.ID = id
|
||||
mi.Name = name
|
||||
mi.Hidden = hidden
|
||||
mi.Priority = priority
|
||||
raw, _ = json.Marshal(mi)
|
||||
return s.st.PutMap(tx, id, raw)
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
|
||||
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
|
||||
var mi *app.MapInfo
|
||||
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetMap(tx, id)
|
||||
mi = &app.MapInfo{}
|
||||
if raw != nil {
|
||||
if err := json.Unmarshal(raw, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mi.ID = id
|
||||
mi.Hidden = !mi.Hidden
|
||||
raw, _ = json.Marshal(mi)
|
||||
return s.st.PutMap(tx, id, raw)
|
||||
})
|
||||
return mi, err
|
||||
}
|
||||
|
||||
// Wipe deletes all grids, markers, tiles, and maps from the database.
|
||||
func (s *AdminService) Wipe(ctx context.Context) error {
|
||||
return s.st.Update(ctx, 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
|
||||
})
|
||||
}
|
||||
|
||||
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
|
||||
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
|
||||
c := app.Coord{X: x, Y: y}
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
grids := tx.Bucket(store.BucketGrids)
|
||||
if grids == nil {
|
||||
return nil
|
||||
}
|
||||
var ids [][]byte
|
||||
err := grids.ForEach(func(k, v []byte) error {
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(v, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Coord == c && g.Map == mapid {
|
||||
ids = append(ids, k)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := grids.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
|
||||
zc := c
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
zc = zc.Parent()
|
||||
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCoords shifts all grid and tile coordinates by a delta.
|
||||
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
|
||||
fc := app.Coord{X: fx, Y: fy}
|
||||
tc := app.Coord{X: tx2, Y: ty}
|
||||
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
|
||||
|
||||
var tds []*app.TileData
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
grids := tx.Bucket(store.BucketGrids)
|
||||
if grids == nil {
|
||||
return nil
|
||||
}
|
||||
tiles := tx.Bucket(store.BucketTiles)
|
||||
if tiles == nil {
|
||||
return nil
|
||||
}
|
||||
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
|
||||
if mapZooms == nil {
|
||||
return nil
|
||||
}
|
||||
mapTiles := mapZooms.Bucket([]byte("0"))
|
||||
if err := grids.ForEach(func(k, v []byte) error {
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(v, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Map == mapid {
|
||||
g.Coord.X += diff.X
|
||||
g.Coord.Y += diff.Y
|
||||
raw, _ := json.Marshal(g)
|
||||
if err := grids.Put(k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mapTiles.ForEach(func(k, v []byte) error {
|
||||
td := &app.TileData{}
|
||||
if err := json.Unmarshal(v, td); err != nil {
|
||||
return err
|
||||
}
|
||||
td.Coord.X += diff.X
|
||||
td.Coord.Y += diff.Y
|
||||
tds = append(tds, td)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ops := make([]TileOp, len(tds))
|
||||
for i, td := range tds {
|
||||
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
|
||||
}
|
||||
s.mapSvc.ProcessZoomLevels(ctx, ops)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HideMarker marks a marker as hidden.
|
||||
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
_, idB, err := s.st.CreateMarkersBuckets(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grid := s.st.GetMarkersGridBucket(tx)
|
||||
if grid == nil {
|
||||
return fmt.Errorf("markers grid bucket not found")
|
||||
}
|
||||
key := idB.Get([]byte(markerID))
|
||||
if key == nil {
|
||||
slog.Warn("marker not found", "id", markerID)
|
||||
return nil
|
||||
}
|
||||
raw := grid.Get(key)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m := app.Marker{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Hidden = true
|
||||
raw, _ = json.Marshal(m)
|
||||
if err := grid.Put(key, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RebuildZooms delegates to MapService.
|
||||
func (s *AdminService) RebuildZooms(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"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, tile ops).
|
||||
type AdminService struct {
|
||||
st *store.Store
|
||||
mapSvc *MapService
|
||||
|
||||
rebuildMu sync.Mutex
|
||||
rebuildRunning bool
|
||||
}
|
||||
|
||||
// NewAdminService creates an AdminService with the given store and map service.
|
||||
// Uses direct args (two dependencies) rather than a deps struct.
|
||||
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
|
||||
return &AdminService{st: st, mapSvc: mapSvc}
|
||||
}
|
||||
|
||||
// ListUsers returns all usernames.
|
||||
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
|
||||
var list []string
|
||||
err := s.st.View(ctx, 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 a user's permissions by username.
|
||||
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
|
||||
err = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
var u app.User
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
auths = u.Auths
|
||||
found = true
|
||||
return nil
|
||||
})
|
||||
return auths, found, err
|
||||
}
|
||||
|
||||
// CreateOrUpdateUser creates or updates a user.
|
||||
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
|
||||
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
|
||||
err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
existed := s.st.GetUser(tx, username) != nil
|
||||
u := app.User{}
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw != nil {
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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(ctx context.Context, username string) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
uRaw := s.st.GetUser(tx, username)
|
||||
if uRaw != nil {
|
||||
var u app.User
|
||||
if err := json.Unmarshal(uRaw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tok := range u.Tokens {
|
||||
if err := s.st.DeleteToken(tx, tok); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.st.DeleteUser(tx, username)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSettings returns the current server settings.
|
||||
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
|
||||
err = s.st.View(ctx, 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 the specified server settings (nil fields are skipped).
|
||||
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if prefix != nil {
|
||||
if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if defaultHide != nil {
|
||||
if *defaultHide {
|
||||
if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if title != nil {
|
||||
if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListMaps returns all maps for the admin panel.
|
||||
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
|
||||
var maps []app.MapInfo
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachMap(tx, func(k, v []byte) error {
|
||||
mi := app.MapInfo{}
|
||||
if err := json.Unmarshal(v, &mi); err != nil {
|
||||
return err
|
||||
}
|
||||
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(ctx context.Context, id int) (*app.MapInfo, bool, error) {
|
||||
var mi *app.MapInfo
|
||||
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetMap(tx, id)
|
||||
if raw != nil {
|
||||
mi = &app.MapInfo{}
|
||||
return json.Unmarshal(raw, mi)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if mi != nil {
|
||||
mi.ID = id
|
||||
}
|
||||
return mi, mi != nil, nil
|
||||
}
|
||||
|
||||
// UpdateMap updates a map's name, hidden, and priority fields.
|
||||
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
mi := app.MapInfo{}
|
||||
raw := s.st.GetMap(tx, id)
|
||||
if raw != nil {
|
||||
if err := json.Unmarshal(raw, &mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mi.ID = id
|
||||
mi.Name = name
|
||||
mi.Hidden = hidden
|
||||
mi.Priority = priority
|
||||
raw, _ = json.Marshal(mi)
|
||||
return s.st.PutMap(tx, id, raw)
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
|
||||
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
|
||||
var mi *app.MapInfo
|
||||
err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetMap(tx, id)
|
||||
mi = &app.MapInfo{}
|
||||
if raw != nil {
|
||||
if err := json.Unmarshal(raw, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mi.ID = id
|
||||
mi.Hidden = !mi.Hidden
|
||||
raw, _ = json.Marshal(mi)
|
||||
return s.st.PutMap(tx, id, raw)
|
||||
})
|
||||
return mi, err
|
||||
}
|
||||
|
||||
// Wipe deletes all grids, markers, tiles, and maps from the database.
|
||||
func (s *AdminService) Wipe(ctx context.Context) error {
|
||||
return s.st.Update(ctx, 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
|
||||
})
|
||||
}
|
||||
|
||||
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
|
||||
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
|
||||
c := app.Coord{X: x, Y: y}
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
grids := tx.Bucket(store.BucketGrids)
|
||||
if grids == nil {
|
||||
return nil
|
||||
}
|
||||
var ids [][]byte
|
||||
err := grids.ForEach(func(k, v []byte) error {
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(v, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Coord == c && g.Map == mapid {
|
||||
ids = append(ids, k)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := grids.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
|
||||
zc := c
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
zc = zc.Parent()
|
||||
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCoords shifts all grid and tile coordinates by a delta.
|
||||
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
|
||||
fc := app.Coord{X: fx, Y: fy}
|
||||
tc := app.Coord{X: tx2, Y: ty}
|
||||
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
|
||||
|
||||
var tds []*app.TileData
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
grids := tx.Bucket(store.BucketGrids)
|
||||
if grids == nil {
|
||||
return nil
|
||||
}
|
||||
tiles := tx.Bucket(store.BucketTiles)
|
||||
if tiles == nil {
|
||||
return nil
|
||||
}
|
||||
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
|
||||
if mapZooms == nil {
|
||||
return nil
|
||||
}
|
||||
mapTiles := mapZooms.Bucket([]byte("0"))
|
||||
if err := grids.ForEach(func(k, v []byte) error {
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(v, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Map == mapid {
|
||||
g.Coord.X += diff.X
|
||||
g.Coord.Y += diff.Y
|
||||
raw, _ := json.Marshal(g)
|
||||
if err := grids.Put(k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mapTiles.ForEach(func(k, v []byte) error {
|
||||
td := &app.TileData{}
|
||||
if err := json.Unmarshal(v, td); err != nil {
|
||||
return err
|
||||
}
|
||||
td.Coord.X += diff.X
|
||||
td.Coord.Y += diff.Y
|
||||
tds = append(tds, td)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ops := make([]TileOp, len(tds))
|
||||
for i, td := range tds {
|
||||
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
|
||||
}
|
||||
s.mapSvc.ProcessZoomLevels(ctx, ops)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HideMarker marks a marker as hidden.
|
||||
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
_, idB, err := s.st.CreateMarkersBuckets(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grid := s.st.GetMarkersGridBucket(tx)
|
||||
if grid == nil {
|
||||
return fmt.Errorf("markers grid bucket not found")
|
||||
}
|
||||
key := idB.Get([]byte(markerID))
|
||||
if key == nil {
|
||||
slog.Warn("marker not found", "id", markerID)
|
||||
return nil
|
||||
}
|
||||
raw := grid.Get(key)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
m := app.Marker{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Hidden = true
|
||||
raw, _ = json.Marshal(m)
|
||||
if err := grid.Put(key, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RebuildZooms delegates to MapService.
|
||||
func (s *AdminService) RebuildZooms(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,308 +1,308 @@
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
st := store.New(db)
|
||||
mapSvc := services.NewMapService(services.MapServiceDeps{
|
||||
Store: st,
|
||||
GridStorage: t.TempDir(),
|
||||
GridUpdates: &app.Topic[app.TileData]{},
|
||||
})
|
||||
return services.NewAdminService(st, mapSvc), st
|
||||
}
|
||||
|
||||
func TestListUsers_Empty(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
users, err := admin.ListUsers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("expected 0 users, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers_WithUsers(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "pass", nil)
|
||||
createUser(t, st, "bob", "pass", nil)
|
||||
|
||||
users, err := admin.ListUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGetUser_Found(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
|
||||
|
||||
auths, found, err := admin.GetUser(context.Background(), "alice")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("expected found, err=%v", err)
|
||||
}
|
||||
if !auths.Has(app.AUTH_MAP) {
|
||||
t.Fatal("expected map auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGetUser_NotFound(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
_, found, err := admin.GetUser(context.Background(), "ghost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateUser_New(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, found, err := admin.GetUser(ctx, "bob")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("expected user to exist, err=%v", err)
|
||||
}
|
||||
if !auths.Has(app.AUTH_MAP) {
|
||||
t.Fatal("expected map auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateUser_Update(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
|
||||
|
||||
_, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, found, err := admin.GetUser(ctx, "alice")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("expected user, err=%v", err)
|
||||
}
|
||||
if !auths.Has(app.AUTH_ADMIN) {
|
||||
t.Fatal("expected admin auth after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !adminCreated {
|
||||
t.Fatal("expected adminCreated=true for new admin user")
|
||||
}
|
||||
|
||||
adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adminCreated {
|
||||
t.Fatal("expected adminCreated=false for existing admin user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
|
||||
|
||||
auth := services.NewAuthService(st)
|
||||
auth.GenerateTokenForUser(ctx, "alice")
|
||||
|
||||
if err := admin.DeleteUser(ctx, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, found, err := admin.GetUser(ctx, "alice")
|
||||
if err != nil || found {
|
||||
t.Fatalf("expected user to be deleted, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSettings_Defaults(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
prefix, defaultHide, title, err := admin.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefix != "" || defaultHide || title != "" {
|
||||
t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := "pfx"
|
||||
dh := true
|
||||
ti := "My Map"
|
||||
if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prefix, defaultHide, title, err := admin.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefix != "pfx" {
|
||||
t.Fatalf("expected pfx, got %s", prefix)
|
||||
}
|
||||
if !defaultHide {
|
||||
t.Fatal("expected defaultHide=true")
|
||||
}
|
||||
if title != "My Map" {
|
||||
t.Fatalf("expected My Map, got %s", title)
|
||||
}
|
||||
|
||||
dh2 := false
|
||||
if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, defaultHide2, _, _ := admin.GetSettings(ctx)
|
||||
if defaultHide2 {
|
||||
t.Fatal("expected defaultHide=false after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMaps_Empty(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
maps, err := admin.ListMaps(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(maps) != 0 {
|
||||
t.Fatalf("expected 0 maps, got %d", len(maps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapCRUD(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mi, found, err := admin.GetMap(ctx, 1)
|
||||
if err != nil || !found || mi == nil {
|
||||
t.Fatalf("expected map, err=%v", err)
|
||||
}
|
||||
if mi.Name != "world" {
|
||||
t.Fatalf("expected world, got %s", mi.Name)
|
||||
}
|
||||
|
||||
maps, err := admin.ListMaps(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(maps) != 1 {
|
||||
t.Fatalf("expected 1 map, got %d", len(maps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleMapHidden(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = admin.UpdateMap(ctx, 1, "world", false, false)
|
||||
|
||||
mi, err := admin.ToggleMapHidden(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !mi.Hidden {
|
||||
t.Fatal("expected hidden=true after toggle")
|
||||
}
|
||||
|
||||
mi, err = admin.ToggleMapHidden(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Hidden {
|
||||
t.Fatal("expected hidden=false after second toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWipe(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutMap(tx, 1, []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := st.CreateMarkersBuckets(tx)
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := admin.Wipe(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if st.GetGrid(tx, "g1") != nil {
|
||||
t.Fatal("expected grids wiped")
|
||||
}
|
||||
if st.GetMap(tx, 1) != nil {
|
||||
t.Fatal("expected maps wiped")
|
||||
}
|
||||
if st.GetTile(tx, 1, 0, "0_0") != nil {
|
||||
t.Fatal("expected tiles wiped")
|
||||
}
|
||||
if st.GetMarkersGridBucket(tx) != nil {
|
||||
t.Fatal("expected markers wiped")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMap_NotFound(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
_, found, err := admin.GetMap(context.Background(), 999)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected not found")
|
||||
}
|
||||
}
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/services"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
st := store.New(db)
|
||||
mapSvc := services.NewMapService(services.MapServiceDeps{
|
||||
Store: st,
|
||||
GridStorage: t.TempDir(),
|
||||
GridUpdates: &app.Topic[app.TileData]{},
|
||||
})
|
||||
return services.NewAdminService(st, mapSvc), st
|
||||
}
|
||||
|
||||
func TestListUsers_Empty(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
users, err := admin.ListUsers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("expected 0 users, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers_WithUsers(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "pass", nil)
|
||||
createUser(t, st, "bob", "pass", nil)
|
||||
|
||||
users, err := admin.ListUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGetUser_Found(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
|
||||
|
||||
auths, found, err := admin.GetUser(context.Background(), "alice")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("expected found, err=%v", err)
|
||||
}
|
||||
if !auths.Has(app.AUTH_MAP) {
|
||||
t.Fatal("expected map auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGetUser_NotFound(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
_, found, err := admin.GetUser(context.Background(), "ghost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateUser_New(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, found, err := admin.GetUser(ctx, "bob")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("expected user to exist, err=%v", err)
|
||||
}
|
||||
if !auths.Has(app.AUTH_MAP) {
|
||||
t.Fatal("expected map auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateUser_Update(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
|
||||
|
||||
_, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, found, err := admin.GetUser(ctx, "alice")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("expected user, err=%v", err)
|
||||
}
|
||||
if !auths.Has(app.AUTH_ADMIN) {
|
||||
t.Fatal("expected admin auth after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !adminCreated {
|
||||
t.Fatal("expected adminCreated=true for new admin user")
|
||||
}
|
||||
|
||||
adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adminCreated {
|
||||
t.Fatal("expected adminCreated=false for existing admin user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
|
||||
|
||||
auth := services.NewAuthService(st)
|
||||
auth.GenerateTokenForUser(ctx, "alice")
|
||||
|
||||
if err := admin.DeleteUser(ctx, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, found, err := admin.GetUser(ctx, "alice")
|
||||
if err != nil || found {
|
||||
t.Fatalf("expected user to be deleted, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSettings_Defaults(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
prefix, defaultHide, title, err := admin.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefix != "" || defaultHide || title != "" {
|
||||
t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := "pfx"
|
||||
dh := true
|
||||
ti := "My Map"
|
||||
if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prefix, defaultHide, title, err := admin.GetSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prefix != "pfx" {
|
||||
t.Fatalf("expected pfx, got %s", prefix)
|
||||
}
|
||||
if !defaultHide {
|
||||
t.Fatal("expected defaultHide=true")
|
||||
}
|
||||
if title != "My Map" {
|
||||
t.Fatalf("expected My Map, got %s", title)
|
||||
}
|
||||
|
||||
dh2 := false
|
||||
if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, defaultHide2, _, _ := admin.GetSettings(ctx)
|
||||
if defaultHide2 {
|
||||
t.Fatal("expected defaultHide=false after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMaps_Empty(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
maps, err := admin.ListMaps(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(maps) != 0 {
|
||||
t.Fatalf("expected 0 maps, got %d", len(maps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapCRUD(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mi, found, err := admin.GetMap(ctx, 1)
|
||||
if err != nil || !found || mi == nil {
|
||||
t.Fatalf("expected map, err=%v", err)
|
||||
}
|
||||
if mi.Name != "world" {
|
||||
t.Fatalf("expected world, got %s", mi.Name)
|
||||
}
|
||||
|
||||
maps, err := admin.ListMaps(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(maps) != 1 {
|
||||
t.Fatalf("expected 1 map, got %d", len(maps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleMapHidden(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = admin.UpdateMap(ctx, 1, "world", false, false)
|
||||
|
||||
mi, err := admin.ToggleMapHidden(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !mi.Hidden {
|
||||
t.Fatal("expected hidden=true after toggle")
|
||||
}
|
||||
|
||||
mi, err = admin.ToggleMapHidden(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mi.Hidden {
|
||||
t.Fatal("expected hidden=false after second toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWipe(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutMap(tx, 1, []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := st.CreateMarkersBuckets(tx)
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := admin.Wipe(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if st.GetGrid(tx, "g1") != nil {
|
||||
t.Fatal("expected grids wiped")
|
||||
}
|
||||
if st.GetMap(tx, 1) != nil {
|
||||
t.Fatal("expected maps wiped")
|
||||
}
|
||||
if st.GetTile(tx, 1, 0, "0_0") != nil {
|
||||
t.Fatal("expected tiles wiped")
|
||||
}
|
||||
if st.GetMarkersGridBucket(tx) != nil {
|
||||
t.Fatal("expected markers wiped")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMap_NotFound(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
_, found, err := admin.GetMap(context.Background(), 999)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected not found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,422 +1,422 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"go.etcd.io/bbolt"
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type zoomproc struct {
|
||||
c app.Coord
|
||||
m int
|
||||
}
|
||||
|
||||
// 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 construction.
|
||||
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 with the given dependencies.
|
||||
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 directory path.
|
||||
func (s *MapService) GridStorage() string { return s.gridStorage }
|
||||
|
||||
// GetCharacters returns all current characters.
|
||||
func (s *MapService) GetCharacters() []app.Character {
|
||||
if s.getChars == nil {
|
||||
return nil
|
||||
}
|
||||
return s.getChars()
|
||||
}
|
||||
|
||||
// GetMarkers returns all markers with computed map positions.
|
||||
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
|
||||
var markers []app.FrontendMarker
|
||||
err := s.st.View(ctx, 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{}
|
||||
if err := json.Unmarshal(v, &marker); err != nil {
|
||||
return err
|
||||
}
|
||||
graw := grids.Get([]byte(marker.GridID))
|
||||
if graw == nil {
|
||||
return nil
|
||||
}
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(graw, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
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*app.GridSize,
|
||||
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return markers, err
|
||||
}
|
||||
|
||||
// GetMaps returns all maps, optionally including hidden ones.
|
||||
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
|
||||
maps := make(map[int]*app.MapInfo)
|
||||
err := s.st.View(ctx, 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{}
|
||||
if err := json.Unmarshal(v, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
if mi.Hidden && !showHidden {
|
||||
return nil
|
||||
}
|
||||
maps[mapid] = mi
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return maps, err
|
||||
}
|
||||
|
||||
// GetConfig returns the application config for the frontend.
|
||||
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
|
||||
config := app.Config{Auths: auths}
|
||||
err := s.st.View(ctx, 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 metadata (title).
|
||||
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
|
||||
p := app.Page{}
|
||||
err := s.st.View(ctx, 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 a grid by its ID.
|
||||
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
|
||||
var gd *app.GridData
|
||||
err := s.st.View(ctx, 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 a tile by map ID, coordinate, and zoom level.
|
||||
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
|
||||
var td *app.TileData
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
|
||||
if raw != nil {
|
||||
td = &app.TileData{}
|
||||
return json.Unmarshal(raw, td)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
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.
|
||||
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 {
|
||||
td := &app.TileData{
|
||||
MapID: mapid,
|
||||
Coord: c,
|
||||
Zoom: z,
|
||||
File: f,
|
||||
Cache: t,
|
||||
}
|
||||
raw, err := json.Marshal(td)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gridUpdates.Send(td)
|
||||
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
subTiles := s.getSubTiles(ctx, mapid, c, z)
|
||||
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
||||
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
||||
for i := 0; i < 4; i++ {
|
||||
td := subTiles[i]
|
||||
if td == nil || td.File == "" {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
slog.Error("failed to create zoom dir", "error", err)
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
|
||||
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
slog.Error("failed to create tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
slog.Error("failed to encode tile PNG", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
slog.Error("failed to close tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// RebuildZooms rebuilds all zoom levels from base tiles.
|
||||
// It can take a long time for many grids; the client should account for request timeouts.
|
||||
func (s *MapService) RebuildZooms(ctx context.Context) error {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
saveGrid := map[zoomproc]string{}
|
||||
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(store.BucketGrids)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if err := b.ForEach(func(k, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
grid := app.GridData{}
|
||||
if err := json.Unmarshal(v, &grid); err != nil {
|
||||
return err
|
||||
}
|
||||
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
|
||||
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
slog.Error("RebuildZooms: failed to update store", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for g, id := range saveGrid {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
continue
|
||||
}
|
||||
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
// WatchTiles creates a channel that receives tile updates.
|
||||
func (s *MapService) WatchTiles() chan *app.TileData {
|
||||
c := make(chan *app.TileData, app.SSETileChannelSize)
|
||||
s.gridUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// WatchMerges creates a channel that receives merge updates.
|
||||
func (s *MapService) WatchMerges() chan *app.Merge {
|
||||
c := make(chan *app.Merge, app.SSEMergeChannelSize)
|
||||
s.mergeUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetAllTileCache returns all tiles for the initial SSE cache dump.
|
||||
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
||||
var cache []TileCache
|
||||
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
td := app.TileData{}
|
||||
if err := json.Unmarshal(v, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
cache = append(cache, TileCache{
|
||||
M: td.MapID,
|
||||
X: td.Coord.X,
|
||||
Y: td.Coord.Y,
|
||||
Z: td.Zoom,
|
||||
T: int(td.Cache),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
// TileCache represents a minimal tile entry for SSE streaming.
|
||||
type TileCache struct {
|
||||
M, X, Y, Z, T int
|
||||
}
|
||||
|
||||
// ProcessZoomLevels processes zoom levels for a set of tile operations.
|
||||
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
for _, op := range ops {
|
||||
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
|
||||
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TileOp represents a tile save operation.
|
||||
type TileOp struct {
|
||||
MapID int
|
||||
X, Y int
|
||||
File string
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"go.etcd.io/bbolt"
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type zoomproc struct {
|
||||
c app.Coord
|
||||
m int
|
||||
}
|
||||
|
||||
// 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 construction.
|
||||
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 with the given dependencies.
|
||||
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 directory path.
|
||||
func (s *MapService) GridStorage() string { return s.gridStorage }
|
||||
|
||||
// GetCharacters returns all current characters.
|
||||
func (s *MapService) GetCharacters() []app.Character {
|
||||
if s.getChars == nil {
|
||||
return nil
|
||||
}
|
||||
return s.getChars()
|
||||
}
|
||||
|
||||
// GetMarkers returns all markers with computed map positions.
|
||||
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
|
||||
var markers []app.FrontendMarker
|
||||
err := s.st.View(ctx, 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{}
|
||||
if err := json.Unmarshal(v, &marker); err != nil {
|
||||
return err
|
||||
}
|
||||
graw := grids.Get([]byte(marker.GridID))
|
||||
if graw == nil {
|
||||
return nil
|
||||
}
|
||||
g := app.GridData{}
|
||||
if err := json.Unmarshal(graw, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
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*app.GridSize,
|
||||
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return markers, err
|
||||
}
|
||||
|
||||
// GetMaps returns all maps, optionally including hidden ones.
|
||||
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
|
||||
maps := make(map[int]*app.MapInfo)
|
||||
err := s.st.View(ctx, 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{}
|
||||
if err := json.Unmarshal(v, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
if mi.Hidden && !showHidden {
|
||||
return nil
|
||||
}
|
||||
maps[mapid] = mi
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return maps, err
|
||||
}
|
||||
|
||||
// GetConfig returns the application config for the frontend.
|
||||
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
|
||||
config := app.Config{Auths: auths}
|
||||
err := s.st.View(ctx, 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 metadata (title).
|
||||
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
|
||||
p := app.Page{}
|
||||
err := s.st.View(ctx, 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 a grid by its ID.
|
||||
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
|
||||
var gd *app.GridData
|
||||
err := s.st.View(ctx, 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 a tile by map ID, coordinate, and zoom level.
|
||||
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
|
||||
var td *app.TileData
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
|
||||
if raw != nil {
|
||||
td = &app.TileData{}
|
||||
return json.Unmarshal(raw, td)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
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.
|
||||
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 {
|
||||
td := &app.TileData{
|
||||
MapID: mapid,
|
||||
Coord: c,
|
||||
Zoom: z,
|
||||
File: f,
|
||||
Cache: t,
|
||||
}
|
||||
raw, err := json.Marshal(td)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.gridUpdates.Send(td)
|
||||
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
subTiles := s.getSubTiles(ctx, mapid, c, z)
|
||||
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
||||
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
||||
for i := 0; i < 4; i++ {
|
||||
td := subTiles[i]
|
||||
if td == nil || td.File == "" {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
slog.Error("failed to create zoom dir", "error", err)
|
||||
return
|
||||
}
|
||||
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
|
||||
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
slog.Error("failed to create tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
os.Remove(path)
|
||||
slog.Error("failed to encode tile PNG", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
slog.Error("failed to close tile file", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// RebuildZooms rebuilds all zoom levels from base tiles.
|
||||
// It can take a long time for many grids; the client should account for request timeouts.
|
||||
func (s *MapService) RebuildZooms(ctx context.Context) error {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
saveGrid := map[zoomproc]string{}
|
||||
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(store.BucketGrids)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
if err := b.ForEach(func(k, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
grid := app.GridData{}
|
||||
if err := json.Unmarshal(v, &grid); err != nil {
|
||||
return err
|
||||
}
|
||||
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
|
||||
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
slog.Error("RebuildZooms: failed to update store", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for g, id := range saveGrid {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
continue
|
||||
}
|
||||
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
// WatchTiles creates a channel that receives tile updates.
|
||||
func (s *MapService) WatchTiles() chan *app.TileData {
|
||||
c := make(chan *app.TileData, app.SSETileChannelSize)
|
||||
s.gridUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// WatchMerges creates a channel that receives merge updates.
|
||||
func (s *MapService) WatchMerges() chan *app.Merge {
|
||||
c := make(chan *app.Merge, app.SSEMergeChannelSize)
|
||||
s.mergeUpdates.Watch(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetAllTileCache returns all tiles for the initial SSE cache dump.
|
||||
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
||||
var cache []TileCache
|
||||
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
td := app.TileData{}
|
||||
if err := json.Unmarshal(v, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
cache = append(cache, TileCache{
|
||||
M: td.MapID,
|
||||
X: td.Coord.X,
|
||||
Y: td.Coord.Y,
|
||||
Z: td.Zoom,
|
||||
T: int(td.Cache),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return cache
|
||||
}
|
||||
|
||||
// TileCache represents a minimal tile entry for SSE streaming.
|
||||
type TileCache struct {
|
||||
M, X, Y, Z, T int
|
||||
}
|
||||
|
||||
// ProcessZoomLevels processes zoom levels for a set of tile operations.
|
||||
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
|
||||
needProcess := map[zoomproc]struct{}{}
|
||||
for _, op := range ops {
|
||||
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
|
||||
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
|
||||
}
|
||||
for z := 1; z <= app.MaxZoomLevel; z++ {
|
||||
process := needProcess
|
||||
needProcess = map[zoomproc]struct{}{}
|
||||
for p := range process {
|
||||
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
||||
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TileOp represents a tile save operation.
|
||||
type TileOp struct {
|
||||
MapID int
|
||||
X, Y int
|
||||
File string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user