Refactor frontend components and enhance API integration

- Updated frontend-nuxt.mdc to specify usage of composables for API calls.
- Added new AuthCard and ConfirmModal components for improved UI consistency.
- Introduced UserAvatar component for user profile display, replacing previous Gravatar implementation.
- Implemented useFormSubmit composable for handling form submissions with loading and error states.
- Enhanced vitest.config.ts to include coverage reporting for composables and components.
- Removed deprecated useAdminApi and useAuth composables to streamline API interactions.
- Updated login and setup pages to utilize new components and composables for better user experience.
This commit is contained in:
2026-03-04 00:14:05 +03:00
parent f6375e7d0f
commit 8f769543f4
34 changed files with 878 additions and 379 deletions

View File

@@ -7,5 +7,5 @@ alwaysApply: false
# Frontend (Nuxt 3) # Frontend (Nuxt 3)
- All frontend source lives in **frontend-nuxt/** (pages, components, composables, layouts, plugins, `public/gfx`). Production build output goes to `frontend/` and is served by the Go backend. - All frontend source lives in **frontend-nuxt/** (pages, components, composables, layouts, plugins, `public/gfx`). Production build output goes to `frontend/` and is served by the Go backend.
- Public API to backend: use composables — e.g. `composables/useMapApi.ts`, `useAuth.ts`, `useAdminApi.ts` — not raw fetch in components. - Public API to backend: use `useMapApi()` from `composables/useMapApi.ts` for all API calls (auth, admin, config, maps, etc.); do not use raw fetch in components.
- Nuxt 3 conventions; ensure dev proxy in `nuxt.config.ts` points at backend when running locally (e.g. 3080 or 8080). - Nuxt 3 conventions; ensure dev proxy in `nuxt.config.ts` points at backend when running locally (e.g. 3080 or 8080).

View File

@@ -0,0 +1,12 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-base-200 via-base-300 to-primary/10 p-4 overflow-hidden">
<div class="card card-app w-full max-w-sm login-card">
<div class="card-body">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,71 @@
<template>
<dialog ref="dialogRef" class="modal" :aria-labelledby="titleId" @close="onClose">
<div class="modal-box">
<h2 :id="titleId" class="font-bold text-lg mb-2">{{ title }}</h2>
<p>{{ message }}</p>
<div class="modal-action">
<form method="dialog">
<button type="button" class="btn" @click="cancel">Cancel</button>
</form>
<button
type="button"
:class="danger ? 'btn btn-error' : 'btn btn-primary'"
:disabled="loading"
@click="confirm"
>
<span v-if="loading" class="loading loading-spinner loading-sm" />
<span v-else>{{ confirmLabel }}</span>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" aria-label="Close" @click="cancel">Close</button>
</form>
</dialog>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
modelValue: boolean
title: string
message: string
confirmLabel?: string
danger?: boolean
loading?: boolean
}>(),
{ confirmLabel: 'Confirm', danger: false, loading: false }
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
const titleId = computed(() => `confirm-modal-title-${Math.random().toString(36).slice(2)}`)
const dialogRef = ref<HTMLDialogElement | null>(null)
watch(
() => props.modelValue,
(open) => {
if (open) {
dialogRef.value?.showModal()
} else {
dialogRef.value?.close()
}
}
)
function cancel() {
dialogRef.value?.close()
emit('update:modelValue', false)
}
function onClose() {
emit('update:modelValue', false)
}
function confirm() {
emit('confirm')
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div class="avatar">
<div
class="rounded-full overflow-hidden flex items-center justify-center shrink-0"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<img
v-if="email && !gravatarError"
:src="useGravatarUrl(email, size)"
alt=""
class="w-full h-full object-cover"
@error="gravatarError = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-full h-full flex items-center justify-center"
:class="initialClass"
>
<span>{{ (username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
username: string
email?: string
size?: number
}>(),
{ size: 32 }
)
const gravatarError = ref(false)
const initialClass = computed(() => {
if (props.size <= 32) return 'text-sm'
if (props.size <= 40) return 'text-sm'
return 'text-2xl font-semibold'
})
</script>

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserAvatar from '../UserAvatar.vue'
const useGravatarUrlMock = vi.fn(() => 'https://gravatar.example/avatar')
vi.stubGlobal('useGravatarUrl', useGravatarUrlMock)
describe('UserAvatar', () => {
beforeEach(() => {
useGravatarUrlMock.mockReturnValue('https://gravatar.example/avatar')
})
it('renders fallback initial when no email', () => {
const wrapper = mount(UserAvatar, {
props: { username: 'alice' },
global: {
stubs: { useGravatarUrl: false },
},
})
const fallback = wrapper.find('.bg-primary')
expect(fallback.exists()).toBe(true)
expect(fallback.text()).toBe('A')
})
it('renders fallback initial for username', () => {
const wrapper = mount(UserAvatar, {
props: { username: 'bob' },
})
expect(wrapper.find('.bg-primary').text()).toBe('B')
})
it('uses size for style', () => {
const wrapper = mount(UserAvatar, {
props: { username: 'x', size: 40 },
})
const div = wrapper.find('.rounded-full')
expect(div.attributes('style')).toContain('width: 40px')
expect(div.attributes('style')).toContain('height: 40px')
})
})

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) {
stateByKey[key] = ref(init())
}
return stateByKey[key]
})
vi.stubGlobal('useState', useStateMock)
vi.stubGlobal('onMounted', vi.fn())
const storage: Record<string, string> = {}
const localStorageMock = {
getItem: vi.fn((key: string) => storage[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
storage[key] = value
}),
clear: vi.fn(() => {
for (const k of Object.keys(storage)) delete storage[k]
}),
}
vi.stubGlobal('localStorage', localStorageMock)
vi.stubGlobal('import.meta.server', false)
vi.stubGlobal('import.meta.client', true)
import { useMapBookmarks } from '../useMapBookmarks'
describe('useMapBookmarks', () => {
beforeEach(() => {
storage['hnh-map-bookmarks'] = '[]'
stateByKey['hnh-map-bookmarks-state'] = ref([])
localStorageMock.getItem.mockImplementation((key: string) => storage[key] ?? null)
localStorageMock.setItem.mockImplementation((key: string, value: string) => {
storage[key] = value
})
})
it('add adds a bookmark and refresh updates state', () => {
const { bookmarks, add, refresh } = useMapBookmarks()
refresh()
expect(bookmarks.value).toEqual([])
const id = add({ name: 'Home', mapId: 1, x: 0, y: 0 })
expect(id).toBeDefined()
expect(bookmarks.value).toHaveLength(1)
expect(bookmarks.value[0].name).toBe('Home')
expect(bookmarks.value[0].mapId).toBe(1)
expect(bookmarks.value[0].id).toBe(id)
})
it('update changes name', () => {
const { bookmarks, add, update, refresh } = useMapBookmarks()
refresh()
const id = add({ name: 'Old', mapId: 1, x: 0, y: 0 })
const ok = update(id, { name: 'New' })
expect(ok).toBe(true)
expect(bookmarks.value[0].name).toBe('New')
})
it('remove deletes bookmark', () => {
const { bookmarks, add, remove, refresh } = useMapBookmarks()
refresh()
const id = add({ name: 'X', mapId: 1, x: 0, y: 0 })
expect(bookmarks.value).toHaveLength(1)
remove(id)
expect(bookmarks.value).toHaveLength(0)
})
it('clear empties bookmarks', () => {
const { bookmarks, add, clear, refresh } = useMapBookmarks()
refresh()
add({ name: 'A', mapId: 1, x: 0, y: 0 })
expect(bookmarks.value).toHaveLength(1)
clear()
expect(bookmarks.value).toHaveLength(0)
})
})

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) {
stateByKey[key] = ref(init())
}
return stateByKey[key]
})
vi.stubGlobal('useState', useStateMock)
import { useToast } from '../useToast'
describe('useToast', () => {
beforeEach(() => {
stateByKey['hnh-map-toasts'] = ref([])
})
it('exposes toasts and show/dismiss', () => {
const { toasts, success, dismiss } = useToast()
expect(toasts.value).toEqual([])
success('Done!')
expect(toasts.value).toHaveLength(1)
expect(toasts.value[0].type).toBe('success')
expect(toasts.value[0].text).toBe('Done!')
const id = toasts.value[0].id
dismiss(id)
expect(toasts.value).toHaveLength(0)
})
it('error and info set type', () => {
const { toasts, error, info } = useToast()
error('Failed')
expect(toasts.value[0].type).toBe('error')
info('Note')
expect(toasts.value[1].type).toBe('info')
})
it('each toast has unique id', () => {
const { toasts, success } = useToast()
success('A')
success('B')
const ids = toasts.value.map((t) => t.id)
expect(new Set(ids).size).toBe(2)
})
})

View File

@@ -1,23 +0,0 @@
/** Admin API composable. Uses useMapApi internally. */
export function useAdminApi() {
const api = useMapApi()
return {
adminUsers: api.adminUsers,
adminUserByName: api.adminUserByName,
adminUserPost: api.adminUserPost,
adminUserDelete: api.adminUserDelete,
adminSettings: api.adminSettings,
adminSettingsPost: api.adminSettingsPost,
adminMaps: api.adminMaps,
adminMapPost: api.adminMapPost,
adminMapToggleHidden: api.adminMapToggleHidden,
adminWipe: api.adminWipe,
adminRebuildZooms: api.adminRebuildZooms,
adminExportUrl: api.adminExportUrl,
adminMerge: api.adminMerge,
adminWipeTile: api.adminWipeTile,
adminSetCoords: api.adminSetCoords,
adminHideMarker: api.adminHideMarker,
}
}

