diff --git a/.dockerignore b/.dockerignore index 9644126..414e46b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/frontend-nuxt/assets/css/leaflet-overrides.css b/frontend-nuxt/assets/css/leaflet-overrides.css index 0fb9f94..c19524f 100644 --- a/frontend-nuxt/assets/css/leaflet-overrides.css +++ b/frontend-nuxt/assets/css/leaflet-overrides.css @@ -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; +} diff --git a/frontend-nuxt/composables/__tests__/useMapApi.test.ts b/frontend-nuxt/composables/__tests__/useMapApi.test.ts index 06a011b..3aa58f5 100644 --- a/frontend-nuxt/composables/__tests__/useMapApi.test.ts +++ b/frontend-nuxt/composables/__tests__/useMapApi.test.ts @@ -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' }), + }), + ) + }) + }) +}) diff --git a/frontend-nuxt/composables/useMapApi.ts b/frontend-nuxt/composables/useMapApi.ts index b42a342..048f3d5 100644 --- a/frontend-nuxt/composables/useMapApi.ts +++ b/frontend-nuxt/composables/useMapApi.ts @@ -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 void>() - -// In-flight dedup: one me() request at a time; concurrent callers share the same promise. -let mePromise: Promise | null = null - -// In-flight dedup for GET endpoints: same path + method shares one request across all callers. -const inFlightByKey = new Map>() - -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(path: string, opts?: RequestInit): Promise { - 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 - } - return undefined as T - } - - function requestDeduped(path: string, opts?: RequestInit): Promise { - const key = path + (opts?.method ?? 'GET') - const existing = inFlightByKey.get(key) - if (existing) return existing as Promise - const p = request(path, opts).finally(() => { - inFlightByKey.delete(key) - }) - inFlightByKey.set(key, p) - return p - } - - async function getConfig() { - return requestDeduped('config') - } - - async function getCharacters() { - return requestDeduped('v1/characters') - } - - async function getMarkers() { - return requestDeduped('v1/markers') - } - - async function getMaps() { - return requestDeduped>('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 - } - - /** 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 { - 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('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('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('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('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(`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 void>() + +// In-flight dedup: one me() request at a time; concurrent callers share the same promise. +let mePromise: Promise | null = null + +// In-flight dedup for GET endpoints: same path + method shares one request across all callers. +const inFlightByKey = new Map>() + +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(path: string, opts?: RequestInit): Promise { + 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 + } + return undefined as T + } + + function requestDeduped(path: string, opts?: RequestInit): Promise { + const key = path + (opts?.method ?? 'GET') + const existing = inFlightByKey.get(key) + if (existing) return existing as Promise + const p = request(path, opts).finally(() => { + inFlightByKey.delete(key) + }) + inFlightByKey.set(key, p) + return p + } + + async function getConfig() { + return requestDeduped('config') + } + + async function getCharacters() { + return requestDeduped('v1/characters') + } + + async function getMarkers() { + return requestDeduped('v1/markers') + } + + async function getMaps() { + return requestDeduped>('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 + } + + /** 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 { + 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('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('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('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('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(`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, + } +} diff --git a/frontend-nuxt/composables/useMapInit.ts b/frontend-nuxt/composables/useMapInit.ts index ba34268..89a0f53 100644 --- a/frontend-nuxt/composables/useMapInit.ts +++ b/frontend-nuxt/composables/useMapInit.ts @@ -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 - -/** 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 { - 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 + +/** 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 { + 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 } +} diff --git a/frontend-nuxt/composables/useMapUpdates.ts b/frontend-nuxt/composables/useMapUpdates.ts index b250dc7..89480b9 100644 --- a/frontend-nuxt/composables/useMapUpdates.ts +++ b/frontend-nuxt/composables/useMapUpdates.ts @@ -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 - -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 -} - -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 | 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 + +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 +} + +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 | 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 } +} diff --git a/frontend-nuxt/lib/Character.ts b/frontend-nuxt/lib/Character.ts index 71b51fc..6bfd696 100644 --- a/frontend-nuxt/lib/Character.ts +++ b/frontend-nuxt/lib/Character.ts @@ -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 = - '' + - `` + - '' + - '' - 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 = + '' + + `` + + '' + + '' + 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 +} diff --git a/frontend-nuxt/lib/__tests__/Character.test.ts b/frontend-nuxt/lib/__tests__/Character.test.ts index 8d10f2e..06b55db 100644 --- a/frontend-nuxt/lib/__tests__/Character.test.ts +++ b/frontend-nuxt/lib/__tests__/Character.test.ts @@ -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 { - 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 } - 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 } - 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 } - 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 } - 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 { + 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 } + 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 } + 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 } + 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 } + 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() + }) +}) diff --git a/frontend-nuxt/pages/admin/maps/[id].vue b/frontend-nuxt/pages/admin/maps/[id].vue index 09ff65b..dc12d6a 100644 --- a/frontend-nuxt/pages/admin/maps/[id].vue +++ b/frontend-nuxt/pages/admin/maps/[id].vue @@ -1,90 +1,90 @@ - - - + + + diff --git a/internal/app/handlers/admin.go b/internal/app/handlers/admin.go index 989e074..adc67b2 100644 --- a/internal/app/handlers/admin.go +++ b/internal/app/handlers/admin.go @@ -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") + } +} diff --git a/internal/app/handlers/handlers_test.go b/internal/app/handlers/handlers_test.go index 3b162ea..1a12d44 100644 --- a/internal/app/handlers/handlers_test.go +++ b/internal/app/handlers/handlers_test.go @@ -1,719 +1,719 @@ -package handlers_test - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - - "github.com/andyleap/hnh-map/internal/app" - "github.com/andyleap/hnh-map/internal/app/apperr" - "github.com/andyleap/hnh-map/internal/app/handlers" - "github.com/andyleap/hnh-map/internal/app/services" - "github.com/andyleap/hnh-map/internal/app/store" - "go.etcd.io/bbolt" - "golang.org/x/crypto/bcrypt" -) - -type testEnv struct { - h *handlers.Handlers - st *store.Store - auth *services.AuthService -} - -func newTestEnv(t *testing.T) *testEnv { - t.Helper() - dir := t.TempDir() - db, err := bbolt.Open(filepath.Join(dir, "test.db"), 0600, nil) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - st := store.New(db) - auth := services.NewAuthService(st) - gridUpdates := &app.Topic[app.TileData]{} - mergeUpdates := &app.Topic[app.Merge]{} - - mapSvc := services.NewMapService(services.MapServiceDeps{ - Store: st, - GridStorage: dir, - GridUpdates: gridUpdates, - MergeUpdates: mergeUpdates, - GetChars: func() []app.Character { return nil }, - }) - admin := services.NewAdminService(st, mapSvc) - client := services.NewClientService(services.ClientServiceDeps{ - Store: st, - MapSvc: mapSvc, - WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) }, - }) - export := services.NewExportService(st, mapSvc) - - h := handlers.New(auth, mapSvc, admin, client, export) - return &testEnv{h: h, st: st, auth: auth} -} - -func (env *testEnv) createUser(t *testing.T, username, password string, auths app.Auths) { - t.Helper() - hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) - u := app.User{Pass: hash, Auths: auths} - raw, _ := json.Marshal(u) - if err := env.st.Update(context.Background(), func(tx *bbolt.Tx) error { - return env.st.PutUser(tx, username, raw) - }); err != nil { - t.Fatal(err) - } -} - -func (env *testEnv) loginSession(t *testing.T, username string) string { - t.Helper() - return env.auth.CreateSession(context.Background(), username, false) -} - -func withSession(req *http.Request, sid string) *http.Request { - req.AddCookie(&http.Cookie{Name: "session", Value: sid}) - return req -} - -func TestAPISetup_NoUsers(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil) - rr := httptest.NewRecorder() - env.h.APISetup(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - - var resp struct{ SetupRequired bool } - _ = json.NewDecoder(rr.Body).Decode(&resp) - if !resp.SetupRequired { - t.Fatal("expected setupRequired=true") - } -} - -func TestAPISetup_WithUsers(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) - - req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil) - rr := httptest.NewRecorder() - env.h.APISetup(rr, req) - - var resp struct{ SetupRequired bool } - _ = json.NewDecoder(rr.Body).Decode(&resp) - if resp.SetupRequired { - t.Fatal("expected setupRequired=false with users") - } -} - -func TestAPISetup_WrongMethod(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodPost, "/map/api/setup", nil) - rr := httptest.NewRecorder() - env.h.APISetup(rr, req) - if rr.Code != http.StatusMethodNotAllowed { - t.Fatalf("expected 405, got %d", rr.Code) - } -} - -func TestAPILogin_Success(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP}) - - body := `{"user":"alice","pass":"secret"}` - req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body)) - rr := httptest.NewRecorder() - env.h.APILogin(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - cookies := rr.Result().Cookies() - found := false - for _, c := range cookies { - if c.Name == "session" && c.Value != "" { - found = true - } - } - if !found { - t.Fatal("expected session cookie") - } -} - -func TestAPILogin_WrongPassword(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP}) - - body := `{"user":"alice","pass":"wrong"}` - req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body)) - rr := httptest.NewRecorder() - env.h.APILogin(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestAPILogin_BadJSON(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader("{invalid")) - rr := httptest.NewRecorder() - env.h.APILogin(rr, req) - if rr.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", rr.Code) - } -} - -func TestAPILogin_MethodNotAllowed(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/map/api/login", nil) - rr := httptest.NewRecorder() - env.h.APILogin(rr, req) - if rr.Code != http.StatusMethodNotAllowed { - t.Fatalf("expected 405, got %d", rr.Code) - } -} - -func TestAPIMe_Authenticated(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid) - rr := httptest.NewRecorder() - env.h.APIMe(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - - var resp struct { - Username string `json:"username"` - Auths []string `json:"auths"` - } - _ = json.NewDecoder(rr.Body).Decode(&resp) - if resp.Username != "alice" { - t.Fatalf("expected alice, got %s", resp.Username) - } -} - -func TestAPIMe_Unauthenticated(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/map/api/me", nil) - rr := httptest.NewRecorder() - env.h.APIMe(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestAPILogout(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", nil) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/logout", nil), sid) - rr := httptest.NewRecorder() - env.h.APILogout(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - - req2 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid) - rr2 := httptest.NewRecorder() - env.h.APIMe(rr2, req2) - if rr2.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 after logout, got %d", rr2.Code) - } -} - -func TestAPIMeTokens_Authenticated(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid) - rr := httptest.NewRecorder() - env.h.APIMeTokens(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - var resp struct{ Tokens []string } - _ = json.NewDecoder(rr.Body).Decode(&resp) - if len(resp.Tokens) != 1 { - t.Fatalf("expected 1 token, got %d", len(resp.Tokens)) - } -} - -func TestAPIMeTokens_Unauthenticated(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil) - rr := httptest.NewRecorder() - env.h.APIMeTokens(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestAPIMeTokens_NoUploadAuth(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid) - rr := httptest.NewRecorder() - env.h.APIMeTokens(rr, req) - if rr.Code != http.StatusForbidden { - t.Fatalf("expected 403, got %d", rr.Code) - } -} - -func TestAPIMePassword(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "old", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - body := `{"pass":"newpass"}` - req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/password", strings.NewReader(body)), sid) - rr := httptest.NewRecorder() - env.h.APIMePassword(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } -} - -func TestAPIMeUpdate_UpdatesEmail(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - patchBody := `{"email":"test@example.com"}` - req := withSession(httptest.NewRequest(http.MethodPatch, "/map/api/me", strings.NewReader(patchBody)), sid) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - env.h.APIMeUpdate(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - - req2 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid) - rr2 := httptest.NewRecorder() - env.h.APIMe(rr2, req2) - if rr2.Code != http.StatusOK { - t.Fatalf("expected 200 on GET /me, got %d", rr2.Code) - } - var meResp struct { - Username string `json:"username"` - Email string `json:"email"` - } - if err := json.NewDecoder(rr2.Body).Decode(&meResp); err != nil { - t.Fatal(err) - } - if meResp.Email != "test@example.com" { - t.Fatalf("expected email test@example.com, got %q", meResp.Email) - } -} - -func TestAPIRouter_Me_MethodNotAllowed(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me", nil), sid) - rr := httptest.NewRecorder() - env.h.APIRouter(rr, req) - if rr.Code != http.StatusMethodNotAllowed { - t.Fatalf("expected 405 for POST /me, got %d", rr.Code) - } -} - -func TestAdminUsers_RequiresAdmin(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid) - rr := httptest.NewRecorder() - env.h.APIAdminUsers(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 for non-admin, got %d", rr.Code) - } -} - -func TestAdminUsers_ListAndCreate(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) - sid := env.loginSession(t, "admin") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid) - rr := httptest.NewRecorder() - env.h.APIAdminUsers(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - - body := `{"user":"bob","pass":"secret","auths":["map","upload"]}` - req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/users", strings.NewReader(body)), sid) - rr2 := httptest.NewRecorder() - env.h.APIAdminUsers(rr2, req2) - if rr2.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr2.Code, rr2.Body.String()) - } -} - -func TestAdminSettings(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) - sid := env.loginSession(t, "admin") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid) - rr := httptest.NewRecorder() - env.h.APIAdminSettingsGet(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - - body := `{"prefix":"pfx","title":"New Title","defaultHide":true}` - req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/settings", strings.NewReader(body)), sid) - rr2 := httptest.NewRecorder() - env.h.APIAdminSettingsPost(rr2, req2) - if rr2.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr2.Code) - } - - req3 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid) - rr3 := httptest.NewRecorder() - env.h.APIAdminSettingsGet(rr3, req3) - - var resp struct { - Prefix string `json:"prefix"` - DefaultHide bool `json:"defaultHide"` - Title string `json:"title"` - } - _ = json.NewDecoder(rr3.Body).Decode(&resp) - if resp.Prefix != "pfx" || !resp.DefaultHide || resp.Title != "New Title" { - t.Fatalf("unexpected settings: %+v", resp) - } -} - -func TestAdminWipe(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) - sid := env.loginSession(t, "admin") - - req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/wipe", nil), sid) - rr := httptest.NewRecorder() - env.h.APIAdminWipe(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } -} - -func TestAdminMaps(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) - sid := env.loginSession(t, "admin") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/maps", nil), sid) - rr := httptest.NewRecorder() - env.h.APIAdminMaps(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } -} - -func TestAPIRouter_NotFound(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/map/api/nonexistent", nil) - rr := httptest.NewRecorder() - env.h.APIRouter(rr, req) - if rr.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", rr.Code) - } -} - -func TestAPIRouter_Config(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/config", nil), sid) - rr := httptest.NewRecorder() - env.h.APIRouter(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } -} - -func TestAPIGetChars_Unauthenticated(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil) - rr := httptest.NewRecorder() - env.h.APIGetChars(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestAPIGetMarkers_Unauthenticated(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil) - rr := httptest.NewRecorder() - env.h.APIGetMarkers(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestAPIGetMaps_Unauthenticated(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/map/api/maps", nil) - rr := httptest.NewRecorder() - env.h.APIGetMaps(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestAPIGetChars_NoMarkersAuth(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil), sid) - rr := httptest.NewRecorder() - env.h.APIGetChars(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - var chars []interface{} - _ = json.NewDecoder(rr.Body).Decode(&chars) - if len(chars) != 0 { - t.Fatal("expected empty array without markers auth") - } -} - -func TestAPIGetMarkers_NoMarkersAuth(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "alice") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil), sid) - rr := httptest.NewRecorder() - env.h.APIGetMarkers(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - var markers []interface{} - _ = json.NewDecoder(rr.Body).Decode(&markers) - if len(markers) != 0 { - t.Fatal("expected empty array without markers auth") - } -} - -func TestRedirectLogout(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/logout", nil) - rr := httptest.NewRecorder() - env.h.RedirectLogout(rr, req) - if rr.Code != http.StatusFound { - t.Fatalf("expected 302, got %d", rr.Code) - } - if loc := rr.Header().Get("Location"); loc != "/login" { - t.Fatalf("expected redirect to /login, got %s", loc) - } -} - -func TestRedirectLogout_WrongPath(t *testing.T) { - env := newTestEnv(t) - req := httptest.NewRequest(http.MethodGet, "/other", nil) - rr := httptest.NewRecorder() - env.h.RedirectLogout(rr, req) - if rr.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", rr.Code) - } -} - -func TestHandleServiceError(t *testing.T) { - tests := []struct { - name string - err error - status int - }{ - {"not found", apperr.ErrNotFound, http.StatusNotFound}, - {"unauthorized", apperr.ErrUnauthorized, http.StatusUnauthorized}, - {"forbidden", apperr.ErrForbidden, http.StatusForbidden}, - {"bad request", apperr.ErrBadRequest, http.StatusBadRequest}, - {"oauth only", apperr.ErrOAuthOnly, http.StatusUnauthorized}, - {"provider unconfigured", apperr.ErrProviderUnconfigured, http.StatusServiceUnavailable}, - {"state expired", apperr.ErrStateExpired, http.StatusBadRequest}, - {"exchange failed", apperr.ErrExchangeFailed, http.StatusBadGateway}, - {"unknown", errors.New("something else"), http.StatusInternalServerError}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rr := httptest.NewRecorder() - handlers.HandleServiceError(rr, tt.err) - if rr.Code != tt.status { - t.Fatalf("expected %d, got %d", tt.status, rr.Code) - } - }) - } -} - -func TestAdminUserByName(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) - env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD}) - sid := env.loginSession(t, "admin") - - req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users/bob", nil), sid) - rr := httptest.NewRecorder() - env.h.APIAdminUserByName(rr, req, "bob") - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - var resp struct { - Username string `json:"username"` - Auths []string `json:"auths"` - } - _ = json.NewDecoder(rr.Body).Decode(&resp) - if resp.Username != "bob" { - t.Fatalf("expected bob, got %s", resp.Username) - } -} - -func TestAdminUserDelete(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) - env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP}) - sid := env.loginSession(t, "admin") - - req := withSession(httptest.NewRequest(http.MethodDelete, "/map/api/admin/users/bob", nil), sid) - rr := httptest.NewRecorder() - env.h.APIAdminUserDelete(rr, req, "bob") - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } -} - -func TestClientRouter_Locate(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) - ctx := context.Background() - tokens := env.auth.GenerateTokenForUser(ctx, "alice") - if len(tokens) == 0 { - t.Fatal("expected token") - } - gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} - raw, _ := json.Marshal(gd) - if err := env.st.Update(ctx, func(tx *bbolt.Tx) error { - return env.st.PutGrid(tx, "g1", raw) - }); err != nil { - t.Fatal(err) - } - - req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=g1", nil) - rr := httptest.NewRecorder() - env.h.ClientRouter(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) - } - if body := strings.TrimSpace(rr.Body.String()); body != "1;2;3" { - t.Fatalf("expected body 1;2;3, got %q", body) - } -} - -func TestClientRouter_Locate_NotFound(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) - tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") - if len(tokens) == 0 { - t.Fatal("expected token") - } - - req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=ghost", nil) - rr := httptest.NewRecorder() - env.h.ClientRouter(rr, req) - - if rr.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", rr.Code) - } -} - -func TestClientRouter_CheckVersion(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) - tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") - if len(tokens) == 0 { - t.Fatal("expected token") - } - - req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/checkVersion?version="+app.ClientVersion, nil) - rr := httptest.NewRecorder() - env.h.ClientRouter(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200 for matching version, got %d", rr.Code) - } - - req2 := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/checkVersion?version=other", nil) - rr2 := httptest.NewRecorder() - env.h.ClientRouter(rr2, req2) - if rr2.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for wrong version, got %d", rr2.Code) - } -} - -func TestClientRouter_InvalidToken(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/client/badtoken/locate?gridID=g1", nil) - rr := httptest.NewRecorder() - env := newTestEnv(t) - env.h.ClientRouter(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestClientRouter_PositionUpdate_BodyTooLarge(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) - tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") - if len(tokens) == 0 { - t.Fatal("expected token") - } - // Body larger than maxClientBodySize (2MB) - bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1) - req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/positionUpdate", bytes.NewReader(bigBody)) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - env.h.ClientRouter(rr, req) - if rr.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String()) - } -} - -func TestClientRouter_MarkerUpdate_BodyTooLarge(t *testing.T) { - env := newTestEnv(t) - env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) - tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") - if len(tokens) == 0 { - t.Fatal("expected token") - } - bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1) - req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/markerUpdate", bytes.NewReader(bigBody)) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - env.h.ClientRouter(rr, req) - if rr.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String()) - } -} +package handlers_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/andyleap/hnh-map/internal/app" + "github.com/andyleap/hnh-map/internal/app/apperr" + "github.com/andyleap/hnh-map/internal/app/handlers" + "github.com/andyleap/hnh-map/internal/app/services" + "github.com/andyleap/hnh-map/internal/app/store" + "go.etcd.io/bbolt" + "golang.org/x/crypto/bcrypt" +) + +type testEnv struct { + h *handlers.Handlers + st *store.Store + auth *services.AuthService +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + dir := t.TempDir() + db, err := bbolt.Open(filepath.Join(dir, "test.db"), 0600, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + st := store.New(db) + auth := services.NewAuthService(st) + gridUpdates := &app.Topic[app.TileData]{} + mergeUpdates := &app.Topic[app.Merge]{} + + mapSvc := services.NewMapService(services.MapServiceDeps{ + Store: st, + GridStorage: dir, + GridUpdates: gridUpdates, + MergeUpdates: mergeUpdates, + GetChars: func() []app.Character { return nil }, + }) + admin := services.NewAdminService(st, mapSvc) + client := services.NewClientService(services.ClientServiceDeps{ + Store: st, + MapSvc: mapSvc, + WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) }, + }) + export := services.NewExportService(st, mapSvc) + + h := handlers.New(auth, mapSvc, admin, client, export) + return &testEnv{h: h, st: st, auth: auth} +} + +func (env *testEnv) createUser(t *testing.T, username, password string, auths app.Auths) { + t.Helper() + hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + u := app.User{Pass: hash, Auths: auths} + raw, _ := json.Marshal(u) + if err := env.st.Update(context.Background(), func(tx *bbolt.Tx) error { + return env.st.PutUser(tx, username, raw) + }); err != nil { + t.Fatal(err) + } +} + +func (env *testEnv) loginSession(t *testing.T, username string) string { + t.Helper() + return env.auth.CreateSession(context.Background(), username, false) +} + +func withSession(req *http.Request, sid string) *http.Request { + req.AddCookie(&http.Cookie{Name: "session", Value: sid}) + return req +} + +func TestAPISetup_NoUsers(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil) + rr := httptest.NewRecorder() + env.h.APISetup(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var resp struct{ SetupRequired bool } + _ = json.NewDecoder(rr.Body).Decode(&resp) + if !resp.SetupRequired { + t.Fatal("expected setupRequired=true") + } +} + +func TestAPISetup_WithUsers(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) + + req := httptest.NewRequest(http.MethodGet, "/map/api/setup", nil) + rr := httptest.NewRecorder() + env.h.APISetup(rr, req) + + var resp struct{ SetupRequired bool } + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp.SetupRequired { + t.Fatal("expected setupRequired=false with users") + } +} + +func TestAPISetup_WrongMethod(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodPost, "/map/api/setup", nil) + rr := httptest.NewRecorder() + env.h.APISetup(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rr.Code) + } +} + +func TestAPILogin_Success(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP}) + + body := `{"user":"alice","pass":"secret"}` + req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body)) + rr := httptest.NewRecorder() + env.h.APILogin(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + cookies := rr.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == "session" && c.Value != "" { + found = true + } + } + if !found { + t.Fatal("expected session cookie") + } +} + +func TestAPILogin_WrongPassword(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "secret", app.Auths{app.AUTH_MAP}) + + body := `{"user":"alice","pass":"wrong"}` + req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader(body)) + rr := httptest.NewRecorder() + env.h.APILogin(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestAPILogin_BadJSON(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodPost, "/map/api/login", strings.NewReader("{invalid")) + rr := httptest.NewRecorder() + env.h.APILogin(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} + +func TestAPILogin_MethodNotAllowed(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/map/api/login", nil) + rr := httptest.NewRecorder() + env.h.APILogin(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rr.Code) + } +} + +func TestAPIMe_Authenticated(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid) + rr := httptest.NewRecorder() + env.h.APIMe(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp struct { + Username string `json:"username"` + Auths []string `json:"auths"` + } + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp.Username != "alice" { + t.Fatalf("expected alice, got %s", resp.Username) + } +} + +func TestAPIMe_Unauthenticated(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/map/api/me", nil) + rr := httptest.NewRecorder() + env.h.APIMe(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestAPILogout(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", nil) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/logout", nil), sid) + rr := httptest.NewRecorder() + env.h.APILogout(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + req2 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid) + rr2 := httptest.NewRecorder() + env.h.APIMe(rr2, req2) + if rr2.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 after logout, got %d", rr2.Code) + } +} + +func TestAPIMeTokens_Authenticated(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid) + rr := httptest.NewRecorder() + env.h.APIMeTokens(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var resp struct{ Tokens []string } + _ = json.NewDecoder(rr.Body).Decode(&resp) + if len(resp.Tokens) != 1 { + t.Fatalf("expected 1 token, got %d", len(resp.Tokens)) + } +} + +func TestAPIMeTokens_Unauthenticated(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil) + rr := httptest.NewRecorder() + env.h.APIMeTokens(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestAPIMeTokens_NoUploadAuth(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/tokens", nil), sid) + rr := httptest.NewRecorder() + env.h.APIMeTokens(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rr.Code) + } +} + +func TestAPIMePassword(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "old", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + body := `{"pass":"newpass"}` + req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me/password", strings.NewReader(body)), sid) + rr := httptest.NewRecorder() + env.h.APIMePassword(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestAPIMeUpdate_UpdatesEmail(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + patchBody := `{"email":"test@example.com"}` + req := withSession(httptest.NewRequest(http.MethodPatch, "/map/api/me", strings.NewReader(patchBody)), sid) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.h.APIMeUpdate(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + req2 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid) + rr2 := httptest.NewRecorder() + env.h.APIMe(rr2, req2) + if rr2.Code != http.StatusOK { + t.Fatalf("expected 200 on GET /me, got %d", rr2.Code) + } + var meResp struct { + Username string `json:"username"` + Email string `json:"email"` + } + if err := json.NewDecoder(rr2.Body).Decode(&meResp); err != nil { + t.Fatal(err) + } + if meResp.Email != "test@example.com" { + t.Fatalf("expected email test@example.com, got %q", meResp.Email) + } +} + +func TestAPIRouter_Me_MethodNotAllowed(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me", nil), sid) + rr := httptest.NewRecorder() + env.h.APIRouter(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 for POST /me, got %d", rr.Code) + } +} + +func TestAdminUsers_RequiresAdmin(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid) + rr := httptest.NewRecorder() + env.h.APIAdminUsers(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for non-admin, got %d", rr.Code) + } +} + +func TestAdminUsers_ListAndCreate(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) + sid := env.loginSession(t, "admin") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users", nil), sid) + rr := httptest.NewRecorder() + env.h.APIAdminUsers(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + body := `{"user":"bob","pass":"secret","auths":["map","upload"]}` + req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/users", strings.NewReader(body)), sid) + rr2 := httptest.NewRecorder() + env.h.APIAdminUsers(rr2, req2) + if rr2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr2.Code, rr2.Body.String()) + } +} + +func TestAdminSettings(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) + sid := env.loginSession(t, "admin") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid) + rr := httptest.NewRecorder() + env.h.APIAdminSettingsGet(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + body := `{"prefix":"pfx","title":"New Title","defaultHide":true}` + req2 := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/settings", strings.NewReader(body)), sid) + rr2 := httptest.NewRecorder() + env.h.APIAdminSettingsPost(rr2, req2) + if rr2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr2.Code) + } + + req3 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/settings", nil), sid) + rr3 := httptest.NewRecorder() + env.h.APIAdminSettingsGet(rr3, req3) + + var resp struct { + Prefix string `json:"prefix"` + DefaultHide bool `json:"defaultHide"` + Title string `json:"title"` + } + _ = json.NewDecoder(rr3.Body).Decode(&resp) + if resp.Prefix != "pfx" || !resp.DefaultHide || resp.Title != "New Title" { + t.Fatalf("unexpected settings: %+v", resp) + } +} + +func TestAdminWipe(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) + sid := env.loginSession(t, "admin") + + req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/admin/wipe", nil), sid) + rr := httptest.NewRecorder() + env.h.APIAdminWipe(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestAdminMaps(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) + sid := env.loginSession(t, "admin") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/maps", nil), sid) + rr := httptest.NewRecorder() + env.h.APIAdminMaps(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestAPIRouter_NotFound(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/map/api/nonexistent", nil) + rr := httptest.NewRecorder() + env.h.APIRouter(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestAPIRouter_Config(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/config", nil), sid) + rr := httptest.NewRecorder() + env.h.APIRouter(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestAPIGetChars_Unauthenticated(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil) + rr := httptest.NewRecorder() + env.h.APIGetChars(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestAPIGetMarkers_Unauthenticated(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil) + rr := httptest.NewRecorder() + env.h.APIGetMarkers(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestAPIGetMaps_Unauthenticated(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/map/api/maps", nil) + rr := httptest.NewRecorder() + env.h.APIGetMaps(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestAPIGetChars_NoMarkersAuth(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/characters", nil), sid) + rr := httptest.NewRecorder() + env.h.APIGetChars(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var chars []interface{} + _ = json.NewDecoder(rr.Body).Decode(&chars) + if len(chars) != 0 { + t.Fatal("expected empty array without markers auth") + } +} + +func TestAPIGetMarkers_NoMarkersAuth(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "alice") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/v1/markers", nil), sid) + rr := httptest.NewRecorder() + env.h.APIGetMarkers(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var markers []interface{} + _ = json.NewDecoder(rr.Body).Decode(&markers) + if len(markers) != 0 { + t.Fatal("expected empty array without markers auth") + } +} + +func TestRedirectLogout(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/logout", nil) + rr := httptest.NewRecorder() + env.h.RedirectLogout(rr, req) + if rr.Code != http.StatusFound { + t.Fatalf("expected 302, got %d", rr.Code) + } + if loc := rr.Header().Get("Location"); loc != "/login" { + t.Fatalf("expected redirect to /login, got %s", loc) + } +} + +func TestRedirectLogout_WrongPath(t *testing.T) { + env := newTestEnv(t) + req := httptest.NewRequest(http.MethodGet, "/other", nil) + rr := httptest.NewRecorder() + env.h.RedirectLogout(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestHandleServiceError(t *testing.T) { + tests := []struct { + name string + err error + status int + }{ + {"not found", apperr.ErrNotFound, http.StatusNotFound}, + {"unauthorized", apperr.ErrUnauthorized, http.StatusUnauthorized}, + {"forbidden", apperr.ErrForbidden, http.StatusForbidden}, + {"bad request", apperr.ErrBadRequest, http.StatusBadRequest}, + {"oauth only", apperr.ErrOAuthOnly, http.StatusUnauthorized}, + {"provider unconfigured", apperr.ErrProviderUnconfigured, http.StatusServiceUnavailable}, + {"state expired", apperr.ErrStateExpired, http.StatusBadRequest}, + {"exchange failed", apperr.ErrExchangeFailed, http.StatusBadGateway}, + {"unknown", errors.New("something else"), http.StatusInternalServerError}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := httptest.NewRecorder() + handlers.HandleServiceError(rr, tt.err) + if rr.Code != tt.status { + t.Fatalf("expected %d, got %d", tt.status, rr.Code) + } + }) + } +} + +func TestAdminUserByName(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) + env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD}) + sid := env.loginSession(t, "admin") + + req := withSession(httptest.NewRequest(http.MethodGet, "/map/api/admin/users/bob", nil), sid) + rr := httptest.NewRecorder() + env.h.APIAdminUserByName(rr, req, "bob") + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + var resp struct { + Username string `json:"username"` + Auths []string `json:"auths"` + } + _ = json.NewDecoder(rr.Body).Decode(&resp) + if resp.Username != "bob" { + t.Fatalf("expected bob, got %s", resp.Username) + } +} + +func TestAdminUserDelete(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "admin", "pass", app.Auths{app.AUTH_ADMIN}) + env.createUser(t, "bob", "pass", app.Auths{app.AUTH_MAP}) + sid := env.loginSession(t, "admin") + + req := withSession(httptest.NewRequest(http.MethodDelete, "/map/api/admin/users/bob", nil), sid) + rr := httptest.NewRecorder() + env.h.APIAdminUserDelete(rr, req, "bob") + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestClientRouter_Locate(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + ctx := context.Background() + tokens := env.auth.GenerateTokenForUser(ctx, "alice") + if len(tokens) == 0 { + t.Fatal("expected token") + } + gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} + raw, _ := json.Marshal(gd) + if err := env.st.Update(ctx, func(tx *bbolt.Tx) error { + return env.st.PutGrid(tx, "g1", raw) + }); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=g1", nil) + rr := httptest.NewRecorder() + env.h.ClientRouter(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if body := strings.TrimSpace(rr.Body.String()); body != "1;2;3" { + t.Fatalf("expected body 1;2;3, got %q", body) + } +} + +func TestClientRouter_Locate_NotFound(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") + if len(tokens) == 0 { + t.Fatal("expected token") + } + + req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=ghost", nil) + rr := httptest.NewRecorder() + env.h.ClientRouter(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestClientRouter_CheckVersion(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") + if len(tokens) == 0 { + t.Fatal("expected token") + } + + req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/checkVersion?version="+app.ClientVersion, nil) + rr := httptest.NewRecorder() + env.h.ClientRouter(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 for matching version, got %d", rr.Code) + } + + req2 := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/checkVersion?version=other", nil) + rr2 := httptest.NewRecorder() + env.h.ClientRouter(rr2, req2) + if rr2.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for wrong version, got %d", rr2.Code) + } +} + +func TestClientRouter_InvalidToken(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/client/badtoken/locate?gridID=g1", nil) + rr := httptest.NewRecorder() + env := newTestEnv(t) + env.h.ClientRouter(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +} + +func TestClientRouter_PositionUpdate_BodyTooLarge(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") + if len(tokens) == 0 { + t.Fatal("expected token") + } + // Body larger than maxClientBodySize (2MB) + bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1) + req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/positionUpdate", bytes.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.h.ClientRouter(rr, req) + if rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestClientRouter_MarkerUpdate_BodyTooLarge(t *testing.T) { + env := newTestEnv(t) + env.createUser(t, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) + tokens := env.auth.GenerateTokenForUser(context.Background(), "alice") + if len(tokens) == 0 { + t.Fatal("expected token") + } + bigBody := bytes.Repeat([]byte("x"), 2*1024*1024+1) + req := httptest.NewRequest(http.MethodPost, "/client/"+tokens[0]+"/markerUpdate", bytes.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + env.h.ClientRouter(rr, req) + if rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d: %s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/app/services/admin.go b/internal/app/services/admin.go index 6502313..8cea078 100644 --- a/internal/app/services/admin.go +++ b/internal/app/services/admin.go @@ -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 +} diff --git a/internal/app/services/admin_test.go b/internal/app/services/admin_test.go index 746609a..4c0c7ee 100644 --- a/internal/app/services/admin_test.go +++ b/internal/app/services/admin_test.go @@ -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") + } +} diff --git a/internal/app/services/map.go b/internal/app/services/map.go index d04c7d9..2c31541 100644 --- a/internal/app/services/map.go +++ b/internal/app/services/map.go @@ -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 +}