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:
@@ -7,5 +7,5 @@ alwaysApply: false
|
||||
# Frontend (Nuxt 3)
|
||||
|
||||
- All frontend source lives in **frontend-nuxt/** (pages, components, composables, layouts, plugins, `public/gfx`). Production build output goes to `frontend/` and is served by the Go backend.
|
||||
- Public API to backend: use composables — e.g. `composables/useMapApi.ts`, `useAuth.ts`, `useAdminApi.ts` — not raw fetch in components.
|
||||
- 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).
|
||||
|
||||
12
frontend-nuxt/components/AuthCard.vue
Normal file
12
frontend-nuxt/components/AuthCard.vue
Normal 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>
|
||||
71
frontend-nuxt/components/ConfirmModal.vue
Normal file
71
frontend-nuxt/components/ConfirmModal.vue
Normal 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>
|
||||
42
frontend-nuxt/components/UserAvatar.vue
Normal file
42
frontend-nuxt/components/UserAvatar.vue
Normal 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>
|
||||
40
frontend-nuxt/components/__tests__/UserAvatar.test.ts
Normal file
40
frontend-nuxt/components/__tests__/UserAvatar.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
79
frontend-nuxt/composables/__tests__/useMapBookmarks.test.ts
Normal file
79
frontend-nuxt/composables/__tests__/useMapBookmarks.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
49
frontend-nuxt/composables/__tests__/useToast.test.ts
Normal file
49
frontend-nuxt/composables/__tests__/useToast.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
24
frontend-nuxt/composables/useFormSubmit.ts
Normal file
24
frontend-nuxt/composables/useFormSubmit.ts
Normal 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 }
|
||||
}
|
||||
@@ -37,23 +37,7 @@
|
||||
<div class="dropdown dropdown-end flex items-center">
|
||||
<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">
|
||||
<div class="avatar">
|
||||
<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>
|
||||
<UserAvatar :username="me.username" :email="me.email" :size="32" />
|
||||
<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">
|
||||
<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">
|
||||
<!-- 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 class="avatar">
|
||||
<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>
|
||||
<UserAvatar :username="me.username" :email="me.email" :size="40" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<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'">
|
||||
@@ -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). */
|
||||
const live = useState<boolean>('mapLive', () => false)
|
||||
const me = useState<MeResponse | null>('me', () => null)
|
||||
const gravatarErrorDesktop = ref(false)
|
||||
const gravatarErrorDrawer = ref(false)
|
||||
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
|
||||
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -281,14 +247,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => me.value?.email,
|
||||
() => {
|
||||
gravatarErrorDesktop.value = false
|
||||
gravatarErrorDrawer.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function onThemeToggle() {
|
||||
dark.value = !dark.value
|
||||
applyTheme()
|
||||
|
||||
@@ -253,44 +253,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog ref="wipeModalRef" class="modal" aria-labelledby="wipe-modal-title">
|
||||
<div class="modal-box">
|
||||
<h2 id="wipe-modal-title" class="font-bold text-lg mb-2">Confirm wipe</h2>
|
||||
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
</form>
|
||||
<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>
|
||||
<ConfirmModal
|
||||
v-model="showWipeModal"
|
||||
title="Confirm wipe"
|
||||
message="Wipe all grids, markers, tiles and maps? This cannot be undone."
|
||||
confirm-label="Wipe"
|
||||
:danger="true"
|
||||
:loading="wiping"
|
||||
@confirm="doWipe"
|
||||
/>
|
||||
|
||||
<dialog ref="rebuildModalRef" class="modal" aria-labelledby="rebuild-modal-title">
|
||||
<div class="modal-box">
|
||||
<h2 id="rebuild-modal-title" class="font-bold text-lg mb-2">Rebuild zooms</h2>
|
||||
<p>Rebuild tile zoom levels for all maps? This may take a while.</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
</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>
|
||||
<ConfirmModal
|
||||
v-model="showRebuildModal"
|
||||
title="Rebuild zooms"
|
||||
message="Rebuild tile zoom levels for all maps? This may take a while."
|
||||
confirm-label="Rebuild"
|
||||
:loading="rebuilding"
|
||||
@confirm="doRebuildZooms"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapInfoAdmin } from '~/types/api'
|
||||
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
|
||||
const api = useMapApi()
|
||||
const toast = useToast()
|
||||
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 savingSettings = ref(false)
|
||||
const rebuilding = ref(false)
|
||||
@@ -298,8 +291,8 @@ const wiping = ref(false)
|
||||
const merging = ref(false)
|
||||
const mergeFile = ref<File | null>(null)
|
||||
const mergeFileRef = ref<HTMLInputElement | null>(null)
|
||||
const wipeModalRef = ref<HTMLDialogElement | null>(null)
|
||||
const rebuildModalRef = ref<HTMLDialogElement | null>(null)
|
||||
const showWipeModal = ref(false)
|
||||
const showRebuildModal = ref(false)
|
||||
const loading = ref(true)
|
||||
const userSearch = ref('')
|
||||
const mapSearch = ref('')
|
||||
@@ -386,17 +379,17 @@ async function saveSettings() {
|
||||
}
|
||||
|
||||
function confirmRebuildZooms() {
|
||||
rebuildModalRef.value?.showModal()
|
||||
showRebuildModal.value = true
|
||||
}
|
||||
|
||||
const { markRebuildDone } = useRebuildZoomsInvalidation()
|
||||
|
||||
async function doRebuildZooms() {
|
||||
rebuildModalRef.value?.close()
|
||||
rebuilding.value = true
|
||||
try {
|
||||
await api.adminRebuildZooms()
|
||||
markRebuildDone()
|
||||
showRebuildModal.value = false
|
||||
toast.success('Zooms rebuilt.')
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
|
||||
@@ -406,14 +399,14 @@ async function doRebuildZooms() {
|
||||
}
|
||||
|
||||
function confirmWipe() {
|
||||
wipeModalRef.value?.showModal()
|
||||
showWipeModal.value = true
|
||||
}
|
||||
|
||||
async function doWipe() {
|
||||
wiping.value = true
|
||||
try {
|
||||
await api.adminWipe()
|
||||
wipeModalRef.value?.close()
|
||||
showWipeModal.value = false
|
||||
await loadMaps()
|
||||
toast.success('All data wiped.')
|
||||
} catch (e) {
|
||||
|
||||
@@ -37,13 +37,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapInfoAdmin } from '~/types/api'
|
||||
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
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 form = ref({ name: '', hidden: false, priority: false })
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -47,18 +47,15 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<dialog ref="deleteModalRef" class="modal">
|
||||
<div class="modal-box">
|
||||
<p>Delete user {{ form.user }}?</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-error" :disabled="deleting" @click="doDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
|
||||
</dialog>
|
||||
<ConfirmModal
|
||||
v-model="showDeleteModal"
|
||||
title="Delete user"
|
||||
:message="`Delete user ${form.user}?`"
|
||||
confirm-label="Delete"
|
||||
:danger="true"
|
||||
:loading="deleting"
|
||||
@confirm="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,7 +73,7 @@ const authOptions = ['admin', 'map', 'markers', 'upload']
|
||||
const loading = ref(false)
|
||||
const deleting = ref(false)
|
||||
const error = ref('')
|
||||
const deleteModalRef = ref<HTMLDialogElement | null>(null)
|
||||
const showDeleteModal = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isNew.value) {
|
||||
@@ -108,14 +105,14 @@ async function submit() {
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
deleteModalRef.value?.showModal()
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.adminUserDelete(form.value.user)
|
||||
deleteModalRef.value?.close()
|
||||
showDeleteModal.value = false
|
||||
await router.push('/admin')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Delete failed'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<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">
|
||||
<AuthCard>
|
||||
<h1 class="card-title justify-center text-2xl">HnH Map</h1>
|
||||
<p class="text-center text-base-content/70 text-sm">Log in to continue</p>
|
||||
<div v-if="(oauthProviders ?? []).length" class="flex flex-col gap-2">
|
||||
@@ -40,9 +38,7 @@
|
||||
<span v-else>Log in</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -50,12 +46,11 @@
|
||||
|
||||
const user = ref('')
|
||||
const pass = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const oauthProviders = ref<string[]>([])
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const api = useMapApi()
|
||||
const { loading, error, run } = useFormSubmit('Login failed')
|
||||
|
||||
const redirect = computed(() => (route.query.redirect as string) || '')
|
||||
|
||||
@@ -64,15 +59,9 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await run(async () => {
|
||||
await api.login(user.value, pass.value)
|
||||
await router.push(redirect.value || '/')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,23 +16,7 @@
|
||||
</template>
|
||||
<template v-else-if="me">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="avatar">
|
||||
<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>
|
||||
<UserAvatar :username="me.username" :email="me.email" :size="56" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-lg font-semibold">{{ me.username }}</h2>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@@ -159,7 +143,6 @@ const passMsg = ref('')
|
||||
const passOk = ref(false)
|
||||
const tokenError = ref('')
|
||||
const copiedToken = ref<string | null>(null)
|
||||
const gravatarError = ref(false)
|
||||
const emailEditing = ref(false)
|
||||
const emailEdit = ref('')
|
||||
const loadingEmail = ref(false)
|
||||
@@ -185,7 +168,6 @@ async function saveEmail() {
|
||||
const data = await api.me()
|
||||
me.value = data
|
||||
emailEditing.value = false
|
||||
gravatarError.value = false
|
||||
toast.success('Email updated')
|
||||
} catch (e: unknown) {
|
||||
emailError.value = e instanceof Error ? e.message : 'Failed to update email'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<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">
|
||||
<AuthCard>
|
||||
<h1 class="card-title justify-center">First-time setup</h1>
|
||||
<p class="text-sm text-base-content/80">
|
||||
This is the first run. Create the administrator account using the bootstrap password
|
||||
@@ -20,8 +19,7 @@
|
||||
<span v-else>Create and log in</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AuthCard>
|
||||
<NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,10 +28,9 @@
|
||||
// No auth required; auth.global skips this path
|
||||
|
||||
const pass = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
const { loading, error, run } = useFormSubmit('Setup failed')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -45,17 +42,12 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const ok = await run(async () => {
|
||||
await api.login('admin', pass.value)
|
||||
await router.push('/profile')
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as Error)?.message === 'Unauthorized'
|
||||
? 'Invalid bootstrap password.'
|
||||
: (e as Error)?.message || 'Setup failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
})
|
||||
if (!ok && error.value === 'Unauthorized') {
|
||||
error.value = 'Invalid bootstrap password.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,12 @@ export default defineConfig({
|
||||
environment: 'happy-dom',
|
||||
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['composables/**/*.ts', 'components/**/*.vue', 'lib/**/*.ts'],
|
||||
exclude: ['**/*.test.ts', '**/__tests__/**', '**/__mocks__/**'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -32,8 +32,7 @@ func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
|
||||
JSON(rw, http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
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 {
|
||||
Username string `json:"username"`
|
||||
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.
|
||||
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
|
||||
if req.Method != http.MethodDelete {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodDelete) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
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")
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
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")
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if h.requireAdmin(rw, req) == nil {
|
||||
|
||||
@@ -31,8 +31,7 @@ type meUpdateRequest struct {
|
||||
|
||||
// APILogin handles POST /map/api/login.
|
||||
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
@@ -73,8 +72,7 @@ func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// APISetup handles GET /map/api/setup.
|
||||
func (h *Handlers) APISetup(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
@@ -98,14 +95,12 @@ func (h *Handlers) APILogout(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// APIMe handles GET /map/api/me.
|
||||
func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
s := h.requireSession(rw, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
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).
|
||||
func (h *Handlers) APIMeUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPatch {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPatch) {
|
||||
return
|
||||
}
|
||||
s := h.requireSession(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
var body meUpdateRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
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.
|
||||
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
s := h.requireSession(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
JSONError(rw, http.StatusForbidden, "Forbidden", "FORBIDDEN")
|
||||
return
|
||||
@@ -166,16 +157,14 @@ func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// APIMePassword handles POST /map/api/me/password.
|
||||
func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
s := h.requireSession(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
var body passwordRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
|
||||
@@ -190,8 +179,7 @@ func (h *Handlers) APIMePassword(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// APIOAuthProviders handles GET /map/api/oauth/providers.
|
||||
func (h *Handlers) APIOAuthProviders(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIOAuthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
func (h *Handlers) APIOAuthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
|
||||
if req.Method != http.MethodGet {
|
||||
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
|
||||
if !h.requireMethod(rw, req, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
code := req.URL.Query().Get("code")
|
||||
|
||||
@@ -24,7 +24,7 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
username, err := h.Auth.ValidateClientToken(ctx, matches[1])
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusUnauthorized)
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
JSONError(rw, http.StatusBadRequest, "version mismatch", "BAD_REQUEST")
|
||||
}
|
||||
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")
|
||||
result, err := h.Client.Locate(req.Context(), gridID)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/plain")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
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)
|
||||
if err != nil {
|
||||
slog.Error("grid update failed", "error", err)
|
||||
HandleServiceError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
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 {
|
||||
slog.Error("multipart parse error", "error", err)
|
||||
JSONError(rw, http.StatusBadRequest, "invalid multipart", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
id := req.FormValue("id")
|
||||
@@ -94,11 +100,13 @@ func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
|
||||
file, _, err := req.FormFile("file")
|
||||
if err != nil {
|
||||
slog.Error("form file error", "error", err)
|
||||
JSONError(rw, http.StatusBadRequest, "missing file", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := h.Client.ProcessGridUpload(req.Context(), id, extraData, file); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
slog.Error("error reading position update", "error", err)
|
||||
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Client.UpdatePositions(req.Context(), buf); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
slog.Error("error reading marker update", "error", err)
|
||||
JSONError(rw, http.StatusBadRequest, "failed to read body", "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if err := h.Client.UploadMarkers(req.Context(), buf); err != nil {
|
||||
slog.Error("marker update failed", "error", err)
|
||||
HandleServiceError(rw, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
func (h *Handlers) requireAdmin(rw http.ResponseWriter, req *http.Request) *app.Session {
|
||||
s := h.Auth.GetSession(req.Context(), req)
|
||||
|
||||
@@ -601,3 +601,79 @@ func TestAdminUserDelete(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import (
|
||||
|
||||
// APIConfig handles GET /map/api/config.
|
||||
func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
s := h.requireSession(rw, req)
|
||||
if s == nil {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
config, err := h.Map.GetConfig(ctx, s.Auths)
|
||||
if err != nil {
|
||||
HandleServiceError(rw, err)
|
||||
@@ -36,8 +35,10 @@ type CharacterResponse struct {
|
||||
|
||||
// APIGetChars handles GET /map/api/v1/characters.
|
||||
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
s := h.requireSession(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
@@ -64,12 +65,15 @@ func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// APIGetMarkers handles GET /map/api/v1/markers.
|
||||
func (h *Handlers) APIGetMarkers(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
s := h.Auth.GetSession(ctx, req)
|
||||
s := h.requireSession(rw, req)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if !h.canAccessMap(s) {
|
||||
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
|
||||
return
|
||||
}
|
||||
ctx := req.Context()
|
||||
if !s.Auths.Has(app.AUTH_MARKERS) && !s.Auths.Has(app.AUTH_ADMIN) {
|
||||
JSON(rw, http.StatusOK, []interface{}{})
|
||||
return
|
||||
|
||||
@@ -73,7 +73,9 @@ var migrations = []func(tx *bbolt.Tx) error{
|
||||
allTiles[string(k)] = zoomTiles
|
||||
return zoom.ForEach(func(tk, tv []byte) error {
|
||||
td := TileData{}
|
||||
json.Unmarshal(tv, &td)
|
||||
if err := json.Unmarshal(tv, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
zoomTiles[string(tk)] = td
|
||||
return nil
|
||||
})
|
||||
@@ -161,7 +163,9 @@ var migrations = []func(tx *bbolt.Tx) error{
|
||||
}
|
||||
return users.ForEach(func(k, v []byte) error {
|
||||
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) {
|
||||
u.Auths = append(u.Auths, AUTH_MARKERS)
|
||||
raw, err := json.Marshal(u)
|
||||
|
||||
@@ -20,6 +20,7 @@ type AdminService struct {
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
@@ -37,19 +38,21 @@ func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
|
||||
// GetUser returns a user's permissions by username.
|
||||
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool) {
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
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
|
||||
json.Unmarshal(raw, &u)
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
auths = u.Auths
|
||||
found = true
|
||||
return nil
|
||||
})
|
||||
return auths, found
|
||||
return auths, found, err
|
||||
}
|
||||
|
||||
// CreateOrUpdateUser creates or updates a user.
|
||||
@@ -60,7 +63,9 @@ func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string,
|
||||
u := app.User{}
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw != nil {
|
||||
json.Unmarshal(raw, &u)
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if pass != "" {
|
||||
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)
|
||||
if uRaw != nil {
|
||||
var u app.User
|
||||
json.Unmarshal(uRaw, &u)
|
||||
if err := json.Unmarshal(uRaw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tok := range u.Tokens {
|
||||
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 {
|
||||
return s.st.ForEachMap(tx, func(k, v []byte) error {
|
||||
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 {
|
||||
mi.ID = id
|
||||
}
|
||||
@@ -152,18 +161,23 @@ func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
if raw != nil {
|
||||
mi = &app.MapInfo{}
|
||||
json.Unmarshal(raw, mi)
|
||||
mi.ID = id
|
||||
return json.Unmarshal(raw, mi)
|
||||
}
|
||||
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.
|
||||
@@ -172,7 +186,9 @@ func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidde
|
||||
mi := app.MapInfo{}
|
||||
raw := s.st.GetMap(tx, id)
|
||||
if raw != nil {
|
||||
json.Unmarshal(raw, &mi)
|
||||
if err := json.Unmarshal(raw, &mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mi.ID = id
|
||||
mi.Name = name
|
||||
@@ -190,7 +206,9 @@ func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInf
|
||||
raw := s.st.GetMap(tx, id)
|
||||
mi = &app.MapInfo{}
|
||||
if raw != nil {
|
||||
json.Unmarshal(raw, mi)
|
||||
if err := json.Unmarshal(raw, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mi.ID = id
|
||||
mi.Hidden = !mi.Hidden
|
||||
@@ -340,7 +358,9 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
|
||||
return nil
|
||||
}
|
||||
m := app.Marker{}
|
||||
json.Unmarshal(raw, &m)
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Hidden = true
|
||||
raw, _ = json.Marshal(m)
|
||||
grid.Put(key, raw)
|
||||
|
||||
@@ -52,9 +52,9 @@ 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 := admin.GetUser(context.Background(), "alice")
|
||||
if !found {
|
||||
t.Fatal("expected found")
|
||||
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")
|
||||
@@ -63,7 +63,10 @@ func TestAdminGetUser_Found(t *testing.T) {
|
||||
|
||||
func TestAdminGetUser_NotFound(t *testing.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 {
|
||||
t.Fatal("expected not found")
|
||||
}
|
||||
@@ -78,9 +81,9 @@ func TestCreateOrUpdateUser_New(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, found := admin.GetUser(ctx, "bob")
|
||||
if !found {
|
||||
t.Fatal("expected user to exist")
|
||||
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")
|
||||
@@ -97,9 +100,9 @@ func TestCreateOrUpdateUser_Update(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, found := admin.GetUser(ctx, "alice")
|
||||
if !found {
|
||||
t.Fatal("expected user")
|
||||
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")
|
||||
@@ -139,9 +142,9 @@ func TestDeleteUser(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, found := admin.GetUser(ctx, "alice")
|
||||
if found {
|
||||
t.Fatal("expected user to be deleted")
|
||||
_, found, err := admin.GetUser(ctx, "alice")
|
||||
if err != nil || found {
|
||||
t.Fatalf("expected user to be deleted, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,9 +213,9 @@ func TestMapCRUD(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mi, found := admin.GetMap(ctx, 1)
|
||||
if !found || mi == nil {
|
||||
t.Fatal("expected map")
|
||||
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)
|
||||
@@ -285,7 +288,10 @@ func TestWipe(t *testing.T) {
|
||||
|
||||
func TestGetMap_NotFound(t *testing.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 {
|
||||
t.Fatal("expected not found")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ type AuthService struct {
|
||||
}
|
||||
|
||||
// NewAuthService creates an AuthService with the given store.
|
||||
// Uses direct args (single dependency) rather than a deps struct.
|
||||
func NewAuthService(st *store.Store) *AuthService {
|
||||
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.
|
||||
func (s *AuthService) GetUser(ctx context.Context, username, pass string) *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)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
json.Unmarshal(raw, &u)
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Pass == nil {
|
||||
u = nil
|
||||
return nil
|
||||
@@ -136,20 +139,26 @@ func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.U
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// GetUserByUsername returns user without password check (for OAuth-only check).
|
||||
func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *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)
|
||||
if raw != nil {
|
||||
json.Unmarshal(raw, &u)
|
||||
if err := json.Unmarshal(raw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -205,11 +214,13 @@ func GetBootstrapPassword() string {
|
||||
|
||||
// GetUserTokensAndPrefix returns tokens and config prefix for a user.
|
||||
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)
|
||||
if uRaw != nil {
|
||||
var u app.User
|
||||
json.Unmarshal(uRaw, &u)
|
||||
if err := json.Unmarshal(uRaw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
tokens = u.Tokens
|
||||
}
|
||||
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)
|
||||
u := app.User{}
|
||||
if uRaw != nil {
|
||||
json.Unmarshal(uRaw, &u)
|
||||
if err := json.Unmarshal(uRaw, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.Tokens = append(u.Tokens, token)
|
||||
tokens = u.Tokens
|
||||
@@ -252,7 +265,9 @@ func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string
|
||||
uRaw := s.st.GetUser(tx, username)
|
||||
u := app.User{}
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -297,7 +312,9 @@ func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (st
|
||||
return apperr.ErrUnauthorized
|
||||
}
|
||||
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) {
|
||||
return apperr.ErrForbidden
|
||||
}
|
||||
@@ -401,7 +418,9 @@ func (s *AuthService) OAuthHandleCallback(ctx context.Context, provider, code, s
|
||||
if raw == nil {
|
||||
return apperr.ErrBadRequest
|
||||
}
|
||||
json.Unmarshal(raw, &st)
|
||||
if err := json.Unmarshal(raw, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.st.DeleteOAuthState(tx, state)
|
||||
})
|
||||
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 {
|
||||
_ = s.st.ForEachUser(tx, func(k, v []byte) error {
|
||||
user := app.User{}
|
||||
if json.Unmarshal(v, &user) != nil {
|
||||
return nil
|
||||
if err := json.Unmarshal(v, &user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub {
|
||||
username = string(k)
|
||||
@@ -491,7 +510,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw != nil {
|
||||
user := app.User{}
|
||||
json.Unmarshal(raw, &user)
|
||||
if err := json.Unmarshal(raw, &user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.OAuthLinks == nil {
|
||||
user.OAuthLinks = map[string]string{provider: sub}
|
||||
} else {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/andyleap/hnh-map/internal/app"
|
||||
"github.com/andyleap/hnh-map/internal/app/apperr"
|
||||
"github.com/andyleap/hnh-map/internal/app/store"
|
||||
"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 {
|
||||
raw := s.st.GetGrid(tx, gridID)
|
||||
if raw == nil {
|
||||
return fmt.Errorf("grid not found")
|
||||
return apperr.ErrNotFound
|
||||
}
|
||||
cur := app.GridData{}
|
||||
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))
|
||||
if gridRaw != nil {
|
||||
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}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +155,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
mi := app.MapInfo{}
|
||||
mraw := mapB.Get([]byte(strconv.Itoa(id)))
|
||||
if mraw != nil {
|
||||
json.Unmarshal(mraw, &mi)
|
||||
if err := json.Unmarshal(mraw, &mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if mi.Priority {
|
||||
mapid = id
|
||||
@@ -171,7 +176,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
for y, grid := range row {
|
||||
cur := app.GridData{}
|
||||
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) {
|
||||
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 curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil {
|
||||
cur := app.GridData{}
|
||||
json.Unmarshal(curRaw, &cur)
|
||||
if err := json.Unmarshal(curRaw, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
greq.Map = cur.Map
|
||||
greq.Coords = cur.Coord
|
||||
}
|
||||
@@ -200,7 +209,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
if len(maps) > 1 {
|
||||
grids.ForEach(func(k, v []byte) error {
|
||||
gd := app.GridData{}
|
||||
json.Unmarshal(v, &gd)
|
||||
if err := json.Unmarshal(v, &gd); err != nil {
|
||||
return err
|
||||
}
|
||||
if gd.Map == mapid {
|
||||
return nil
|
||||
}
|
||||
@@ -216,7 +227,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
}
|
||||
tileraw := zoom.Get([]byte(gd.Coord.Name()))
|
||||
if tileraw != nil {
|
||||
json.Unmarshal(tileraw, &td)
|
||||
if err := json.Unmarshal(tileraw, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
gd.Map = mapid
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"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 TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
|
||||
@@ -30,3 +35,57 @@ func TestFixMultipartContentType_Normal(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type ExportService struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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))
|
||||
if gridRaw != nil {
|
||||
gd := app.GridData{}
|
||||
json.Unmarshal(gridRaw, &gd)
|
||||
if err := json.Unmarshal(gridRaw, &gd); err != nil {
|
||||
return err
|
||||
}
|
||||
ops = append(ops, TileOp{
|
||||
MapID: gd.Map,
|
||||
X: gd.Coord.X,
|
||||
@@ -265,7 +268,9 @@ func (s *ExportService) processMergeJSON(
|
||||
gridRaw := grids.Get([]byte(v))
|
||||
if gridRaw != nil {
|
||||
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}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +306,9 @@ func (s *ExportService) processMergeJSON(
|
||||
mi := app.MapInfo{}
|
||||
mraw := mapB.Get([]byte(strconv.Itoa(id)))
|
||||
if mraw != nil {
|
||||
json.Unmarshal(mraw, &mi)
|
||||
if err := json.Unmarshal(mraw, &mi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if mi.Priority {
|
||||
mapid = id
|
||||
@@ -333,7 +340,9 @@ func (s *ExportService) processMergeJSON(
|
||||
if len(existingMaps) > 1 {
|
||||
grids.ForEach(func(k, v []byte) error {
|
||||
gd := app.GridData{}
|
||||
json.Unmarshal(v, &gd)
|
||||
if err := json.Unmarshal(v, &gd); err != nil {
|
||||
return err
|
||||
}
|
||||
if gd.Map == mapid {
|
||||
return nil
|
||||
}
|
||||
@@ -349,7 +358,9 @@ func (s *ExportService) processMergeJSON(
|
||||
}
|
||||
tileraw := zoom.Get([]byte(gd.Coord.Name()))
|
||||
if tileraw != nil {
|
||||
json.Unmarshal(tileraw, &td)
|
||||
if err := json.Unmarshal(tileraw, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
gd.Map = mapid
|
||||
|
||||
64
internal/app/services/export_test.go
Normal file
64
internal/app/services/export_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -77,13 +77,17 @@ func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, erro
|
||||
}
|
||||
return grid.ForEach(func(k, v []byte) error {
|
||||
marker := app.Marker{}
|
||||
json.Unmarshal(v, &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{}
|
||||
json.Unmarshal(graw, &g)
|
||||
if err := json.Unmarshal(graw, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
markers = append(markers, app.FrontendMarker{
|
||||
Image: marker.Image,
|
||||
Hidden: marker.Hidden,
|
||||
@@ -111,7 +115,9 @@ func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app
|
||||
return nil
|
||||
}
|
||||
mi := &app.MapInfo{}
|
||||
json.Unmarshal(v, mi)
|
||||
if err := json.Unmarshal(v, mi); err != nil {
|
||||
return err
|
||||
}
|
||||
if mi.Hidden && !showHidden {
|
||||
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.
|
||||
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *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())
|
||||
if raw != nil {
|
||||
td = &app.TileData{}
|
||||
json.Unmarshal(raw, td)
|
||||
return json.Unmarshal(raw, td)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
return td
|
||||
}
|
||||
|
||||
@@ -259,7 +267,9 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
|
||||
}
|
||||
b.ForEach(func(k, v []byte) error {
|
||||
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{}{}
|
||||
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||
return nil
|
||||
@@ -318,7 +328,9 @@ func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
||||
td := app.TileData{}
|
||||
json.Unmarshal(v, &td)
|
||||
if err := json.Unmarshal(v, &td); err != nil {
|
||||
return err
|
||||
}
|
||||
cache = append(cache, TileCache{
|
||||
M: td.MapID,
|
||||
X: td.Coord.X,
|
||||
|
||||
@@ -32,6 +32,8 @@ func (t *Topic[T]) Send(b *T) {
|
||||
|
||||
// Close closes all subscriber channels.
|
||||
func (t *Topic[T]) Close() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, c := range t.c {
|
||||
close(c)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user