View File

@@ -1,15 +0,0 @@
import type { MeResponse } from '~/types/api'
/** Auth composable: login, logout, me, OAuth, setup. Uses useMapApi internally. */
export function useAuth() {
const api = useMapApi()
return {
login: api.login,
logout: api.logout,
me: api.me,
oauthLoginUrl: api.oauthLoginUrl,
oauthProviders: api.oauthProviders,
setupRequired: api.setupRequired,
}
}

View File

@@ -0,0 +1,24 @@
/**
* Composable for form submit with loading and error state.
* Use run(fn) to execute an async action; loading and error are updated automatically.
*/
export function useFormSubmit(defaultError = 'Something went wrong') {
const loading = ref(false)
const error = ref('')
async function run<T>(fn: () => Promise<T>): Promise<T | undefined> {
error.value = ''
loading.value = true
try {
const result = await fn()
return result
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : defaultError
return undefined
} finally {
loading.value = false
}
}
return { loading, error, run }
}

View File

@@ -37,23 +37,7 @@
<div class="dropdown dropdown-end flex items-center"> <div class="dropdown dropdown-end flex items-center">
<details ref="userDropdownRef" class="dropdown group"> <details ref="userDropdownRef" class="dropdown group">
<summary class="btn btn-ghost btn-sm gap-2 flex items-center min-h-9 h-9 cursor-pointer list-none [&::-webkit-details-marker]:hidden"> <summary class="btn btn-ghost btn-sm gap-2 flex items-center min-h-9 h-9 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="avatar"> <UserAvatar :username="me.username" :email="me.email" :size="32" />
<div class="rounded-full w-8 h-8 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarErrorDesktop"
:src="useGravatarUrl(me.email, 32)"
alt=""
class="w-full h-full object-cover"
@error="gravatarErrorDesktop = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
>
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<span class="max-w-[8rem] truncate font-medium">{{ me.username }}</span> <span class="max-w-[8rem] truncate font-medium">{{ me.username }}</span>
<svg class="size-4 opacity-70 shrink-0 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg class="size-4 opacity-70 shrink-0 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
@@ -128,23 +112,7 @@
<aside class="bg-base-200/95 backdrop-blur-xl min-h-full w-72 p-4 flex flex-col"> <aside class="bg-base-200/95 backdrop-blur-xl min-h-full w-72 p-4 flex flex-col">
<!-- Mobile: user + live when logged in --> <!-- Mobile: user + live when logged in -->
<div v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50"> <div v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50">
<div class="avatar"> <UserAvatar :username="me.username" :email="me.email" :size="40" />
<div class="rounded-full w-10 h-10 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarErrorDrawer"
:src="useGravatarUrl(me.email, 40)"
alt=""
class="w-full h-full object-cover"
@error="gravatarErrorDrawer = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-10 h-10 flex items-center justify-center"
>
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ me.username }}</p> <p class="font-medium truncate">{{ me.username }}</p>
<p class="text-xs flex items-center gap-1.5" :class="live ? 'text-success' : 'text-base-content/60'"> <p class="text-xs flex items-center gap-1.5" :class="live ? 'text-success' : 'text-base-content/60'">
@@ -227,8 +195,6 @@ const dark = ref(false)
/** Live when at least one of current user's characters is on the map (set by MapView). */ /** Live when at least one of current user's characters is on the map (set by MapView). */
const live = useState<boolean>('mapLive', () => false) const live = useState<boolean>('mapLive', () => false)
const me = useState<MeResponse | null>('me', () => null) const me = useState<MeResponse | null>('me', () => null)
const gravatarErrorDesktop = ref(false)
const gravatarErrorDrawer = ref(false)
const userDropdownRef = ref<HTMLDetailsElement | null>(null) const userDropdownRef = ref<HTMLDetailsElement | null>(null)
const drawerCheckboxRef = ref<HTMLInputElement | null>(null) const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
@@ -281,14 +247,6 @@ watch(
{ immediate: true } { immediate: true }
) )
watch(
() => me.value?.email,
() => {
gravatarErrorDesktop.value = false
gravatarErrorDrawer.value = false
}
)
function onThemeToggle() { function onThemeToggle() {
dark.value = !dark.value dark.value = !dark.value
applyTheme() applyTheme()

View File

@@ -253,44 +253,37 @@
</div> </div>
</div> </div>
<dialog ref="wipeModalRef" class="modal" aria-labelledby="wipe-modal-title"> <ConfirmModal
<div class="modal-box"> v-model="showWipeModal"
<h2 id="wipe-modal-title" class="font-bold text-lg mb-2">Confirm wipe</h2> title="Confirm wipe"
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p> message="Wipe all grids, markers, tiles and maps? This cannot be undone."
<div class="modal-action"> confirm-label="Wipe"
<form method="dialog"> :danger="true"
<button class="btn">Cancel</button> :loading="wiping"
</form> @confirm="doWipe"
<button class="btn btn-error" :disabled="wiping" @click="doWipe">Wipe</button> />
</div>
</div>
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
</dialog>
<dialog ref="rebuildModalRef" class="modal" aria-labelledby="rebuild-modal-title"> <ConfirmModal
<div class="modal-box"> v-model="showRebuildModal"
<h2 id="rebuild-modal-title" class="font-bold text-lg mb-2">Rebuild zooms</h2> title="Rebuild zooms"
<p>Rebuild tile zoom levels for all maps? This may take a while.</p> message="Rebuild tile zoom levels for all maps? This may take a while."
<div class="modal-action"> confirm-label="Rebuild"
<form method="dialog"> :loading="rebuilding"
<button class="btn">Cancel</button> @confirm="doRebuildZooms"
</form> />
<button class="btn btn-primary" :disabled="rebuilding" @click="doRebuildZooms">Rebuild</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
</dialog>
</template> </template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' }) definePageMeta({ middleware: 'admin' })
const api = useMapApi() const api = useMapApi()
const toast = useToast() const toast = useToast()
const users = ref<string[]>([]) const users = ref<string[]>([])
const maps = ref<Array<{ ID: number; Name: string; Hidden: boolean; Priority: boolean }>>([]) const maps = ref<MapInfoAdmin[]>([])
const settings = ref({ prefix: '', defaultHide: false, title: '' }) const settings = ref({ prefix: '', defaultHide: false, title: '' })
const savingSettings = ref(false) const savingSettings = ref(false)
const rebuilding = ref(false) const rebuilding = ref(false)
@@ -298,8 +291,8 @@ const wiping = ref(false)
const merging = ref(false) const merging = ref(false)
const mergeFile = ref<File | null>(null) const mergeFile = ref<File | null>(null)
const mergeFileRef = ref<HTMLInputElement | null>(null) const mergeFileRef = ref<HTMLInputElement | null>(null)
const wipeModalRef = ref<HTMLDialogElement | null>(null) const showWipeModal = ref(false)
const rebuildModalRef = ref<HTMLDialogElement | null>(null) const showRebuildModal = ref(false)
const loading = ref(true) const loading = ref(true)
const userSearch = ref('') const userSearch = ref('')
const mapSearch = ref('') const mapSearch = ref('')
@@ -386,17 +379,17 @@ async function saveSettings() {
} }
function confirmRebuildZooms() { function confirmRebuildZooms() {
rebuildModalRef.value?.showModal() showRebuildModal.value = true
} }
const { markRebuildDone } = useRebuildZoomsInvalidation() const { markRebuildDone } = useRebuildZoomsInvalidation()
async function doRebuildZooms() { async function doRebuildZooms() {
rebuildModalRef.value?.close()
rebuilding.value = true rebuilding.value = true
try { try {
await api.adminRebuildZooms() await api.adminRebuildZooms()
markRebuildDone() markRebuildDone()
showRebuildModal.value = false
toast.success('Zooms rebuilt.') toast.success('Zooms rebuilt.')
} catch (e) { } catch (e) {
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.') toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
@@ -406,14 +399,14 @@ async function doRebuildZooms() {
} }
function confirmWipe() { function confirmWipe() {
wipeModalRef.value?.showModal() showWipeModal.value = true
} }
async function doWipe() { async function doWipe() {
wiping.value = true wiping.value = true
try { try {
await api.adminWipe() await api.adminWipe()
wipeModalRef.value?.close() showWipeModal.value = false
await loadMaps() await loadMaps()
toast.success('All data wiped.') toast.success('All data wiped.')
} catch (e) { } catch (e) {

View File

@@ -37,13 +37,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' }) definePageMeta({ middleware: 'admin' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const api = useMapApi() const api = useMapApi()
const id = computed(() => parseInt(route.params.id as string, 10)) const id = computed(() => parseInt(route.params.id as string, 10))
const map = ref<{ ID: number; Name: string; Hidden: boolean; Priority: boolean } | null>(null) const map = ref<MapInfoAdmin | null>(null)
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const form = ref({ name: '', hidden: false, priority: false }) const form = ref({ name: '', hidden: false, priority: false })
const loading = ref(false) const loading = ref(false)

View File

@@ -47,18 +47,15 @@
</div> </div>
</form> </form>
<dialog ref="deleteModalRef" class="modal"> <ConfirmModal
<div class="modal-box"> v-model="showDeleteModal"
<p>Delete user {{ form.user }}?</p> title="Delete user"
<div class="modal-action"> :message="`Delete user ${form.user}?`"
<form method="dialog"> confirm-label="Delete"
<button class="btn">Cancel</button> :danger="true"
</form> :loading="deleting"
<button class="btn btn-error" :disabled="deleting" @click="doDelete">Delete</button> @confirm="doDelete"
</div> />
</div>
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
</dialog>
</div> </div>
</template> </template>
@@ -76,7 +73,7 @@ const authOptions = ['admin', 'map', 'markers', 'upload']
const loading = ref(false) const loading = ref(false)
const deleting = ref(false) const deleting = ref(false)
const error = ref('') const error = ref('')
const deleteModalRef = ref<HTMLDialogElement | null>(null) const showDeleteModal = ref(false)
onMounted(async () => { onMounted(async () => {
if (!isNew.value) { if (!isNew.value) {
@@ -108,14 +105,14 @@ async function submit() {
} }
function confirmDelete() { function confirmDelete() {
deleteModalRef.value?.showModal() showDeleteModal.value = true
} }
async function doDelete() { async function doDelete() {
deleting.value = true deleting.value = true
try { try {
await api.adminUserDelete(form.value.user) await api.adminUserDelete(form.value.user)
deleteModalRef.value?.close() showDeleteModal.value = false
await router.push('/admin') await router.push('/admin')
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Delete failed' error.value = e instanceof Error ? e.message : 'Delete failed'

View File

@@ -1,48 +1,44 @@
<template> <template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-base-200 via-base-300 to-primary/10 p-4 overflow-hidden"> <AuthCard>
<div class="card card-app w-full max-w-sm login-card"> <h1 class="card-title justify-center text-2xl">HnH Map</h1>
<div class="card-body"> <p class="text-center text-base-content/70 text-sm">Log in to continue</p>
<h1 class="card-title justify-center text-2xl">HnH Map</h1> <div v-if="(oauthProviders ?? []).length" class="flex flex-col gap-2">
<p class="text-center text-base-content/70 text-sm">Log in to continue</p> <a
<div v-if="(oauthProviders ?? []).length" class="flex flex-col gap-2"> v-for="p in (oauthProviders ?? [])"
<a :key="p"
v-for="p in (oauthProviders ?? [])" :href="api.oauthLoginUrl(p, redirect || undefined)"
:key="p" class="btn btn-outline gap-2"
:href="api.oauthLoginUrl(p, redirect || undefined)" >
class="btn btn-outline gap-2" <span v-if="p === 'google'">Login with Google</span>
> <span v-else>Login with {{ p }}</span>
<span v-if="p === 'google'">Login with Google</span> </a>
<span v-else>Login with {{ p }}</span> <div class="divider text-sm">or</div>
</a>
<div class="divider text-sm">or</div>
</div>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<fieldset class="fieldset">
<label class="label" for="user">User</label>
<input
id="user"
v-model="user"
type="text"
class="input min-h-11 touch-manipulation"
required
autocomplete="username"
/>
</fieldset>
<PasswordInput
v-model="pass"
label="Password"
required
autocomplete="current-password"
/>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm" />
<span v-else>Log in</span>
</button>
</form>
</div>
</div> </div>
</div> <form @submit.prevent="submit" class="flex flex-col gap-4">
<fieldset class="fieldset">
<label class="label" for="user">User</label>
<input
id="user"
v-model="user"
type="text"
class="input min-h-11 touch-manipulation"
required
autocomplete="username"
/>
</fieldset>
<PasswordInput
v-model="pass"
label="Password"
required
autocomplete="current-password"
/>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm" />
<span v-else>Log in</span>
</button>
</form>
</AuthCard>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -50,12 +46,11 @@
const user = ref('') const user = ref('')
const pass = ref('') const pass = ref('')
const error = ref('')
const loading = ref(false)
const oauthProviders = ref<string[]>([]) const oauthProviders = ref<string[]>([])
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const api = useMapApi() const api = useMapApi()
const { loading, error, run } = useFormSubmit('Login failed')
const redirect = computed(() => (route.query.redirect as string) || '') const redirect = computed(() => (route.query.redirect as string) || '')
@@ -64,15 +59,9 @@ onMounted(async () => {
}) })
async function submit() { async function submit() {
error.value = '' await run(async () => {
loading.value = true
try {
await api.login(user.value, pass.value) await api.login(user.value, pass.value)
await router.push(redirect.value || '/') await router.push(redirect.value || '/')
} catch (e: unknown) { })
error.value = e instanceof Error ? e.message : 'Login failed'
} finally {
loading.value = false
}
} }
</script> </script>

View File

@@ -16,23 +16,7 @@
</template> </template>
<template v-else-if="me"> <template v-else-if="me">
<div class="flex flex-wrap items-center gap-4"> <div class="flex flex-wrap items-center gap-4">
<div class="avatar"> <UserAvatar :username="me.username" :email="me.email" :size="56" />
<div class="rounded-full w-14 h-14 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarError"
:src="useGravatarUrl(me.email, 80)"
alt=""
class="w-full h-full object-cover"
@error="gravatarError = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-14 h-14 flex items-center justify-center"
>
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h2 class="text-lg font-semibold">{{ me.username }}</h2> <h2 class="text-lg font-semibold">{{ me.username }}</h2>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
@@ -159,7 +143,6 @@ const passMsg = ref('')
const passOk = ref(false) const passOk = ref(false)
const tokenError = ref('') const tokenError = ref('')
const copiedToken = ref<string | null>(null) const copiedToken = ref<string | null>(null)
const gravatarError = ref(false)
const emailEditing = ref(false) const emailEditing = ref(false)
const emailEdit = ref('') const emailEdit = ref('')
const loadingEmail = ref(false) const loadingEmail = ref(false)
@@ -185,7 +168,6 @@ async function saveEmail() {
const data = await api.me() const data = await api.me()
me.value = data me.value = data
emailEditing.value = false emailEditing.value = false
gravatarError.value = false
toast.success('Email updated') toast.success('Email updated')
} catch (e: unknown) { } catch (e: unknown) {
emailError.value = e instanceof Error ? e.message : 'Failed to update email' emailError.value = e instanceof Error ? e.message : 'Failed to update email'

View File

@@ -1,27 +1,25 @@
<template> <template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-base-200 via-base-300 to-primary/10 p-4 overflow-hidden"> <div class="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-base-200 via-base-300 to-primary/10 p-4 overflow-hidden">
<div class="card card-app w-full max-w-sm login-card"> <AuthCard>
<div class="card-body"> <h1 class="card-title justify-center">First-time setup</h1>
<h1 class="card-title justify-center">First-time setup</h1> <p class="text-sm text-base-content/80">
<p class="text-sm text-base-content/80"> This is the first run. Create the administrator account using the bootstrap password
This is the first run. Create the administrator account using the bootstrap password from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>). </p>
</p> <form @submit.prevent="submit" class="flex flex-col gap-4">
<form @submit.prevent="submit" class="flex flex-col gap-4"> <PasswordInput
<PasswordInput v-model="pass"
v-model="pass" label="Bootstrap password"
label="Bootstrap password" required
required autocomplete="current-password"
autocomplete="current-password" />
/> <p v-if="error" class="text-error text-sm">{{ error }}</p>
<p v-if="error" class="text-error text-sm">{{ error }}</p> <button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading">
<button type="submit" class="btn btn-primary min-h-11 touch-manipulation" :disabled="loading"> <span v-if="loading" class="loading loading-spinner loading-sm" />
<span v-if="loading" class="loading loading-spinner loading-sm" /> <span v-else>Create and log in</span>
<span v-else>Create and log in</span> </button>
</button> </form>
</form> </AuthCard>
</div>
</div>
<NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink> <NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink>
</div> </div>
</template> </template>
@@ -30,10 +28,9 @@
// No auth required; auth.global skips this path // No auth required; auth.global skips this path
const pass = ref('') const pass = ref('')
const error = ref('')
const loading = ref(false)
const router = useRouter() const router = useRouter()
const api = useMapApi() const api = useMapApi()
const { loading, error, run } = useFormSubmit('Setup failed')
onMounted(async () => { onMounted(async () => {
try { try {
@@ -45,17 +42,12 @@ onMounted(async () => {
}) })
async function submit() { async function submit() {
error.value = '' const ok = await run(async () => {
loading.value = true
try {
await api.login('admin', pass.value) await api.login('admin', pass.value)
await router.push('/profile') await router.push('/profile')
} catch (e: unknown) { })
error.value = (e as Error)?.message === 'Unauthorized' if (!ok && error.value === 'Unauthorized') {
? 'Invalid bootstrap password.' error.value = 'Invalid bootstrap password.'
: (e as Error)?.message || 'Setup failed'
} finally {
loading.value = false
} }
} }
</script> </script>

View File

@@ -6,6 +6,12 @@ export default defineConfig({
environment: 'happy-dom', environment: 'happy-dom',
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
globals: true, globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['composables/**/*.ts', 'components/**/*.vue', 'lib/**/*.ts'],
exclude: ['**/*.test.ts', '**/__tests__/**', '**/__mocks__/**'],
},
}, },
resolve: { resolve: {
alias: { alias: {

View File

@@ -32,8 +32,7 @@ func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
JSON(rw, http.StatusOK, list) JSON(rw, http.StatusOK, list)
return return
} }
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
s := h.requireAdmin(rw, req) s := h.requireAdmin(rw, req)
@@ -65,14 +64,17 @@ func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
// APIAdminUserByName handles GET /map/api/admin/users/:name. // APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) { func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
auths, found := h.Admin.GetUser(req.Context(), name) auths, found, err := h.Admin.GetUser(req.Context(), name)
if err != nil {
HandleServiceError(rw, err)
return
}
out := struct { out := struct {
Username string `json:"username"` Username string `json:"username"`
Auths []string `json:"auths"` Auths []string `json:"auths"`
@@ -85,8 +87,7 @@ func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request,
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name. // APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) { func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if req.Method != http.MethodDelete { if !h.requireMethod(rw, req, http.MethodDelete) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
s := h.requireAdmin(rw, req) s := h.requireAdmin(rw, req)
@@ -106,8 +107,7 @@ func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request,
// APIAdminSettingsGet handles GET /map/api/admin/settings. // APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -127,8 +127,7 @@ func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request
// APIAdminSettingsPost handles POST /map/api/admin/settings. // APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -152,8 +151,7 @@ func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Reques
// APIAdminMaps handles GET /map/api/admin/maps. // APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -178,8 +176,7 @@ func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, id
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -208,8 +205,7 @@ func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Req
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -230,8 +226,7 @@ func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Req
// APIAdminWipe handles POST /map/api/admin/wipe. // APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -327,8 +322,7 @@ func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request)
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms. // APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -343,8 +337,7 @@ func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Reques
// APIAdminExport handles GET /map/api/admin/export. // APIAdminExport handles GET /map/api/admin/export.
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
@@ -359,8 +352,7 @@ func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
// APIAdminMerge handles POST /map/api/admin/merge. // APIAdminMerge handles POST /map/api/admin/merge.
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {

View File

@@ -31,8 +31,7 @@ type meUpdateRequest struct {
// APILogin handles POST /map/api/login. // APILogin handles POST /map/api/login.
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
ctx := req.Context() ctx := req.Context()
@@ -73,8 +72,7 @@ func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
// APISetup handles GET /map/api/setup. // APISetup handles GET /map/api/setup.
func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
JSON(rw, http.StatusOK, struct { JSON(rw, http.StatusOK, struct {
@@ -84,8 +82,7 @@ func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
// APILogout handles POST /map/api/logout. // APILogout handles POST /map/api/logout.
func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
ctx := req.Context() ctx := req.Context()
@@ -98,14 +95,12 @@ func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
// APIMe handles GET /map/api/me. // APIMe handles GET /map/api/me.
func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
ctx := req.Context() ctx := req.Context()
s := h.Auth.GetSession(ctx, req) s := h.requireSession(rw, req)
if s == nil { if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return return
} }
out := meResponse{Username: s.Username, Auths: s.Auths} out := meResponse{Username: s.Username, Auths: s.Auths}
@@ -118,16 +113,14 @@ func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
// APIMeUpdate handles PATCH /map/api/me (update current user email). // APIMeUpdate handles PATCH /map/api/me (update current user email).
func (h *Handlers) APIMeUpdate(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIMeUpdate(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPatch { if !h.requireMethod(rw, req, http.MethodPatch) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED") return
}
s := h.requireSession(rw, req)
if s == nil {
return return
} }
ctx := req.Context() ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
var body meUpdateRequest var body meUpdateRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil { if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
@@ -142,16 +135,14 @@ func (h *Handlers) APIMeUpdate(rw http.ResponseWriter, req *http.Request) {
// APIMeTokens handles POST /map/api/me/tokens. // APIMeTokens handles POST /map/api/me/tokens.
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED") return
}
s := h.requireSession(rw, req)
if s == nil {
return return
} }
ctx := req.Context() ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
if !s.Auths.Has(app.AUTH_UPLOAD) { if !s.Auths.Has(app.AUTH_UPLOAD) {
JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN") JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN")
return return
@@ -166,16 +157,14 @@ func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
// APIMePassword handles POST /map/api/me/password. // APIMePassword handles POST /map/api/me/password.
func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if !h.requireMethod(rw, req, http.MethodPost) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED") return
}
s := h.requireSession(rw, req)
if s == nil {
return return
} }
ctx := req.Context() ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
var body passwordRequest var body passwordRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil { if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
@@ -190,8 +179,7 @@ func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
// APIOAuthProviders handles GET /map/api/oauth/providers. // APIOAuthProviders handles GET /map/api/oauth/providers.
func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
JSON(rw, http.StatusOK, services.OAuthProviders()) JSON(rw, http.StatusOK, services.OAuthProviders())
@@ -199,8 +187,7 @@ func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request)
// APIOAuthLogin handles GET /map/api/oauth/:provider/login. // APIOAuthLogin handles GET /map/api/oauth/:provider/login.
func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) { func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
redirect := req.URL.Query().Get("redirect") redirect := req.URL.Query().Get("redirect")
@@ -214,8 +201,7 @@ func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, prov
// APIOAuthCallback handles GET /map/api/oauth/:provider/callback. // APIOAuthCallback handles GET /map/api/oauth/:provider/callback.
func (h *Handlers) APIOAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) { func (h *Handlers) APIOAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet { if !h.requireMethod(rw, req, http.MethodGet) {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
code := req.URL.Query().Get("code") code := req.URL.Query().Get("code")

View File

@@ -24,7 +24,7 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context() ctx := req.Context()
username, err := h.Auth.ValidateClientToken(ctx, matches[1]) username, err := h.Auth.ValidateClientToken(ctx, matches[1])
if err != nil { if err != nil {
rw.WriteHeader(http.StatusUnauthorized) HandleServiceError(rw, err)
return return
} }
ctx = context.WithValue(ctx, app.ClientUsernameKey, username) ctx = context.WithValue(ctx, app.ClientUsernameKey, username)
@@ -47,10 +47,10 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
if req.FormValue("version") == app.ClientVersion { if req.FormValue("version") == app.ClientVersion {
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} else { } else {
rw.WriteHeader(http.StatusBadRequest) JSONError(rw, http.StatusBadRequest, "version mismatch", "BAD_REQUEST")
} }
default: default:
rw.WriteHeader(http.StatusNotFound) JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
} }
} }
@@ -58,9 +58,11 @@ func (h *Handlers) clientLocate(rw http.ResponseWriter, req *http.Request) {
gridID := req.FormValue("gridID") gridID := req.FormValue("gridID")
result, err := h.Client.Locate(req.Context(), gridID) result, err := h.Client.Locate(req.Context(), gridID)
if err != nil { if err != nil {
rw.WriteHeader(http.StatusNotFound) HandleServiceError(rw, err)
return return
} }
rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(result)) rw.Write([]byte(result))
} }
@@ -75,8 +77,11 @@ func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
result, err := h.Client.ProcessGridUpdate(req.Context(), grup) result, err := h.Client.ProcessGridUpdate(req.Context(), grup)
if err != nil { if err != nil {
slog.Error("grid update failed", "error", err) slog.Error("grid update failed", "error", err)
HandleServiceError(rw, err)
return return
} }
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(result.Response) json.NewEncoder(rw).Encode(result.Response)
} }
@@ -87,6 +92,7 @@ func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
} }
if err := req.ParseMultipartForm(app.MultipartMaxMemory); err != nil { if err := req.ParseMultipartForm(app.MultipartMaxMemory); err != nil {
slog.Error("multipart parse error", "error", err) slog.Error("multipart parse error", "error", err)
JSONError(rw, http.StatusBadRequest, "invalid multipart", "BAD_REQUEST")
return return
} }
id := req.FormValue("id") id := req.FormValue("id")
@@ -94,11 +100,13 @@ func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
file, _, err := req.FormFile("file") file, _, err := req.FormFile("file")
if err != nil { if err != nil {
slog.Error("form file error", "error", err) slog.Error("form file error", "error", err)
JSONError(rw, http.StatusBadRequest, "missing file", "BAD_REQUEST")
return return
} }
defer file.Close() defer file.Close()
if err := h.Client.ProcessGridUpload(req.Context(), id, extraData, file); err != nil { if err := h.Client.ProcessGridUpload(req.Context(), id, extraData, file); err != nil {
slog.Error("grid upload failed", "error", err) slog.Error("grid upload failed", "error", err)
HandleServiceError(rw, err)
} }
} }
@@ -107,10 +115,12 @@ func (h *Handlers) clientPositionUpdate(rw http.ResponseWriter, req *http.Reques
buf, err := io.ReadAll(req.Body) buf, err := io.ReadAll(req.Body)
if err != nil { if err != nil {
slog.Error("error reading position update", "error", err) slog.Error("error reading position update", "error", err)
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
return return
} }
if err := h.Client.UpdatePositions(req.Context(), buf); err != nil { if err := h.Client.UpdatePositions(req.Context(), buf); err != nil {
slog.Error("position update failed", "error", err) slog.Error("position update failed", "error", err)
HandleServiceError(rw, err)
} }
} }
@@ -119,9 +129,11 @@ func (h *Handlers) clientMarkerUpdate(rw http.ResponseWriter, req *http.Request)
buf, err := io.ReadAll(req.Body) buf, err := io.ReadAll(req.Body)
if err != nil { if err != nil {
slog.Error("error reading marker update", "error", err) slog.Error("error reading marker update", "error", err)
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
return return
} }
if err := h.Client.UploadMarkers(req.Context(), buf); err != nil { if err := h.Client.UploadMarkers(req.Context(), buf); err != nil {
slog.Error("marker update failed", "error", err) slog.Error("marker update failed", "error", err)
HandleServiceError(rw, err)
} }
} }

View File

@@ -35,6 +35,25 @@ func New(
} }
} }
// requireMethod writes 405 and returns false if req.Method != method; otherwise returns true.
func (h *Handlers) requireMethod(rw http.ResponseWriter, req *http.Request, method string) bool {
if req.Method != method {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return false
}
return true
}
// requireSession returns session or writes 401 and returns nil.
func (h *Handlers) requireSession(rw http.ResponseWriter, req *http.Request) *app.Session {
s := h.Auth.GetSession(req.Context(), req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return nil
}
return s
}
// requireAdmin returns session if admin, or writes 401 and returns nil. // requireAdmin returns session if admin, or writes 401 and returns nil.
func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.Session { func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.Session {
s := h.Auth.GetSession(req.Context(), req) s := h.Auth.GetSession(req.Context(), req)

View File

@@ -601,3 +601,79 @@ func TestAdminUserDelete(t *testing.T) {
t.Fatalf("expected 200, got %d", rr.Code) 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)
env.st.Update(ctx, func(tx *bbolt.Tx) error {
return env.st.PutGrid(tx, "g1", raw)
})
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)
}
}

View File

@@ -9,12 +9,11 @@ import (
// APIConfig handles GET /map/api/config. // APIConfig handles GET /map/api/config.
func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context() s := h.requireSession(rw, req)
s := h.Auth.GetSession(ctx, req)
if s == nil { if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return return
} }
ctx := req.Context()
config, err := h.Map.GetConfig(ctx, s.Auths) config, err := h.Map.GetConfig(ctx, s.Auths)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
@@ -36,8 +35,10 @@ type CharacterResponse struct {
// APIGetChars handles GET /map/api/v1/characters. // APIGetChars handles GET /map/api/v1/characters.
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context() s := h.requireSession(rw, req)
s := h.Auth.GetSession(ctx, req) if s == nil {
return
}
if !h.canAccessMap(s) { if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return return
@@ -64,12 +65,15 @@ func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
// APIGetMarkers handles GET /map/api/v1/markers. // APIGetMarkers handles GET /map/api/v1/markers.
func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context() s := h.requireSession(rw, req)
s := h.Auth.GetSession(ctx, req) if s == nil {
return
}
if !h.canAccessMap(s) { if !h.canAccessMap(s) {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED") JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return return
} }
ctx := req.Context()
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) { if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
JSON(rw, http.StatusOK, []interface{}{}) JSON(rw, http.StatusOK, []interface{}{})
return return

View File

@@ -73,7 +73,9 @@ var migrations = []func(tx *bbolt.Tx) error{
allTiles[string(k)] = zoomTiles allTiles[string(k)] = zoomTiles
return zoom.ForEach(func(tk, tv []byte) error { return zoom.ForEach(func(tk, tv []byte) error {
td := TileData{} td := TileData{}
json.Unmarshal(tv, &td) if err := json.Unmarshal(tv, &td); err != nil {
return err
}
zoomTiles[string(tk)] = td zoomTiles[string(tk)] = td
return nil return nil
}) })
@@ -161,7 +163,9 @@ var migrations = []func(tx *bbolt.Tx) error{
} }
return users.ForEach(func(k, v []byte) error { return users.ForEach(func(k, v []byte) error {
u := User{} u := User{}
json.Unmarshal(v, &u) if err := json.Unmarshal(v, &u); err != nil {
return err
}
if u.Auths.Has(AUTH_MAP) && !u.Auths.Has(AUTH_MARKERS) { if u.Auths.Has(AUTH_MAP) && !u.Auths.Has(AUTH_MARKERS) {
u.Auths = append(u.Auths, AUTH_MARKERS) u.Auths = append(u.Auths, AUTH_MARKERS)
raw, err := json.Marshal(u) raw, err := json.Marshal(u)

View File

@@ -20,6 +20,7 @@ type AdminService struct {
} }
// NewAdminService creates an AdminService with the given store and map service. // 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 { func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
return &AdminService{st: st, mapSvc: mapSvc} return &AdminService{st: st, mapSvc: mapSvc}
} }
@@ -37,19 +38,21 @@ func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
} }
// GetUser returns a user's permissions by username. // GetUser returns a user's permissions by username.
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool) { func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
s.st.View(ctx, func(tx *bbolt.Tx) error { err = s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw == nil { if raw == nil {
return nil return nil
} }
var u app.User var u app.User
json.Unmarshal(raw, &u) if err := json.Unmarshal(raw, &u); err != nil {
return err
}
auths = u.Auths auths = u.Auths
found = true found = true
return nil return nil
}) })
return auths, found return auths, found, err
} }
// CreateOrUpdateUser creates or updates a user. // CreateOrUpdateUser creates or updates a user.
@@ -60,7 +63,9 @@ func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string,
u := app.User{} u := app.User{}
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw != nil { if raw != nil {
json.Unmarshal(raw, &u) if err := json.Unmarshal(raw, &u); err != nil {
return err
}
} }
if pass != "" { if pass != "" {
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
@@ -88,7 +93,9 @@ func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
if uRaw != nil { if uRaw != nil {
var u app.User var u app.User
json.Unmarshal(uRaw, &u) if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
for _, tok := range u.Tokens { for _, tok := range u.Tokens {
s.st.DeleteToken(tx, tok) s.st.DeleteToken(tx, tok)
} }
@@ -140,7 +147,9 @@ func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error { return s.st.ForEachMap(tx, func(k, v []byte) error {
mi := app.MapInfo{} mi := app.MapInfo{}
json.Unmarshal(v, &mi) if err := json.Unmarshal(v, &mi); err != nil {
return err
}
if id, err := strconv.Atoi(string(k)); err == nil { if id, err := strconv.Atoi(string(k)); err == nil {
mi.ID = id mi.ID = id
} }
@@ -152,18 +161,23 @@ func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
} }
// GetMap returns a map by ID. // GetMap returns a map by ID.
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool) { func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) {
var mi *app.MapInfo var mi *app.MapInfo
s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetMap(tx, id) raw := s.st.GetMap(tx, id)
if raw != nil { if raw != nil {
mi = &app.MapInfo{} mi = &app.MapInfo{}
json.Unmarshal(raw, mi) return json.Unmarshal(raw, mi)
mi.ID = id
} }
return nil return nil
}) })
return mi, mi != 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. // UpdateMap updates a map's name, hidden, and priority fields.
@@ -172,7 +186,9 @@ func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidde
mi := app.MapInfo{} mi := app.MapInfo{}
raw := s.st.GetMap(tx, id) raw := s.st.GetMap(tx, id)
if raw != nil { if raw != nil {
json.Unmarshal(raw, &mi) if err := json.Unmarshal(raw, &mi); err != nil {
return err
}
} }
mi.ID = id mi.ID = id
mi.Name = name mi.Name = name
@@ -190,7 +206,9 @@ func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInf
raw := s.st.GetMap(tx, id) raw := s.st.GetMap(tx, id)
mi = &app.MapInfo{} mi = &app.MapInfo{}
if raw != nil { if raw != nil {
json.Unmarshal(raw, mi) if err := json.Unmarshal(raw, mi); err != nil {
return err
}
} }
mi.ID = id mi.ID = id
mi.Hidden = !mi.Hidden mi.Hidden = !mi.Hidden
@@ -340,7 +358,9 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
return nil return nil
} }
m := app.Marker{} m := app.Marker{}
json.Unmarshal(raw, &m) if err := json.Unmarshal(raw, &m); err != nil {
return err
}
m.Hidden = true m.Hidden = true
raw, _ = json.Marshal(m) raw, _ = json.Marshal(m)
grid.Put(key, raw) grid.Put(key, raw)

View File

@@ -52,9 +52,9 @@ func TestAdminGetUser_Found(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
auths, found := admin.GetUser(context.Background(), "alice") auths, found, err := admin.GetUser(context.Background(), "alice")
if !found { if err != nil || !found {
t.Fatal("expected found") t.Fatalf("expected found, err=%v", err)
} }
if !auths.Has(app.AUTH_MAP) { if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth") t.Fatal("expected map auth")
@@ -63,7 +63,10 @@ func TestAdminGetUser_Found(t *testing.T) {
func TestAdminGetUser_NotFound(t *testing.T) { func TestAdminGetUser_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
_, found := admin.GetUser(context.Background(), "ghost") _, found, err := admin.GetUser(context.Background(), "ghost")
if err != nil {
t.Fatal(err)
}
if found { if found {
t.Fatal("expected not found") t.Fatal("expected not found")
} }
@@ -78,9 +81,9 @@ func TestCreateOrUpdateUser_New(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
auths, found := admin.GetUser(ctx, "bob") auths, found, err := admin.GetUser(ctx, "bob")
if !found { if err != nil || !found {
t.Fatal("expected user to exist") t.Fatalf("expected user to exist, err=%v", err)
} }
if !auths.Has(app.AUTH_MAP) { if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth") t.Fatal("expected map auth")
@@ -97,9 +100,9 @@ func TestCreateOrUpdateUser_Update(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
auths, found := admin.GetUser(ctx, "alice") auths, found, err := admin.GetUser(ctx, "alice")
if !found { if err != nil || !found {
t.Fatal("expected user") t.Fatalf("expected user, err=%v", err)
} }
if !auths.Has(app.AUTH_ADMIN) { if !auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth after update") t.Fatal("expected admin auth after update")
@@ -139,9 +142,9 @@ func TestDeleteUser(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, found := admin.GetUser(ctx, "alice") _, found, err := admin.GetUser(ctx, "alice")
if found { if err != nil || found {
t.Fatal("expected user to be deleted") t.Fatalf("expected user to be deleted, err=%v", err)
} }
} }
@@ -210,9 +213,9 @@ func TestMapCRUD(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
mi, found := admin.GetMap(ctx, 1) mi, found, err := admin.GetMap(ctx, 1)
if !found || mi == nil { if err != nil || !found || mi == nil {
t.Fatal("expected map") t.Fatalf("expected map, err=%v", err)
} }
if mi.Name != "world" { if mi.Name != "world" {
t.Fatalf("expected world, got %s", mi.Name) t.Fatalf("expected world, got %s", mi.Name)
@@ -285,7 +288,10 @@ func TestWipe(t *testing.T) {
func TestGetMap_NotFound(t *testing.T) { func TestGetMap_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
_, found := admin.GetMap(context.Background(), 999) _, found, err := admin.GetMap(context.Background(), 999)
if err != nil {
t.Fatal(err)
}
if found { if found {
t.Fatal("expected not found") t.Fatal("expected not found")
} }

View File

@@ -41,6 +41,7 @@ type AuthService struct {
} }
// NewAuthService creates an AuthService with the given store. // NewAuthService creates an AuthService with the given store.
// Uses direct args (single dependency) rather than a deps struct.
func NewAuthService(st *store.Store) *AuthService { func NewAuthService(st *store.Store) *AuthService {
return &AuthService{st: st} return &AuthService{st: st}
} }
@@ -121,12 +122,14 @@ func (s *AuthService) CreateSession(ctx context.Context, username string, tempAd
// GetUser returns user if username/password match. // GetUser returns user if username/password match.
func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.User { func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.User {
var u *app.User var u *app.User
s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw == nil { if raw == nil {
return nil return nil
} }
json.Unmarshal(raw, &u) if err := json.Unmarshal(raw, &u); err != nil {
return err
}
if u.Pass == nil { if u.Pass == nil {
u = nil u = nil
return nil return nil
@@ -136,20 +139,26 @@ func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.U
return nil return nil
} }
return nil return nil
}) }); err != nil {
return nil
}
return u return u
} }
// GetUserByUsername returns user without password check (for OAuth-only check). // GetUserByUsername returns user without password check (for OAuth-only check).
func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *app.User { func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *app.User {
var u *app.User var u *app.User
s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw != nil { if raw != nil {
json.Unmarshal(raw, &u) if err := json.Unmarshal(raw, &u); err != nil {
return err
}
} }
return nil return nil
}) }); err != nil {
return nil
}
return u return u
} }
@@ -205,11 +214,13 @@ func GetBootstrapPassword() string {
// GetUserTokensAndPrefix returns tokens and config prefix for a user. // GetUserTokensAndPrefix returns tokens and config prefix for a user.
func (s *AuthService) GetUserTokensAndPrefix(ctx context.Context, username string) (tokens []string, prefix string) { func (s *AuthService) GetUserTokensAndPrefix(ctx context.Context, username string) (tokens []string, prefix string) {
s.st.View(ctx, func(tx *bbolt.Tx) error { _ = s.st.View(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
if uRaw != nil { if uRaw != nil {
var u app.User var u app.User
json.Unmarshal(uRaw, &u) if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
tokens = u.Tokens tokens = u.Tokens
} }
if p := s.st.GetConfig(tx, "prefix"); p != nil { if p := s.st.GetConfig(tx, "prefix"); p != nil {
@@ -232,7 +243,9 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
u := app.User{} u := app.User{}
if uRaw != nil { if uRaw != nil {
json.Unmarshal(uRaw, &u) if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
} }
u.Tokens = append(u.Tokens, token) u.Tokens = append(u.Tokens, token)
tokens = u.Tokens tokens = u.Tokens
@@ -252,7 +265,9 @@ func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
u := app.User{} u := app.User{}
if uRaw != nil { if uRaw != nil {
json.Unmarshal(uRaw, &u) if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
} }
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -297,7 +312,9 @@ func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (st
return apperr.ErrUnauthorized return apperr.ErrUnauthorized
} }
var u app.User var u app.User
json.Unmarshal(uRaw, &u) if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
if !u.Auths.Has(app.AUTH_UPLOAD) { if !u.Auths.Has(app.AUTH_UPLOAD) {
return apperr.ErrForbidden return apperr.ErrForbidden
} }
@@ -401,7 +418,9 @@ func (s *AuthService) OAuthHandleCallback(ctx context.Context, provider, code, s
if raw == nil { if raw == nil {
return apperr.ErrBadRequest return apperr.ErrBadRequest
} }
json.Unmarshal(raw, &st) if err := json.Unmarshal(raw, &st); err != nil {
return err
}
return s.st.DeleteOAuthState(tx, state) return s.st.DeleteOAuthState(tx, state)
}) })
if err != nil || st.Provider == "" { if err != nil || st.Provider == "" {
@@ -479,8 +498,8 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
err := s.st.Update(ctx, func(tx *bbolt.Tx) error { err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
_ = s.st.ForEachUser(tx, func(k, v []byte) error { _ = s.st.ForEachUser(tx, func(k, v []byte) error {
user := app.User{} user := app.User{}
if json.Unmarshal(v, &user) != nil { if err := json.Unmarshal(v, &user); err != nil {
return nil return err
} }
if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub { if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub {
username = string(k) username = string(k)
@@ -491,7 +510,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw != nil { if raw != nil {
user := app.User{} user := app.User{}
json.Unmarshal(raw, &user) if err := json.Unmarshal(raw, &user); err != nil {
return err
}
if user.OAuthLinks == nil { if user.OAuthLinks == nil {
user.OAuthLinks = map[string]string{provider: sub} user.OAuthLinks = map[string]string{provider: sub}
} else { } else {

View File

@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/apperr"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
@@ -63,7 +64,7 @@ func (s *ClientService) Locate(ctx context.Context, gridID string) (string, erro
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, gridID) raw := s.st.GetGrid(tx, gridID)
if raw == nil { if raw == nil {
return fmt.Errorf("grid not found") return apperr.ErrNotFound
} }
cur := app.GridData{} cur := app.GridData{}
if err := json.Unmarshal(raw, &cur); err != nil { if err := json.Unmarshal(raw, &cur); err != nil {
@@ -110,7 +111,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
gridRaw := grids.Get([]byte(grid)) gridRaw := grids.Get([]byte(grid))
if gridRaw != nil { if gridRaw != nil {
gd := app.GridData{} gd := app.GridData{}
json.Unmarshal(gridRaw, &gd) if err := json.Unmarshal(gridRaw, &gd); err != nil {
return err
}
maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y} maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y}
} }
} }
@@ -152,7 +155,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
mi := app.MapInfo{} mi := app.MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id))) mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil { if mraw != nil {
json.Unmarshal(mraw, &mi) if err := json.Unmarshal(mraw, &mi); err != nil {
return err
}
} }
if mi.Priority { if mi.Priority {
mapid = id mapid = id
@@ -171,7 +176,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
for y, grid := range row { for y, grid := range row {
cur := app.GridData{} cur := app.GridData{}
if curRaw := grids.Get([]byte(grid)); curRaw != nil { if curRaw := grids.Get([]byte(grid)); curRaw != nil {
json.Unmarshal(curRaw, &cur) if err := json.Unmarshal(curRaw, &cur); err != nil {
return err
}
if time.Now().After(cur.NextUpdate) { if time.Now().After(cur.NextUpdate) {
greq.GridRequests = append(greq.GridRequests, grid) greq.GridRequests = append(greq.GridRequests, grid)
} }
@@ -192,7 +199,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 { if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 {
if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil { if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil {
cur := app.GridData{} cur := app.GridData{}
json.Unmarshal(curRaw, &cur) if err := json.Unmarshal(curRaw, &cur); err != nil {
return err
}
greq.Map = cur.Map greq.Map = cur.Map
greq.Coords = cur.Coord greq.Coords = cur.Coord
} }
@@ -200,7 +209,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
if len(maps) > 1 { if len(maps) > 1 {
grids.ForEach(func(k, v []byte) error { grids.ForEach(func(k, v []byte) error {
gd := app.GridData{} gd := app.GridData{}
json.Unmarshal(v, &gd) if err := json.Unmarshal(v, &gd); err != nil {
return err
}
if gd.Map == mapid { if gd.Map == mapid {
return nil return nil
} }
@@ -216,7 +227,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
} }
tileraw := zoom.Get([]byte(gd.Coord.Name())) tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil { if tileraw != nil {
json.Unmarshal(tileraw, &td) if err := json.Unmarshal(tileraw, &td); err != nil {
return err
}
} }
gd.Map = mapid gd.Map = mapid

View File

@@ -1,9 +1,14 @@
package services_test package services_test
import ( import (
"context"
"encoding/json"
"testing" "testing"
"github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services" "github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt"
) )
func TestFixMultipartContentType_NeedsQuoting(t *testing.T) { func TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
@@ -30,3 +35,57 @@ func TestFixMultipartContentType_Normal(t *testing.T) {
t.Fatalf("expected unchanged, got %q", got) t.Fatalf("expected unchanged, got %q", got)
} }
} }
func newTestClientService(t *testing.T) (*services.ClientService, *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]{},
})
client := services.NewClientService(services.ClientServiceDeps{
Store: st,
MapSvc: mapSvc,
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
})
return client, st
}
func TestClientLocate_Found(t *testing.T) {
client, st := newTestClientService(t)
ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
raw, _ := json.Marshal(gd)
st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw)
})
result, err := client.Locate(ctx, "g1")
if err != nil {
t.Fatal(err)
}
if result != "1;2;3" {
t.Fatalf("expected 1;2;3, got %q", result)
}
}
func TestClientLocate_NotFound(t *testing.T) {
client, _ := newTestClientService(t)
_, err := client.Locate(context.Background(), "ghost")
if err == nil {
t.Fatal("expected error for unknown grid")
}
}
func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
client, _ := newTestClientService(t)
ctx := context.Background()
result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}

View File

@@ -30,6 +30,7 @@ type ExportService struct {
} }
// NewExportService creates an ExportService with the given store and map service. // NewExportService creates an ExportService with the given store and map service.
// Uses direct args (two dependencies) rather than a deps struct.
func NewExportService(st *store.Store, mapSvc *MapService) *ExportService { func NewExportService(st *store.Store, mapSvc *MapService) *ExportService {
return &ExportService{st: st, mapSvc: mapSvc} return &ExportService{st: st, mapSvc: mapSvc}
} }
@@ -190,7 +191,9 @@ func (s *ExportService) Merge(ctx context.Context, zr *zip.Reader) error {
gridRaw := grids.Get([]byte(gid)) gridRaw := grids.Get([]byte(gid))
if gridRaw != nil { if gridRaw != nil {
gd := app.GridData{} gd := app.GridData{}
json.Unmarshal(gridRaw, &gd) if err := json.Unmarshal(gridRaw, &gd); err != nil {
return err
}
ops = append(ops, TileOp{ ops = append(ops, TileOp{
MapID: gd.Map, MapID: gd.Map,
X: gd.Coord.X, X: gd.Coord.X,
@@ -265,7 +268,9 @@ func (s *ExportService) processMergeJSON(
gridRaw := grids.Get([]byte(v)) gridRaw := grids.Get([]byte(v))
if gridRaw != nil { if gridRaw != nil {
gd := app.GridData{} gd := app.GridData{}
json.Unmarshal(gridRaw, &gd) if err := json.Unmarshal(gridRaw, &gd); err != nil {
return err
}
existingMaps[gd.Map] = struct{ X, Y int }{gd.Coord.X - c.X, gd.Coord.Y - c.Y} existingMaps[gd.Map] = struct{ X, Y int }{gd.Coord.X - c.X, gd.Coord.Y - c.Y}
} }
} }
@@ -301,7 +306,9 @@ func (s *ExportService) processMergeJSON(
mi := app.MapInfo{} mi := app.MapInfo{}
mraw := mapB.Get([]byte(strconv.Itoa(id))) mraw := mapB.Get([]byte(strconv.Itoa(id)))
if mraw != nil { if mraw != nil {
json.Unmarshal(mraw, &mi) if err := json.Unmarshal(mraw, &mi); err != nil {
return err
}
} }
if mi.Priority { if mi.Priority {
mapid = id mapid = id
@@ -333,7 +340,9 @@ func (s *ExportService) processMergeJSON(
if len(existingMaps) > 1 { if len(existingMaps) > 1 {
grids.ForEach(func(k, v []byte) error { grids.ForEach(func(k, v []byte) error {
gd := app.GridData{} gd := app.GridData{}
json.Unmarshal(v, &gd) if err := json.Unmarshal(v, &gd); err != nil {
return err
}
if gd.Map == mapid { if gd.Map == mapid {
return nil return nil
} }
@@ -349,7 +358,9 @@ func (s *ExportService) processMergeJSON(
} }
tileraw := zoom.Get([]byte(gd.Coord.Name())) tileraw := zoom.Get([]byte(gd.Coord.Name()))
if tileraw != nil { if tileraw != nil {
json.Unmarshal(tileraw, &td) if err := json.Unmarshal(tileraw, &td); err != nil {
return err
}
} }
gd.Map = mapid gd.Map = mapid

View File

@@ -0,0 +1,64 @@
package services_test
import (
"bytes"
"context"
"encoding/json"
"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 newTestExportService(t *testing.T) (*services.ExportService, *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.NewExportService(st, mapSvc), st
}
func TestExport_EmptyDB(t *testing.T) {
export, _ := newTestExportService(t)
var buf bytes.Buffer
err := export.Export(context.Background(), &buf)
if err != nil {
t.Fatal(err)
}
if buf.Len() == 0 {
t.Fatal("expected non-empty zip output")
}
// ZIP magic number
if buf.Len() < 4 || buf.Bytes()[0] != 0x50 || buf.Bytes()[1] != 0x4b {
t.Fatal("expected valid zip header")
}
}
func TestExport_WithGrid(t *testing.T) {
export, st := newTestExportService(t)
ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 0, Y: 0}}
gdRaw, _ := json.Marshal(gd)
mi := app.MapInfo{ID: 1, Name: "test", Hidden: false}
miRaw, _ := json.Marshal(mi)
st.Update(ctx, func(tx *bbolt.Tx) error {
if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
return err
}
return st.PutMap(tx, 1, miRaw)
})
var buf bytes.Buffer
err := export.Export(ctx, &buf)
if err != nil {
t.Fatal(err)
}
if buf.Len() < 4 {
t.Fatal("expected zip data")
}
}

View File

@@ -77,13 +77,17 @@ func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, erro
} }
return grid.ForEach(func(k, v []byte) error { return grid.ForEach(func(k, v []byte) error {
marker := app.Marker{} marker := app.Marker{}
json.Unmarshal(v, &marker) if err := json.Unmarshal(v, &marker); err != nil {
return err
}
graw := grids.Get([]byte(marker.GridID)) graw := grids.Get([]byte(marker.GridID))
if graw == nil { if graw == nil {
return nil return nil
} }
g := app.GridData{} g := app.GridData{}
json.Unmarshal(graw, &g) if err := json.Unmarshal(graw, &g); err != nil {
return err
}
markers = append(markers, app.FrontendMarker{ markers = append(markers, app.FrontendMarker{
Image: marker.Image, Image: marker.Image,
Hidden: marker.Hidden, Hidden: marker.Hidden,
@@ -111,7 +115,9 @@ func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app
return nil return nil
} }
mi := &app.MapInfo{} mi := &app.MapInfo{}
json.Unmarshal(v, mi) if err := json.Unmarshal(v, mi); err != nil {
return err
}
if mi.Hidden && !showHidden { if mi.Hidden && !showHidden {
return nil return nil
} }
@@ -165,14 +171,16 @@ func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, err
// GetTile returns a tile by map ID, coordinate, and zoom level. // 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 { func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
var td *app.TileData var td *app.TileData
s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetTile(tx, mapID, zoom, c.Name()) raw := s.st.GetTile(tx, mapID, zoom, c.Name())
if raw != nil { if raw != nil {
td = &app.TileData{} td = &app.TileData{}
json.Unmarshal(raw, td) return json.Unmarshal(raw, td)
} }
return nil return nil
}) }); err != nil {
return nil
}
return td return td
} }
@@ -259,7 +267,9 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
} }
b.ForEach(func(k, v []byte) error { b.ForEach(func(k, v []byte) error {
grid := app.GridData{} grid := app.GridData{}
json.Unmarshal(v, &grid) if err := json.Unmarshal(v, &grid); err != nil {
return err
}
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{} needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil return nil
@@ -318,7 +328,9 @@ func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
s.st.View(ctx, func(tx *bbolt.Tx) error { s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error { return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
td := app.TileData{} td := app.TileData{}
json.Unmarshal(v, &td) if err := json.Unmarshal(v, &td); err != nil {
return err
}
cache = append(cache, TileCache{ cache = append(cache, TileCache{
M: td.MapID, M: td.MapID,
X: td.Coord.X, X: td.Coord.X,

View File

@@ -32,6 +32,8 @@ func (t *Topic[T]) Send(b *T) {
// Close closes all subscriber channels. // Close closes all subscriber channels.
func (t *Topic[T]) Close() { func (t *Topic[T]) Close() {
t.mu.Lock()
defer t.mu.Unlock()
for _, c := range t.c { for _, c := range t.c {
close(c) close(c)
} }