security(settings): gate Book Dock settings behind manage_book_dock

Require manage_book_dock for the settings UI and the scoped API so regular Book Dock users cannot
view or modify administrative configuration.

Reject every Book Dock key on the generic PATCH /app-settings/:key route as well, including
book_dock_paused. That route accepted the same keys with any string value, so an operator with
manage_app_settings could store an out-of-range threshold or an unknown metadata mode and bypass
the validation the scoped endpoints apply.

Closes #1003
This commit is contained in:
neon
2026-08-14 03:11:48 +00:00
parent 8cbc8bd8ab
commit 26b7c602f8
25 changed files with 584 additions and 167 deletions
@@ -3,12 +3,11 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Loader2 } from '@lucide/vue'
import { toast } from 'vue-sonner'
import type { BookDockAutoFinalizeMetadataMode } from '@bookorbit/types'
import type { BookDockAutoFinalizeMetadataMode, BookDockSettings, UpdateBookDockSettingsRequest } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { api } from '@/lib/api'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useAppInfo } from './composables/useAppInfo'
const { t } = useI18n()
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
@@ -20,11 +19,11 @@ const autoFinalizeThreshold = ref(85)
const autoFinalizeLibraryId = ref<number | null>(null)
const autoFinalizeFolderId = ref<number | null>(null)
const autoFinalizeMetadataMode = ref<BookDockAutoFinalizeMetadataMode>('safe_merge')
const bookDockPath = ref('')
const loading = ref(true)
const saving = ref(false)
const { libraries, fetchLibraries } = useLibraries()
const { bookDockPath, loadAppInfo } = useAppInfo()
const autoFinalizeLibrary = computed(() => libraries.value.find((l) => l.id === autoFinalizeLibraryId.value))
const autoFinalizeFolders = computed(() => autoFinalizeLibrary.value?.folders ?? [])
@@ -32,107 +31,97 @@ const isThresholdApplicable = computed(() => autoFinalizeMetadataMode.value !==
onMounted(async () => {
try {
const [res] = await Promise.all([api('/api/v1/app-settings'), fetchLibraries(), loadAppInfo()])
const [res] = await Promise.all([api('/api/v1/book-dock/settings'), fetchLibraries()])
if (res.ok) {
const settings: { key: string; value: string }[] = await res.json()
const get = (key: string) => settings.find((s) => s.key === key)?.value
autoFetch.value = get('book_dock_auto_fetch_metadata') !== 'false'
autoFinalizeEnabled.value = get('book_dock_auto_finalize_enabled') === 'true'
autoFinalizeThreshold.value = parseInt(get('book_dock_auto_finalize_threshold') ?? '85', 10)
const libId = parseInt(get('book_dock_auto_finalize_library_id') ?? '', 10)
const folderId = parseInt(get('book_dock_auto_finalize_folder_id') ?? '', 10)
const metadataMode = get('book_dock_auto_finalize_metadata_mode')
autoFinalizeLibraryId.value = isNaN(libId) ? null : libId
autoFinalizeFolderId.value = isNaN(folderId) ? null : folderId
autoFinalizeMetadataMode.value = metadataMode === 'fetched_only' || metadataMode === 'embedded_only' ? metadataMode : 'safe_merge'
applySettings(await res.json())
}
} finally {
loading.value = false
}
})
async function saveSetting(key: string, value: string) {
const res = await api(`/api/v1/app-settings/${key}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (!res.ok) {
toast.error(t('settings.reader.bookDock.saveSettingFailed'))
function applySettings(settings: BookDockSettings) {
bookDockPath.value = settings.bookDockPath
autoFetch.value = settings.autoFetchMetadata
autoFinalizeEnabled.value = settings.autoFinalizeEnabled
autoFinalizeThreshold.value = settings.autoFinalizeThreshold
autoFinalizeLibraryId.value = settings.autoFinalizeLibraryId
autoFinalizeFolderId.value = settings.autoFinalizeFolderId
autoFinalizeMetadataMode.value = settings.autoFinalizeMetadataMode
}
function settingsPayload(overrides: Partial<UpdateBookDockSettingsRequest> = {}): UpdateBookDockSettingsRequest {
return {
autoFetchMetadata: autoFetch.value,
autoFinalizeEnabled: autoFinalizeEnabled.value,
autoFinalizeThreshold: autoFinalizeThreshold.value,
autoFinalizeLibraryId: autoFinalizeLibraryId.value,
autoFinalizeFolderId: autoFinalizeFolderId.value,
autoFinalizeMetadataMode: autoFinalizeMetadataMode.value,
...overrides,
}
}
async function toggle() {
if (saving.value) return
const newVal = !autoFetch.value
async function saveSettings(overrides: Partial<UpdateBookDockSettingsRequest>): Promise<boolean> {
if (saving.value) return false
saving.value = true
try {
const res = await api('/api/v1/app-settings/book_dock_auto_fetch_metadata', {
method: 'PATCH',
const res = await api('/api/v1/book-dock/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
body: JSON.stringify(settingsPayload(overrides)),
})
if (res.ok) {
autoFetch.value = newVal
toast.success(newVal ? t('settings.reader.bookDock.autoFetchEnabled') : t('settings.reader.bookDock.autoFetchDisabled'))
} else {
toast.error(t('settings.reader.bookDock.updateSettingFailed'))
if (!res.ok) {
toast.error(t('settings.reader.bookDock.saveSettingFailed'))
return false
}
applySettings(await res.json())
return true
} finally {
saving.value = false
}
}
async function toggleAutoFinalize() {
if (saving.value) return
const newVal = !autoFinalizeEnabled.value
saving.value = true
try {
const res = await api('/api/v1/app-settings/book_dock_auto_finalize_enabled', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
})
if (res.ok) {
autoFinalizeEnabled.value = newVal
toast.success(newVal ? t('settings.reader.bookDock.autoFinalizeEnabled') : t('settings.reader.bookDock.autoFinalizeDisabled'))
} else {
toast.error(t('settings.reader.bookDock.updateSettingFailed'))
}
} finally {
saving.value = false
async function handleAutoFetchChange(enabled: boolean) {
if (await saveSettings({ autoFetchMetadata: enabled })) {
toast.success(enabled ? t('settings.reader.bookDock.autoFetchEnabled') : t('settings.reader.bookDock.autoFetchDisabled'))
}
}
async function handleAutoFinalizeChange(enabled: boolean) {
if (await saveSettings({ autoFinalizeEnabled: enabled })) {
toast.success(enabled ? t('settings.reader.bookDock.autoFinalizeEnabled') : t('settings.reader.bookDock.autoFinalizeDisabled'))
}
}
async function onLibraryChange(event: Event) {
const id = Number((event.target as HTMLSelectElement).value)
autoFinalizeLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
autoFinalizeFolderId.value = lib?.folders?.[0]?.id ?? null
await Promise.all([
saveSetting('book_dock_auto_finalize_library_id', String(id)),
saveSetting('book_dock_auto_finalize_folder_id', String(autoFinalizeFolderId.value ?? '')),
])
toast.success(t('settings.reader.bookDock.destinationLibraryUpdated'))
const libraryId = Number((event.target as HTMLSelectElement).value)
const library = libraries.value.find((candidate) => candidate.id === libraryId)
const folderId = library?.folders?.[0]?.id ?? null
if (await saveSettings({ autoFinalizeLibraryId: libraryId, autoFinalizeFolderId: folderId })) {
toast.success(t('settings.reader.bookDock.destinationLibraryUpdated'))
}
}
async function onFolderChange(event: Event) {
autoFinalizeFolderId.value = Number((event.target as HTMLSelectElement).value)
await saveSetting('book_dock_auto_finalize_folder_id', String(autoFinalizeFolderId.value))
toast.success(t('settings.reader.bookDock.destinationFolderUpdated'))
const folderId = Number((event.target as HTMLSelectElement).value)
if (await saveSettings({ autoFinalizeFolderId: folderId })) {
toast.success(t('settings.reader.bookDock.destinationFolderUpdated'))
}
}
async function onThresholdChange() {
if (!isThresholdApplicable.value) return
await saveSetting('book_dock_auto_finalize_threshold', String(autoFinalizeThreshold.value))
toast.success(t('settings.reader.bookDock.confidenceThresholdUpdated'))
if (await saveSettings({ autoFinalizeThreshold: autoFinalizeThreshold.value })) {
toast.success(t('settings.reader.bookDock.confidenceThresholdUpdated'))
}
}
async function onMetadataModeChange(event: Event) {
const value = (event.target as HTMLSelectElement).value as BookDockAutoFinalizeMetadataMode
autoFinalizeMetadataMode.value = value
await saveSetting('book_dock_auto_finalize_metadata_mode', value)
toast.success(t('settings.reader.bookDock.metadataModeUpdated'))
const metadataMode = (event.target as HTMLSelectElement).value as BookDockAutoFinalizeMetadataMode
if (await saveSettings({ autoFinalizeMetadataMode: metadataMode })) {
toast.success(t('settings.reader.bookDock.metadataModeUpdated'))
}
}
</script>
@@ -202,7 +191,12 @@ async function onMetadataModeChange(event: Event) {
{{ t('settings.reader.bookDock.autoFetchHint') }}
</p>
</div>
<ToggleSwitch :model-value="autoFetch" :disabled="saving" class="self-start md:self-auto md:ml-4" @update:model-value="() => toggle()" />
<ToggleSwitch
:model-value="autoFetch"
:disabled="saving"
class="self-start md:self-auto md:ml-4"
@update:model-value="handleAutoFetchChange"
/>
</div>
</div>
@@ -224,7 +218,7 @@ async function onMetadataModeChange(event: Event) {
:model-value="autoFinalizeEnabled"
:disabled="saving"
class="self-start md:self-auto md:ml-4"
@update:model-value="() => toggleAutoFinalize()"
@update:model-value="handleAutoFinalizeChange"
/>
</div>
@@ -85,7 +85,7 @@ const sections = computed<Section[]>(() => {
})
}
if (su || perms.includes('manage_app_settings') || perms.includes('book_dock_access')) {
if (su || perms.includes(Permission.ManageAppSettings) || perms.includes(Permission.ManageBookDock)) {
result.push({
id: 'settings-system',
label: t('settings.common.nav.system'),
@@ -43,7 +43,7 @@ const tabWidths: Record<Tab, string> = {
<SettingsTabs :class="tabWidths[activeTab]" :tabs="availableTabs" :active-tab="activeTab" @select="selectTab" />
<div :class="tabWidths[activeTab]">
<div v-if="availableTabIds.includes(activeTab)" :class="tabWidths[activeTab]">
<FileNamingSettings v-if="activeTab === 'file-naming'" embedded />
<BookDockSettings v-else-if="activeTab === 'book-dock'" embedded />
<MaintenanceSettings v-else-if="activeTab === 'maintenance'" embedded />
@@ -0,0 +1,113 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { BookDockSettings as BookDockSettingsResponse } from '@bookorbit/types'
const { apiMock, fetchLibrariesMock, toastErrorMock } = vi.hoisted(() => ({
apiMock: vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(),
fetchLibrariesMock: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
toastErrorMock: vi.fn<(message: string) => void>(),
}))
vi.mock('@/lib/api', () => ({ api: apiMock }))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: ref([
{
id: 4,
name: 'Library',
folders: [{ id: 8, path: '/books' }],
},
]),
fetchLibraries: fetchLibrariesMock,
}),
}))
vi.mock('vue-sonner', () => ({
toast: {
success: vi.fn<(message: string) => void>(),
error: toastErrorMock,
},
}))
import BookDockSettings from '../BookDockSettings.vue'
const settings: BookDockSettingsResponse = {
bookDockPath: '/data/private-book-dock',
autoFetchMetadata: true,
autoFinalizeEnabled: true,
autoFinalizeThreshold: 85,
autoFinalizeLibraryId: 4,
autoFinalizeFolderId: 8,
autoFinalizeMetadataMode: 'safe_merge',
}
function response(body: unknown, ok = true): Response {
return {
ok,
status: ok ? 200 : 403,
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
} as unknown as Response
}
async function mountSettings() {
const wrapper = mount(BookDockSettings, { props: { embedded: true } })
await flushPromises()
return wrapper
}
describe('BookDockSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
fetchLibrariesMock.mockResolvedValue(undefined)
apiMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (url === '/api/v1/book-dock/settings' && init?.method === 'PUT') {
return response({ ...settings, ...JSON.parse(String(init.body)) })
}
return response(settings)
})
})
it('loads the protected settings contract and renders its scoped path', async () => {
const wrapper = await mountSettings()
expect(apiMock).toHaveBeenCalledWith('/api/v1/book-dock/settings')
expect(fetchLibrariesMock).toHaveBeenCalledOnce()
expect(wrapper.get('[data-testid="book-dock-path"]').text()).toBe('/data/private-book-dock')
expect(apiMock.mock.calls.some(([url]) => url === '/api/v1/app-info' || url === '/api/v1/app-settings')).toBe(false)
})
it('updates the complete typed settings document through the protected endpoint', async () => {
const wrapper = await mountSettings()
await wrapper.findAll('[role="switch"]')[0]!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenLastCalledWith('/api/v1/book-dock/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
autoFetchMetadata: false,
autoFinalizeEnabled: true,
autoFinalizeThreshold: 85,
autoFinalizeLibraryId: 4,
autoFinalizeFolderId: 8,
autoFinalizeMetadataMode: 'safe_merge',
}),
})
})
it('reports a failed update without applying the optimistic value', async () => {
apiMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (url === '/api/v1/book-dock/settings' && init?.method === 'PUT') return response({}, false)
return response(settings)
})
const wrapper = await mountSettings()
await wrapper.findAll('[role="switch"]')[0]!.trigger('click')
await flushPromises()
expect(toastErrorMock).toHaveBeenCalledOnce()
expect(wrapper.findAll('[role="switch"]')[0]!.attributes('aria-checked')).toBe('true')
})
})
@@ -190,12 +190,17 @@ describe('SettingsHeader', () => {
expect(labels).toContain('System')
})
it('shows System tab for user with book_dock_access', () => {
it('hides System tab for user with book_dock_access only', () => {
const labels = getTabLabels(mountHeader({ perms: ['book_dock_access'] }))
expect(labels).not.toContain('System')
})
it('shows System tab for user with manage_book_dock', () => {
const labels = getTabLabels(mountHeader({ perms: ['manage_book_dock'] }))
expect(labels).toContain('System')
})
it('hides System tab when user lacks manage_app_settings and book_dock_access', () => {
it('hides System tab when user lacks settings management permissions', () => {
const labels = getTabLabels(mountHeader({ perms: ['kobo_sync'] }))
expect(labels).not.toContain('System')
})
@@ -94,17 +94,30 @@ describe('SystemAllSettings', () => {
})
describe('user with book_dock_access only', () => {
it('sees book-dock tab only', () => {
it('cannot see the Book Dock settings tab', () => {
const wrapper = mountComponent(undefined, { perms: ['book_dock_access'] })
const labels = wrapper.findAll('button').map((b) => b.text())
expect(labels).toContain('Book Dock')
expect(labels).not.toContain('Book Dock')
expect(labels).not.toContain('File Naming')
expect(labels).not.toContain('Maintenance')
expect(labels).not.toContain('Audit Log')
})
it('defaults to book-dock', () => {
const wrapper = mountComponent(undefined, { perms: ['book_dock_access'] })
it('does not render protected settings when the Book Dock tab is requested directly', () => {
const wrapper = mountComponent('book-dock', { perms: ['book_dock_access'] })
expect(wrapper.find('[data-testid="book-dock"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="file-naming"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="maintenance"]').exists()).toBe(false)
})
})
describe('user with manage_book_dock', () => {
it('sees and defaults to Book Dock settings only', () => {
const wrapper = mountComponent(undefined, { perms: ['manage_book_dock'] })
const labels = wrapper.findAll('button').map((button) => button.text())
expect(labels).toContain('Book Dock')
expect(labels).not.toContain('File Naming')
expect(labels).not.toContain('Maintenance')
expect(wrapper.find('[data-testid="book-dock"]').exists()).toBe(true)
})
})
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Permission } from '@bookorbit/types'
import { SYSTEM_TABS, SYSTEM_TAB_INFO, normalizeSystemTab } from '../lib/system-tabs'
describe('system-tabs', () => {
@@ -27,15 +28,15 @@ describe('system-tabs', () => {
})
it('file-naming has manage_app_settings permission', () => {
expect(SYSTEM_TAB_INFO['file-naming'].permission).toBe('manage_app_settings')
expect(SYSTEM_TAB_INFO['file-naming'].permission).toBe(Permission.ManageAppSettings)
})
it('book-dock has book_dock_access permission', () => {
expect(SYSTEM_TAB_INFO['book-dock'].permission).toBe('book_dock_access')
it('book-dock requires management permission', () => {
expect(SYSTEM_TAB_INFO['book-dock'].permission).toBe(Permission.ManageBookDock)
})
it('maintenance has manage_app_settings permission', () => {
expect(SYSTEM_TAB_INFO.maintenance.permission).toBe('manage_app_settings')
expect(SYSTEM_TAB_INFO.maintenance.permission).toBe(Permission.ManageAppSettings)
})
it('audit-log has null permission (superuser only)', () => {
@@ -25,21 +25,19 @@ function makeErrorResponse(status: number): Response {
describe('useAppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
const { version, updateAvailable, latestVersion, bookDockPath, isLoading, error } = useAppInfo()
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
version.value = ''
updateAvailable.value = null
latestVersion.value = null
bookDockPath.value = ''
isLoading.value = false
error.value = null
})
it('starts in idle state before loadAppInfo is called', () => {
const { version, updateAvailable, latestVersion, bookDockPath, isLoading, error } = useAppInfo()
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
expect(version.value).toBe('')
expect(updateAvailable.value).toBeNull()
expect(latestVersion.value).toBeNull()
expect(bookDockPath.value).toBe('')
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
@@ -149,45 +147,4 @@ describe('useAppInfo', () => {
expect(mockApi).toHaveBeenCalledOnce()
})
it('populates bookDockPath from API response', async () => {
mockApi.mockResolvedValue(
makeOkResponse({
version: 'v1.2.3',
updateAvailable: false,
latestVersion: 'v1.2.3',
bookDockPath: '/data/book-dock',
}),
)
const { bookDockPath, loadAppInfo } = useAppInfo()
await loadAppInfo()
expect(bookDockPath.value).toBe('/data/book-dock')
})
it('defaults bookDockPath to empty string when field is absent from response', async () => {
mockApi.mockResolvedValue(
makeOkResponse({
version: 'v1.2.3',
updateAvailable: false,
latestVersion: 'v1.2.3',
}),
)
const { bookDockPath, loadAppInfo } = useAppInfo()
await loadAppInfo()
expect(bookDockPath.value).toBe('')
})
it('does not change bookDockPath on API error', async () => {
mockApi.mockRejectedValue(new Error('fail'))
const { bookDockPath, loadAppInfo } = useAppInfo()
bookDockPath.value = '/data/book-dock'
await loadAppInfo()
expect(bookDockPath.value).toBe('/data/book-dock')
})
})
@@ -7,7 +7,6 @@ import { api } from '@/lib/api'
const version = ref('')
const updateAvailable = ref<boolean | null>(null)
const latestVersion = ref<string | null>(null)
const bookDockPath = ref('')
const maxUploadSizeMb = ref(500)
const isLoading = ref(false)
const error = ref<string | null>(null)
@@ -24,7 +23,6 @@ export function useAppInfo() {
version.value = data.version
updateAvailable.value = data.updateAvailable
latestVersion.value = data.latestVersion
bookDockPath.value = data.bookDockPath ?? ''
maxUploadSizeMb.value = data.maxUploadSizeMb ?? 500
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load app info'
@@ -33,5 +31,5 @@ export function useAppInfo() {
}
}
return { version, updateAvailable, latestVersion, bookDockPath, maxUploadSizeMb, isLoading, error, loadAppInfo }
return { version, updateAvailable, latestVersion, maxUploadSizeMb, isLoading, error, loadAppInfo }
}
@@ -1,20 +1,22 @@
import { Permission } from '@bookorbit/types'
export const SYSTEM_TABS = ['file-naming', 'book-dock', 'maintenance', 'audit-log'] as const
export type SystemTab = (typeof SYSTEM_TABS)[number]
type SystemTabInfo = {
permission: string | null
permission: Permission | null
}
export const SYSTEM_TAB_INFO: Record<SystemTab, SystemTabInfo> = {
'file-naming': {
permission: 'manage_app_settings',
permission: Permission.ManageAppSettings,
},
'book-dock': {
permission: 'book_dock_access',
permission: Permission.ManageBookDock,
},
maintenance: {
permission: 'manage_app_settings',
permission: Permission.ManageAppSettings,
},
'audit-log': {
permission: null,