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,7 +1,5 @@
<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">
<div class="card-body">
<h1 class="card-title justify-center text-2xl">HnH Map</h1> <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> <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"> <div v-if="(oauthProviders ?? []).length" class="flex flex-col gap-2">
@@ -40,9 +38,7 @@
<span v-else>Log in</span> <span v-else>Log in</span>
</button> </button>
</form> </form>
</div> </AuthCard>
</div>
</div>
</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,7 +1,6 @@
<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
@@ -20,8 +19,7 @@
<span v-else>Create and log in</span> <span v-else>Create and log in</span>
</button> </button>
</form> </form>
</div> </AuthCard>
</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)
} }