From 51dd3d30f37c1900c81b308eaed05e8bcd42624f Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:02:15 -0600 Subject: [PATCH] chore: backend infrastructure improvements - add URI versioning: global prefix changed to api/v1, all frontend call sites updated - add AbortSignal.timeout to all outbound fetch calls (10s API, 15s scrapers) - add X-Request-Id response header and requestId field in error payloads - add response compression via @fastify/compress (gzip + brotli) - add security headers via @fastify/helmet with CSP for the Vite SPA - tune pg pool: max 20, idle/connection/statement timeouts on app and migration pools - add GET /api/v1/health endpoint via @nestjs/terminus with DB connectivity check --- client/src/features/admin/PermissionsPage.vue | 6 +-- client/src/features/admin/RoleFormDrawer.vue | 8 ++-- client/src/features/admin/RolesPage.vue | 4 +- client/src/features/admin/UserFormDrawer.vue | 8 ++-- client/src/features/admin/UsersPage.vue | 6 +-- .../features/auth/ChangePasswordDialog.vue | 2 +- .../src/features/auth/ForgotPasswordPage.vue | 2 +- .../src/features/auth/ResetPasswordPage.vue | 2 +- .../src/features/auth/composables/useAuth.ts | 8 ++-- .../src/features/auth/composables/useOidc.ts | 6 +-- .../features/book/components/BookListRow.vue | 2 +- .../components/detail/tabs/DetailsTab.vue | 2 +- .../book/components/detail/tabs/FilesTab.vue | 2 +- .../book/composables/useAuthorSearch.ts | 2 +- .../book/composables/useBookDetail.ts | 2 +- .../features/book/composables/useBookQuery.ts | 2 +- .../src/features/book/composables/useBooks.ts | 2 +- .../book/composables/useCoverEditor.ts | 6 +-- .../book/composables/useCoverVersions.ts | 2 +- .../book/composables/useGlobalSearch.ts | 2 +- .../book/composables/useMetadataEditor.ts | 2 +- .../book/composables/useMetadataSearch.ts | 4 +- .../book/composables/useRecommendations.ts | 2 +- .../book/composables/useRefreshMetadata.ts | 2 +- .../features/book/composables/useTagSearch.ts | 2 +- .../composables/useCollectionBooks.ts | 2 +- .../collection/composables/useCollections.ts | 12 +++--- .../composables/useDashboardScroller.ts | 2 +- .../lens/components/LensEditorPanel.vue | 2 +- .../src/features/lens/composables/useLens.ts | 2 +- .../features/lens/composables/useLenses.ts | 10 ++--- .../library/components/BookUploadModal.vue | 2 +- .../library/components/FolderPickerModal.vue | 2 +- .../components/LibraryCreatorAccess.vue | 8 ++-- .../components/LibraryCreatorFolders.vue | 2 +- .../components/LibraryCreatorModal.vue | 2 +- .../library/composables/useBookUpload.ts | 2 +- .../library/composables/useLibraries.ts | 2 +- .../library/composables/useLibraryCreator.ts | 6 +-- .../reader/composables/useAnnotations.ts | 8 ++-- .../reader/composables/useBookmarks.ts | 6 +-- .../src/features/reader/composables/useCbz.ts | 4 +- .../features/reader/composables/useFoliate.ts | 2 +- .../src/features/reader/composables/usePdf.ts | 2 +- .../reader/composables/useReaderProgress.ts | 4 +- .../reader/composables/useReaderSettings.ts | 18 ++++---- client/src/features/settings/KoboSettings.vue | 2 +- .../features/settings/LibrariesSettings.vue | 12 +++--- .../features/settings/MaintenanceSettings.vue | 2 +- client/src/features/settings/OidcSettings.vue | 6 +-- client/src/features/settings/OpdsSettings.vue | 4 +- .../src/features/settings/ReaderSettings.vue | 2 +- .../src/features/settings/StagingSettings.vue | 8 ++-- .../composables/useFileNamingPattern.ts | 6 +-- .../composables/useMetadataPreferences.ts | 10 ++--- .../composables/useProviderConfig.ts | 4 +- .../components/StagingBulkEditDialog.vue | 2 +- .../staging/components/StagingFileList.vue | 4 +- .../components/StagingFinalizeDialog.vue | 2 +- .../staging/components/StagingToolbar.vue | 2 +- .../staging/composables/useStagingDetail.ts | 10 ++--- .../staging/composables/useStagingFiles.ts | 2 +- .../staging/composables/useStagingFinalize.ts | 2 +- .../composables/useStagingStatistics.ts | 2 +- .../staging/composables/useStagingSummary.ts | 4 +- .../staging/composables/useStagingUpload.ts | 2 +- client/src/lib/api.ts | 2 +- client/src/views/CollectionView.vue | 2 +- client/src/views/HomeView.vue | 2 +- client/src/views/LensView.vue | 2 +- client/src/views/StagingView.vue | 8 ++-- server/package.json | 3 ++ server/src/app.module.ts | 2 + .../common/filters/http-exception.filter.ts | 1 + server/src/db/db.module.ts | 8 +++- server/src/main.ts | 42 +++++++++++++++++-- .../auth/oidc/oidc-discovery.service.ts | 2 +- .../auth/oidc/oidc-token-client.service.ts | 2 + .../src/modules/health/health.controller.ts | 27 ++++++++++++ server/src/modules/health/health.module.ts | 10 +++++ .../src/modules/kobo/kobo-user.controller.ts | 2 +- .../providers/amazon/amazon.provider.ts | 2 +- .../providers/goodreads/goodreads.provider.ts | 2 +- .../providers/google/google.provider.ts | 4 +- .../open-library/open-library.provider.ts | 4 +- .../src/modules/opds/opds-user.controller.ts | 2 +- server/src/modules/opds/opds.controller.ts | 2 +- 87 files changed, 246 insertions(+), 161 deletions(-) create mode 100644 server/src/modules/health/health.controller.ts create mode 100644 server/src/modules/health/health.module.ts diff --git a/client/src/features/admin/PermissionsPage.vue b/client/src/features/admin/PermissionsPage.vue index 3705dbe19..5c4fc632b 100644 --- a/client/src/features/admin/PermissionsPage.vue +++ b/client/src/features/admin/PermissionsPage.vue @@ -17,7 +17,7 @@ async function loadData() { loading.value = true error.value = null try { - const res = await api('/api/permissions') + const res = await api('/api/v1/permissions') if (!res.ok) throw new Error('Failed to load') permissions.value = await res.json() } catch (e) { @@ -33,7 +33,7 @@ async function createPermission() { createError.value = null creating.value = true try { - const res = await api('/api/permissions', { + const res = await api('/api/v1/permissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName.value, description: newDescription.value || undefined }), @@ -53,7 +53,7 @@ async function createPermission() { async function deletePermission(perm: Permission) { if (!confirm(`Delete permission "${perm.name}"?`)) return - const res = await api(`/api/permissions/${perm.id}`, { method: 'DELETE' }) + const res = await api(`/api/v1/permissions/${perm.id}`, { method: 'DELETE' }) if (res.ok) loadData() } diff --git a/client/src/features/admin/RoleFormDrawer.vue b/client/src/features/admin/RoleFormDrawer.vue index 45ae976d4..c06371744 100644 --- a/client/src/features/admin/RoleFormDrawer.vue +++ b/client/src/features/admin/RoleFormDrawer.vue @@ -47,7 +47,7 @@ async function handleSubmit() { const isEdit = !!props.role?.id if (isEdit) { - const res = await api(`/api/roles/${props.role!.id}`, { + const res = await api(`/api/v1/roles/${props.role!.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, description: description.value || undefined }), @@ -65,16 +65,16 @@ async function handleSubmit() { await Promise.all([ ...toAssign.map((permissionId) => - api(`/api/roles/${props.role!.id}/permissions`, { + api(`/api/v1/roles/${props.role!.id}/permissions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionId }), }), ), - ...toRevoke.map((permId) => api(`/api/roles/${props.role!.id}/permissions/${permId}`, { method: 'DELETE' })), + ...toRevoke.map((permId) => api(`/api/v1/roles/${props.role!.id}/permissions/${permId}`, { method: 'DELETE' })), ]) } else { - const res = await api('/api/roles', { + const res = await api('/api/v1/roles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/client/src/features/admin/RolesPage.vue b/client/src/features/admin/RolesPage.vue index e189d046e..117823a2b 100644 --- a/client/src/features/admin/RolesPage.vue +++ b/client/src/features/admin/RolesPage.vue @@ -17,7 +17,7 @@ async function loadData() { loading.value = true error.value = null try { - const [rolesRes, permsRes] = await Promise.all([api('/api/roles'), api('/api/permissions')]) + const [rolesRes, permsRes] = await Promise.all([api('/api/v1/roles'), api('/api/v1/permissions')]) if (!rolesRes.ok || !permsRes.ok) throw new Error('Failed to load data') roles.value = await rolesRes.json() permissions.value = await permsRes.json() @@ -42,7 +42,7 @@ function openEdit(role: Role) { async function deleteRole(role: Role) { if (!confirm(`Delete role "${role.name}"?`)) return - const res = await api(`/api/roles/${role.id}`, { method: 'DELETE' }) + const res = await api(`/api/v1/roles/${role.id}`, { method: 'DELETE' }) if (res.ok) loadData() } diff --git a/client/src/features/admin/UserFormDrawer.vue b/client/src/features/admin/UserFormDrawer.vue index 9facbf9e1..8181a61f6 100644 --- a/client/src/features/admin/UserFormDrawer.vue +++ b/client/src/features/admin/UserFormDrawer.vue @@ -51,7 +51,7 @@ async function handleSubmit() { const isEdit = !!props.user?.id if (isEdit) { - const res = await api(`/api/users/${props.user!.id}`, { + const res = await api(`/api/v1/users/${props.user!.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, email: email.value || undefined, active: active.value }), @@ -69,16 +69,16 @@ async function handleSubmit() { await Promise.all([ ...toAssign.map((roleId) => - api(`/api/users/${props.user!.id}/roles`, { + api(`/api/v1/users/${props.user!.id}/roles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ roleId }), }), ), - ...toRevoke.map((roleId) => api(`/api/users/${props.user!.id}/roles/${roleId}`, { method: 'DELETE' })), + ...toRevoke.map((roleId) => api(`/api/v1/users/${props.user!.id}/roles/${roleId}`, { method: 'DELETE' })), ]) } else { - const res = await api('/api/users', { + const res = await api('/api/v1/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/client/src/features/admin/UsersPage.vue b/client/src/features/admin/UsersPage.vue index 3e804271e..2a77e56dc 100644 --- a/client/src/features/admin/UsersPage.vue +++ b/client/src/features/admin/UsersPage.vue @@ -28,7 +28,7 @@ async function loadData() { loading.value = true error.value = null try { - const [usersRes, rolesRes] = await Promise.all([api(`/api/users?page=${page.value}&pageSize=50`), api('/api/roles')]) + const [usersRes, rolesRes] = await Promise.all([api(`/api/v1/users?page=${page.value}&pageSize=50`), api('/api/v1/roles')]) if (!usersRes.ok || !rolesRes.ok) throw new Error('Failed to load data') const ud = await usersRes.json() users.value = ud.users ?? ud.items ?? ud @@ -54,7 +54,7 @@ function openEdit(user: UserRow) { } async function handleResetPassword(userId: number) { - const res = await api(`/api/users/${userId}/reset-password`, { method: 'POST' }) + const res = await api(`/api/v1/users/${userId}/reset-password`, { method: 'POST' }) if (!res.ok) return const data = await res.json() resetUrl.value = data.resetUrl @@ -62,7 +62,7 @@ async function handleResetPassword(userId: number) { async function deleteUser(user: UserRow) { if (!confirm(`Delete user "${user.username}"? This cannot be undone.`)) return - const res = await api(`/api/users/${user.id}`, { method: 'DELETE' }) + const res = await api(`/api/v1/users/${user.id}`, { method: 'DELETE' }) if (!res.ok) { const data = await res.json().catch(() => ({})) error.value = data.message ?? 'Failed to delete user' diff --git a/client/src/features/auth/ChangePasswordDialog.vue b/client/src/features/auth/ChangePasswordDialog.vue index 3fdf25b5a..65b88d3fa 100644 --- a/client/src/features/auth/ChangePasswordDialog.vue +++ b/client/src/features/auth/ChangePasswordDialog.vue @@ -24,7 +24,7 @@ async function handleSubmit() { loading.value = true try { - const res = await api('/api/auth/change-password', { + const res = await api('/api/v1/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: currentPassword.value, newPassword: newPassword.value }), diff --git a/client/src/features/auth/ForgotPasswordPage.vue b/client/src/features/auth/ForgotPasswordPage.vue index 46d4c04f5..9b68b3629 100644 --- a/client/src/features/auth/ForgotPasswordPage.vue +++ b/client/src/features/auth/ForgotPasswordPage.vue @@ -11,7 +11,7 @@ async function handleSubmit() { error.value = null loading.value = true try { - const res = await api('/api/auth/forgot-password', { + const res = await api('/api/v1/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.value }), diff --git a/client/src/features/auth/ResetPasswordPage.vue b/client/src/features/auth/ResetPasswordPage.vue index 9ba84af6b..fcb8c8d0a 100644 --- a/client/src/features/auth/ResetPasswordPage.vue +++ b/client/src/features/auth/ResetPasswordPage.vue @@ -28,7 +28,7 @@ async function handleSubmit() { loading.value = true try { - const res = await fetch('/api/auth/reset-password', { + const res = await fetch('/api/v1/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: token.value, newPassword: newPassword.value }), diff --git a/client/src/features/auth/composables/useAuth.ts b/client/src/features/auth/composables/useAuth.ts index aabde46e0..b9d2bace4 100644 --- a/client/src/features/auth/composables/useAuth.ts +++ b/client/src/features/auth/composables/useAuth.ts @@ -17,7 +17,7 @@ setOnAuthFailure(() => { }) async function me(): Promise { - const res = await api('/api/auth/me') + const res = await api('/api/v1/auth/me') if (!res.ok) throw new Error('Failed to load user') user.value = await res.json() } @@ -26,7 +26,7 @@ export function useAuth() { async function init(): Promise { isLoading.value = true try { - const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' }) + const res = await fetch('/api/v1/auth/refresh', { method: 'POST', credentials: 'include' }) if (!res.ok) return const { accessToken } = await res.json() setAccessToken(accessToken) @@ -39,7 +39,7 @@ export function useAuth() { } async function login(username: string, password: string): Promise { - const res = await fetch('/api/auth/login', { + const res = await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -65,7 +65,7 @@ export function useAuth() { async function logout(): Promise { try { - const res = await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) + const res = await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }) clearAuth() if (res.ok) { const data = await res.json().catch(() => ({})) diff --git a/client/src/features/auth/composables/useOidc.ts b/client/src/features/auth/composables/useOidc.ts index 4aea292c9..db71c3238 100644 --- a/client/src/features/auth/composables/useOidc.ts +++ b/client/src/features/auth/composables/useOidc.ts @@ -32,7 +32,7 @@ function generateNonce(): string { export function useOidc() { async function getPublicConfig(): Promise { try { - const res = await fetch('/api/app-settings/oidc/public') + const res = await fetch('/api/v1/app-settings/oidc/public') if (!res.ok) return null return res.json() } catch { @@ -60,7 +60,7 @@ export function useOidc() { const nonce = generateNonce() // Get server-side state (CSRF protection) - const stateRes = await fetch('/api/auth/oidc/state', { method: 'POST', credentials: 'include' }) + const stateRes = await fetch('/api/v1/auth/oidc/state', { method: 'POST', credentials: 'include' }) if (!stateRes.ok) throw new Error('Failed to generate state') const { state } = await stateRes.json() @@ -92,7 +92,7 @@ export function useOidc() { const redirectUri = `${window.location.origin}/oauth2-callback` - const res = await fetch('/api/auth/oidc/callback', { + const res = await fetch('/api/v1/auth/oidc/callback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', diff --git a/client/src/features/book/components/BookListRow.vue b/client/src/features/book/components/BookListRow.vue index 6bde92a2a..5827e9d8f 100644 --- a/client/src/features/book/components/BookListRow.vue +++ b/client/src/features/book/components/BookListRow.vue @@ -69,7 +69,7 @@ async function setRating(star: number) { const newRating = localRating.value === star ? null : star localRating.value = newRating emit('rating-change', newRating) - await api(`/api/books/${props.book.id}/metadata`, { + await api(`/api/v1/books/${props.book.id}/metadata`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rating: newRating }), diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index bc972989b..2bfb44e42 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -42,7 +42,7 @@ function openBook() { function downloadFile() { if (!primaryFile.value) return const a = document.createElement('a') - a.href = `/api/books/files/${primaryFile.value.id}/serve` + a.href = `/api/v1/books/files/${primaryFile.value.id}/serve` a.download = `book.${primaryFile.value.format ?? 'epub'}` a.click() } diff --git a/client/src/features/book/components/detail/tabs/FilesTab.vue b/client/src/features/book/components/detail/tabs/FilesTab.vue index 20c9bf141..880f0797d 100644 --- a/client/src/features/book/components/detail/tabs/FilesTab.vue +++ b/client/src/features/book/components/detail/tabs/FilesTab.vue @@ -30,7 +30,7 @@ function openFile(file: BookDetailFile) { function downloadFile(file: BookDetailFile) { const a = document.createElement('a') - a.href = `/api/books/files/${file.id}/serve` + a.href = `/api/v1/books/files/${file.id}/serve` a.download = file.filename ?? `book.${file.format ?? 'epub'}` a.click() } diff --git a/client/src/features/book/composables/useAuthorSearch.ts b/client/src/features/book/composables/useAuthorSearch.ts index c21a3cafe..1bc489b0c 100644 --- a/client/src/features/book/composables/useAuthorSearch.ts +++ b/client/src/features/book/composables/useAuthorSearch.ts @@ -3,7 +3,7 @@ import { api } from '@/lib/api' export function useAuthorSearch() { async function search(q: string): Promise { if (!q.trim()) return [] - const res = await api(`/api/metadata/authors?q=${encodeURIComponent(q)}`) + const res = await api(`/api/v1/metadata/authors?q=${encodeURIComponent(q)}`) if (!res.ok) return [] const data: { name: string }[] = await res.json() return data.map((a) => a.name) diff --git a/client/src/features/book/composables/useBookDetail.ts b/client/src/features/book/composables/useBookDetail.ts index 6f7281848..3c0816c85 100644 --- a/client/src/features/book/composables/useBookDetail.ts +++ b/client/src/features/book/composables/useBookDetail.ts @@ -12,7 +12,7 @@ export function useBookDetail() { error.value = null detail.value = null try { - const res = await api(`/api/books/${bookId}`) + const res = await api(`/api/v1/books/${bookId}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) detail.value = await res.json() } catch (e) { diff --git a/client/src/features/book/composables/useBookQuery.ts b/client/src/features/book/composables/useBookQuery.ts index e3e43b1a1..262aa1cee 100644 --- a/client/src/features/book/composables/useBookQuery.ts +++ b/client/src/features/book/composables/useBookQuery.ts @@ -33,7 +33,7 @@ export function useBookQuery(libraryId: Ref) { pagination: { page: pagination.page, size: pagination.size }, } - const res = await api(`/api/libraries/${libraryId.value}/books`, { + const res = await api(`/api/v1/libraries/${libraryId.value}/books`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/client/src/features/book/composables/useBooks.ts b/client/src/features/book/composables/useBooks.ts index 63f727c8a..5328c038c 100644 --- a/client/src/features/book/composables/useBooks.ts +++ b/client/src/features/book/composables/useBooks.ts @@ -53,7 +53,7 @@ export function useBooks(libraryId: Ref) { }) if (search.value.trim()) params.set('search', search.value.trim()) - const res = await api(`/api/books?${params}`) + const res = await api(`/api/v1/books?${params}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: BooksPage = await res.json() diff --git a/client/src/features/book/composables/useCoverEditor.ts b/client/src/features/book/composables/useCoverEditor.ts index 8bb5a6808..757341f15 100644 --- a/client/src/features/book/composables/useCoverEditor.ts +++ b/client/src/features/book/composables/useCoverEditor.ts @@ -41,10 +41,10 @@ export function useCoverEditor(bookId: Ref) { if (pendingFile.value) { const form = new FormData() form.append('file', pendingFile.value) - const res = await api(`/api/books/${bookId.value}/cover`, { method: 'POST', body: form }) + const res = await api(`/api/v1/books/${bookId.value}/cover`, { method: 'POST', body: form }) if (!res.ok) throw new Error(`HTTP ${res.status}`) } else { - const res = await api(`/api/books/${bookId.value}/cover/from-url`, { + const res = await api(`/api/v1/books/${bookId.value}/cover/from-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: pendingUrl.value }), @@ -66,7 +66,7 @@ export function useCoverEditor(bookId: Ref) { uploading.value = true error.value = null try { - const res = await api(`/api/books/${bookId.value}/cover`, { method: 'DELETE' }) + const res = await api(`/api/v1/books/${bookId.value}/cover`, { method: 'DELETE' }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() bumpVersion(bookId.value) diff --git a/client/src/features/book/composables/useCoverVersions.ts b/client/src/features/book/composables/useCoverVersions.ts index fcc89b231..f4bbeeb1e 100644 --- a/client/src/features/book/composables/useCoverVersions.ts +++ b/client/src/features/book/composables/useCoverVersions.ts @@ -34,7 +34,7 @@ export function useCoverVersions() { } function coverUrl(bookId: number, type: 'thumbnail' | 'cover' = 'thumbnail'): string { - const base = `/api/books/${bookId}/${type}` + const base = `/api/v1/books/${bookId}/${type}` const v = versions.value.get(bookId) return v ? `${base}?t=${v}` : base } diff --git a/client/src/features/book/composables/useGlobalSearch.ts b/client/src/features/book/composables/useGlobalSearch.ts index ab7477460..68f549952 100644 --- a/client/src/features/book/composables/useGlobalSearch.ts +++ b/client/src/features/book/composables/useGlobalSearch.ts @@ -25,7 +25,7 @@ export function useGlobalSearch(query: Ref) { timer = setTimeout(async () => { try { const params = new URLSearchParams({ q: q.trim(), limit: '10' }) - const res = await api(`/api/books/search?${params}`) + const res = await api(`/api/v1/books/search?${params}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) results.value = await res.json() } catch { diff --git a/client/src/features/book/composables/useMetadataEditor.ts b/client/src/features/book/composables/useMetadataEditor.ts index 9203d7737..5a6877e21 100644 --- a/client/src/features/book/composables/useMetadataEditor.ts +++ b/client/src/features/book/composables/useMetadataEditor.ts @@ -68,7 +68,7 @@ export function useMetadataEditor() { saving.value = true error.value = null try { - const res = await api(`/api/books/${bookId}/metadata`, { + const res = await api(`/api/v1/books/${bookId}/metadata`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form), diff --git a/client/src/features/book/composables/useMetadataSearch.ts b/client/src/features/book/composables/useMetadataSearch.ts index 84d8a51b6..2352a229c 100644 --- a/client/src/features/book/composables/useMetadataSearch.ts +++ b/client/src/features/book/composables/useMetadataSearch.ts @@ -20,7 +20,7 @@ export function useMetadataSearch() { let abortController: AbortController | null = null async function loadProviders() { - const res = await api('/api/metadata-fetch/providers') + const res = await api('/api/v1/metadata-fetch/providers') if (res.ok) providers.value = (await res.json()) as MetadataProviderInfo[] } @@ -48,7 +48,7 @@ export function useMetadataSearch() { if (selectedProviders.value.length) query.set('providers', selectedProviders.value.join(',')) try { - const res = await api(`/api/metadata-fetch/stream?${query}`, { + const res = await api(`/api/v1/metadata-fetch/stream?${query}`, { signal: abortController.signal, }) if (!res.ok || !res.body) return diff --git a/client/src/features/book/composables/useRecommendations.ts b/client/src/features/book/composables/useRecommendations.ts index 8b6e347e9..7ee08bd43 100644 --- a/client/src/features/book/composables/useRecommendations.ts +++ b/client/src/features/book/composables/useRecommendations.ts @@ -11,7 +11,7 @@ export function useRecommendations() { loading.value = true recommendations.value = [] try { - const res = await api(`/api/books/${bookId}/recommendations`) + const res = await api(`/api/v1/books/${bookId}/recommendations`) if (!res.ok) return recommendations.value = await res.json() } catch { diff --git a/client/src/features/book/composables/useRefreshMetadata.ts b/client/src/features/book/composables/useRefreshMetadata.ts index 9a824b53d..381bd8e31 100644 --- a/client/src/features/book/composables/useRefreshMetadata.ts +++ b/client/src/features/book/composables/useRefreshMetadata.ts @@ -26,7 +26,7 @@ export function useRefreshMetadata() { async function callRefresh(bookId: number, preview: boolean): Promise { refreshing.value = true try { - const url = `/api/books/${bookId}/refresh-metadata${preview ? '?preview=true' : ''}` + const url = `/api/v1/books/${bookId}/refresh-metadata${preview ? '?preview=true' : ''}` const res = await api(url, { method: 'POST' }) if (!res.ok) return null return (await res.json()) as T diff --git a/client/src/features/book/composables/useTagSearch.ts b/client/src/features/book/composables/useTagSearch.ts index 93963e186..c32594883 100644 --- a/client/src/features/book/composables/useTagSearch.ts +++ b/client/src/features/book/composables/useTagSearch.ts @@ -3,7 +3,7 @@ import { api } from '@/lib/api' function useMetadataSearch(endpoint: string) { async function search(q: string): Promise { if (!q.trim()) return [] - const res = await api(`/api/metadata/${endpoint}?q=${encodeURIComponent(q)}`) + const res = await api(`/api/v1/metadata/${endpoint}?q=${encodeURIComponent(q)}`) if (!res.ok) return [] const data: { name: string }[] = await res.json() return data.map((item) => item.name) diff --git a/client/src/features/collection/composables/useCollectionBooks.ts b/client/src/features/collection/composables/useCollectionBooks.ts index 39930dd32..228a6f77c 100644 --- a/client/src/features/collection/composables/useCollectionBooks.ts +++ b/client/src/features/collection/composables/useCollectionBooks.ts @@ -20,7 +20,7 @@ export function useCollectionBooks(collectionId: Ref) { } loading.value = true try { - const res = await api(`/api/collections/${collectionId.value}/books?page=${page}&size=${PAGE_SIZE}`) + const res = await api(`/api/v1/collections/${collectionId.value}/books?page=${page}&size=${PAGE_SIZE}`) if (!res.ok) throw new Error('Failed to load collection books') const data = await res.json() items.value = reset ? data.items : [...items.value, ...data.items] diff --git a/client/src/features/collection/composables/useCollections.ts b/client/src/features/collection/composables/useCollections.ts index 1af5bd3a5..cd9333e20 100644 --- a/client/src/features/collection/composables/useCollections.ts +++ b/client/src/features/collection/composables/useCollections.ts @@ -6,19 +6,19 @@ const collections = ref([]) export function useCollections() { async function fetchCollections() { - const res = await api('/api/collections') + const res = await api('/api/v1/collections') if (!res.ok) throw new Error('Failed to fetch collections') collections.value = await res.json() } async function fetchCollectionsWithMembership(bookIds: number[]): Promise { - const res = await api(`/api/collections?bookIds=${bookIds.join(',')}`) + const res = await api(`/api/v1/collections?bookIds=${bookIds.join(',')}`) if (!res.ok) throw new Error('Failed to fetch collections') return res.json() } async function createCollection(name: string, icon?: string, description?: string): Promise { - const res = await api('/api/collections', { + const res = await api('/api/v1/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, icon: icon || undefined, description }), @@ -30,7 +30,7 @@ export function useCollections() { } async function updateCollection(id: number, name: string, icon: string, syncToKobo?: boolean): Promise { - const res = await api(`/api/collections/${id}`, { + const res = await api(`/api/v1/collections/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, icon, ...(syncToKobo !== undefined && { syncToKobo }) }), @@ -42,7 +42,7 @@ export function useCollections() { } async function addBooksToCollection(collectionId: number, bookIds: number[]): Promise { - const res = await api(`/api/collections/${collectionId}/books`, { + const res = await api(`/api/v1/collections/${collectionId}/books`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookIds }), @@ -54,7 +54,7 @@ export function useCollections() { } async function removeBooksFromCollection(collectionId: number, bookIds: number[]): Promise { - const res = await api(`/api/collections/${collectionId}/books`, { + const res = await api(`/api/v1/collections/${collectionId}/books`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookIds }), diff --git a/client/src/features/dashboard/composables/useDashboardScroller.ts b/client/src/features/dashboard/composables/useDashboardScroller.ts index c3819f72e..058684495 100644 --- a/client/src/features/dashboard/composables/useDashboardScroller.ts +++ b/client/src/features/dashboard/composables/useDashboardScroller.ts @@ -14,7 +14,7 @@ export function useDashboardScroller(type: ScrollerType, limit = 20, lensId?: nu try { const params = new URLSearchParams({ limit: String(limit) }) if (type === 'lens' && lensId) params.set('lensId', String(lensId)) - const res = await api(`/api/dashboard/scrollers/${type}?${params}`) + const res = await api(`/api/v1/dashboard/scrollers/${type}?${params}`) if (!res.ok) throw new Error() books.value = await res.json() } catch { diff --git a/client/src/features/lens/components/LensEditorPanel.vue b/client/src/features/lens/components/LensEditorPanel.vue index 106601bb6..70e9809b8 100644 --- a/client/src/features/lens/components/LensEditorPanel.vue +++ b/client/src/features/lens/components/LensEditorPanel.vue @@ -83,7 +83,7 @@ async function fetchPreview() { pagination: { page: 0, size: 1 }, } if (draftFilter.value) body.filter = draftFilter.value - const res = await api('/api/books/query', { + const res = await api('/api/v1/books/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/client/src/features/lens/composables/useLens.ts b/client/src/features/lens/composables/useLens.ts index 2632067e4..10a371fd9 100644 --- a/client/src/features/lens/composables/useLens.ts +++ b/client/src/features/lens/composables/useLens.ts @@ -22,7 +22,7 @@ export function useLens(lensId: Ref) { } try { - const res = await api(`/api/lenses/${lensId.value}/books?page=${page.value}&size=${PAGE_SIZE}`) + const res = await api(`/api/v1/lenses/${lensId.value}/books?page=${page.value}&size=${PAGE_SIZE}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: BooksPage = await res.json() diff --git a/client/src/features/lens/composables/useLenses.ts b/client/src/features/lens/composables/useLenses.ts index 2bef3b136..955ace3a7 100644 --- a/client/src/features/lens/composables/useLenses.ts +++ b/client/src/features/lens/composables/useLenses.ts @@ -29,21 +29,21 @@ const loaded = ref(false) export function useLenses() { async function fetchLenses() { if (loaded.value) return - const res = await api('/api/lenses') + const res = await api('/api/v1/lenses') if (!res.ok) return lenses.value = await res.json() loaded.value = true } async function refreshLenses() { - const res = await api('/api/lenses') + const res = await api('/api/v1/lenses') if (!res.ok) return lenses.value = await res.json() loaded.value = true } async function createLens(payload: CreateLensPayload): Promise { - const res = await api('/api/lenses', { + const res = await api('/api/v1/lenses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -55,7 +55,7 @@ export function useLenses() { } async function updateLens(id: number, payload: Partial): Promise { - const res = await api(`/api/lenses/${id}`, { + const res = await api(`/api/v1/lenses/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -68,7 +68,7 @@ export function useLenses() { } async function deleteLens(id: number): Promise { - const res = await api(`/api/lenses/${id}`, { method: 'DELETE' }) + const res = await api(`/api/v1/lenses/${id}`, { method: 'DELETE' }) if (!res.ok) throw new Error(`HTTP ${res.status}`) lenses.value = lenses.value.filter((l) => l.id !== id) } diff --git a/client/src/features/library/components/BookUploadModal.vue b/client/src/features/library/components/BookUploadModal.vue index 47141748f..762d48e58 100644 --- a/client/src/features/library/components/BookUploadModal.vue +++ b/client/src/features/library/components/BookUploadModal.vue @@ -30,7 +30,7 @@ const hasFiles = computed(() => files.value.length > 0) const allDone = computed(() => hasFiles.value && files.value.every((f) => f.status === 'done' || f.status === 'error')) async function loadFolders(libraryId: number) { - const res = await api(`/api/libraries/${libraryId}`) + const res = await api(`/api/v1/libraries/${libraryId}`) if (res.ok) { const data: Library = await res.json() folders.value = data.folders ?? [] diff --git a/client/src/features/library/components/FolderPickerModal.vue b/client/src/features/library/components/FolderPickerModal.vue index 3085c56a7..9da8bc53f 100644 --- a/client/src/features/library/components/FolderPickerModal.vue +++ b/client/src/features/library/components/FolderPickerModal.vue @@ -53,7 +53,7 @@ async function loadEntries(path: string) { loading.value = true error.value = null try { - const res = await api(`/api/path?path=${encodeURIComponent(path)}`) + const res = await api(`/api/v1/path?path=${encodeURIComponent(path)}`) if (res.ok) { entries.value = await res.json() } else { diff --git a/client/src/features/library/components/LibraryCreatorAccess.vue b/client/src/features/library/components/LibraryCreatorAccess.vue index df35d16a7..850253ca9 100644 --- a/client/src/features/library/components/LibraryCreatorAccess.vue +++ b/client/src/features/library/components/LibraryCreatorAccess.vue @@ -24,7 +24,7 @@ async function loadAccess() { if (!props.libraryId) return loading.value = true try { - const [accessRes, usersRes] = await Promise.all([api(`/api/libraries/${props.libraryId}/access`), api('/api/users')]) + const [accessRes, usersRes] = await Promise.all([api(`/api/v1/libraries/${props.libraryId}/access`), api('/api/v1/users')]) if (accessRes.ok) accessList.value = await accessRes.json() if (usersRes.ok) { const all: UserOption[] = await usersRes.json() @@ -38,7 +38,7 @@ async function loadAccess() { async function grant() { if (!props.libraryId || !grantUserId.value) return - await api(`/api/libraries/${props.libraryId}/access`, { + await api(`/api/v1/libraries/${props.libraryId}/access`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: grantUserId.value, accessLevel: grantLevel.value }), @@ -49,7 +49,7 @@ async function grant() { async function changeLevel(userId: number, accessLevel: 'viewer' | 'editor' | 'owner') { if (!props.libraryId) return - await api(`/api/libraries/${props.libraryId}/access/${userId}`, { + await api(`/api/v1/libraries/${props.libraryId}/access/${userId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessLevel }), @@ -59,7 +59,7 @@ async function changeLevel(userId: number, accessLevel: 'viewer' | 'editor' | 'o async function revoke(userId: number) { if (!props.libraryId) return - await api(`/api/libraries/${props.libraryId}/access/${userId}`, { method: 'DELETE' }) + await api(`/api/v1/libraries/${props.libraryId}/access/${userId}`, { method: 'DELETE' }) await loadAccess() } diff --git a/client/src/features/library/components/LibraryCreatorFolders.vue b/client/src/features/library/components/LibraryCreatorFolders.vue index 886ebff14..48c9be92e 100644 --- a/client/src/features/library/components/LibraryCreatorFolders.vue +++ b/client/src/features/library/components/LibraryCreatorFolders.vue @@ -47,7 +47,7 @@ async function testPath() { testLoading.value = true testResult.value = null try { - const res = await api('/api/libraries/prescan', { + const res = await api('/api/v1/libraries/prescan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: [trimmed] }), diff --git a/client/src/features/library/components/LibraryCreatorModal.vue b/client/src/features/library/components/LibraryCreatorModal.vue index 29b79fd6b..ee40fbcfd 100644 --- a/client/src/features/library/components/LibraryCreatorModal.vue +++ b/client/src/features/library/components/LibraryCreatorModal.vue @@ -79,7 +79,7 @@ const mobileView = ref<'nav' | 'content'>('nav') onMounted(async () => { if (props.library) { - const res = await api(`/api/libraries/${props.library.id}`) + const res = await api(`/api/v1/libraries/${props.library.id}`) const full: Library = res.ok ? await res.json() : props.library creator.initEdit(full) visitedUpTo.value = ALL_SECTIONS.length - 1 // all unlocked in edit diff --git a/client/src/features/library/composables/useBookUpload.ts b/client/src/features/library/composables/useBookUpload.ts index 5f22bc534..eba0a1aaf 100644 --- a/client/src/features/library/composables/useBookUpload.ts +++ b/client/src/features/library/composables/useBookUpload.ts @@ -113,7 +113,7 @@ export function useBookUpload() { const pending = files.value.filter((f) => f.status === 'pending') if (pending.length === 0) return - const baseUrl = `/api/libraries/${libraryId}/upload` + const baseUrl = `/api/v1/libraries/${libraryId}/upload` const url = folderId !== undefined ? `${baseUrl}?folderId=${folderId}` : baseUrl // Process pending items with a concurrency limit of UPLOAD_CONCURRENCY diff --git a/client/src/features/library/composables/useLibraries.ts b/client/src/features/library/composables/useLibraries.ts index e3d9f5838..4f44926a5 100644 --- a/client/src/features/library/composables/useLibraries.ts +++ b/client/src/features/library/composables/useLibraries.ts @@ -6,7 +6,7 @@ const libraries = ref([]) export function useLibraries() { async function fetchLibraries() { - const res = await api('/api/libraries') + const res = await api('/api/v1/libraries') if (!res.ok) return libraries.value = await res.json() } diff --git a/client/src/features/library/composables/useLibraryCreator.ts b/client/src/features/library/composables/useLibraryCreator.ts index fee3fa5c1..e9269336c 100644 --- a/client/src/features/library/composables/useLibraryCreator.ts +++ b/client/src/features/library/composables/useLibraryCreator.ts @@ -82,7 +82,7 @@ export function useLibraryCreator() { prescanLoading.value = true prescanResult.value = null try { - const res = await api('/api/libraries/prescan', { + const res = await api('/api/v1/libraries/prescan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths: form.folders }), @@ -110,13 +110,13 @@ export function useLibraryCreator() { const payload = { ...form } let res: Response if (mode.value === 'create') { - res = await api('/api/libraries', { + res = await api('/api/v1/libraries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) } else { - res = await api(`/api/libraries/${editingLibraryId.value}`, { + res = await api(`/api/v1/libraries/${editingLibraryId.value}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), diff --git a/client/src/features/reader/composables/useAnnotations.ts b/client/src/features/reader/composables/useAnnotations.ts index edc02a8be..b6b63ab84 100644 --- a/client/src/features/reader/composables/useAnnotations.ts +++ b/client/src/features/reader/composables/useAnnotations.ts @@ -19,7 +19,7 @@ export function useAnnotations() { async function load(bookId: number) { loadError.value = null - const res = await api(`/api/books/${bookId}/annotations`) + const res = await api(`/api/v1/books/${bookId}/annotations`) if (!res.ok) { loadError.value = 'Failed to load' return @@ -31,7 +31,7 @@ export function useAnnotations() { bookId: number, data: { cfi: string; text: string; color: string; style: string; note?: string | null; chapterTitle?: string | null }, ): Promise { - const res = await api(`/api/books/${bookId}/annotations`, { + const res = await api(`/api/v1/books/${bookId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -43,7 +43,7 @@ export function useAnnotations() { } async function updateNote(bookId: number, id: number, note: string | null) { - const res = await api(`/api/books/${bookId}/annotations/${id}`, { + const res = await api(`/api/v1/books/${bookId}/annotations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note }), @@ -55,7 +55,7 @@ export function useAnnotations() { } async function remove(bookId: number, id: number) { - const res = await api(`/api/books/${bookId}/annotations/${id}`, { + const res = await api(`/api/v1/books/${bookId}/annotations/${id}`, { method: 'DELETE', }) if (res.ok) { diff --git a/client/src/features/reader/composables/useBookmarks.ts b/client/src/features/reader/composables/useBookmarks.ts index 659693560..6b0b34228 100644 --- a/client/src/features/reader/composables/useBookmarks.ts +++ b/client/src/features/reader/composables/useBookmarks.ts @@ -25,7 +25,7 @@ export function useBookmarks() { async function load(bookId: number) { loadError.value = null - const res = await api(`/api/books/${bookId}/bookmarks`) + const res = await api(`/api/v1/books/${bookId}/bookmarks`) if (!res.ok) { loadError.value = 'Failed to load' return @@ -38,7 +38,7 @@ export function useBookmarks() { if (existing) { await remove(bookId, existing.id) } else { - const res = await api(`/api/books/${bookId}/bookmarks`, { + const res = await api(`/api/v1/books/${bookId}/bookmarks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cfi, title }), @@ -51,7 +51,7 @@ export function useBookmarks() { } async function remove(bookId: number, bookmarkId: number) { - const res = await api(`/api/books/${bookId}/bookmarks/${bookmarkId}`, { + const res = await api(`/api/v1/books/${bookId}/bookmarks/${bookmarkId}`, { method: 'DELETE', }) if (res.ok) { diff --git a/client/src/features/reader/composables/useCbz.ts b/client/src/features/reader/composables/useCbz.ts index c15979ce5..79adb9fd2 100644 --- a/client/src/features/reader/composables/useCbz.ts +++ b/client/src/features/reader/composables/useCbz.ts @@ -8,11 +8,11 @@ export function useCbz(fileId: number, bookId: number) { const error = ref(null) function pageUrl(n: number): string { - return `/api/cbz/files/${fileId}/pages/${n}` + return `/api/v1/cbz/files/${fileId}/pages/${n}` } async function load(): Promise { - const [pagesRes, bookRes] = await Promise.all([api(`/api/cbz/files/${fileId}/pages`), api(`/api/books/${bookId}`)]) + const [pagesRes, bookRes] = await Promise.all([api(`/api/v1/cbz/files/${fileId}/pages`), api(`/api/v1/books/${bookId}`)]) if (!pagesRes.ok) { error.value = 'Failed to load comic' loading.value = false diff --git a/client/src/features/reader/composables/useFoliate.ts b/client/src/features/reader/composables/useFoliate.ts index 80c70f731..ecc363ca5 100644 --- a/client/src/features/reader/composables/useFoliate.ts +++ b/client/src/features/reader/composables/useFoliate.ts @@ -106,7 +106,7 @@ export function useFoliate( const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip' const ext = format === 'pdf' ? 'pdf' : format === 'cbz' ? 'cbz' : 'epub' - const res = await api(`/api/books/files/${fileId}/serve`) + const res = await api(`/api/v1/books/files/${fileId}/serve`) if (!res.ok) throw new Error(`Failed to fetch book file: ${res.status}`) const blob = await res.blob() const file = new File([blob], `book-file-${fileId}.${ext}`, { type: mimeType }) diff --git a/client/src/features/reader/composables/usePdf.ts b/client/src/features/reader/composables/usePdf.ts index 4831c5fd4..d920078cc 100644 --- a/client/src/features/reader/composables/usePdf.ts +++ b/client/src/features/reader/composables/usePdf.ts @@ -23,7 +23,7 @@ export function usePdf() { error.value = null try { const doc = await pdfjsLib.getDocument({ - url: `/api/books/files/${fileId}/serve`, + url: `/api/v1/books/files/${fileId}/serve`, rangeChunkSize: 65536, disableStream: true, disableAutoFetch: true, diff --git a/client/src/features/reader/composables/useReaderProgress.ts b/client/src/features/reader/composables/useReaderProgress.ts index 1b76fcc8b..095139f22 100644 --- a/client/src/features/reader/composables/useReaderProgress.ts +++ b/client/src/features/reader/composables/useReaderProgress.ts @@ -14,7 +14,7 @@ export function useReaderProgress(bookId: number, fileId: number) { let saveTimer: ReturnType | null = null async function load() { - const res = await api(`/api/books/files/${fileId}/progress`) + const res = await api(`/api/v1/books/files/${fileId}/progress`) if (!res.ok) return const data = await res.json() cfi.value = data.cfi ?? null @@ -35,7 +35,7 @@ export function useReaderProgress(bookId: number, fileId: number) { } async function save() { - await api(`/api/books/files/${fileId}/progress`, { + await api(`/api/v1/books/files/${fileId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cfi: cfi.value, pageNumber: pageNumber.value, percentage: percentage.value }), diff --git a/client/src/features/reader/composables/useReaderSettings.ts b/client/src/features/reader/composables/useReaderSettings.ts index 3d4dc05f3..b2a9e902c 100644 --- a/client/src/features/reader/composables/useReaderSettings.ts +++ b/client/src/features/reader/composables/useReaderSettings.ts @@ -67,8 +67,8 @@ export function useReaderSettings(bookFileId: number, format: string) { async function syncFromDb() { const [prefRes, defRes] = await Promise.all([ - api(`/api/reader/preferences/${bookFileId}`).then((r) => (r.ok ? r.json() : null)), - api(`/api/reader/defaults`).then((r) => (r.ok ? r.json() : null)), + api(`/api/v1/reader/preferences/${bookFileId}`).then((r) => (r.ok ? r.json() : null)), + api(`/api/v1/reader/defaults`).then((r) => (r.ok ? r.json() : null)), ]) if (prefRes?.settings) { @@ -90,7 +90,7 @@ export function useReaderSettings(bookFileId: number, format: string) { writeLs(lsBookKey(bookFileId), next) if (syncEnabled.value) { - api(`/api/reader/preferences/${bookFileId}`, { + api(`/api/v1/reader/preferences/${bookFileId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: next }), @@ -104,7 +104,7 @@ export function useReaderSettings(bookFileId: number, format: string) { removeLs(lsBookKey(bookFileId)) if (syncEnabled.value) { - api(`/api/reader/preferences/${bookFileId}`, { method: 'DELETE' }).catch(() => {}) + api(`/api/v1/reader/preferences/${bookFileId}`, { method: 'DELETE' }).catch(() => {}) } } @@ -115,7 +115,7 @@ export function useReaderSettings(bookFileId: number, format: string) { writeLs(lsDefaultKey(group), next) if (syncEnabled.value) { - api(`/api/reader/defaults/${group}`, { + api(`/api/v1/reader/defaults/${group}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: next }), @@ -128,7 +128,7 @@ export function useReaderSettings(bookFileId: number, format: string) { removeLs(lsDefaultKey(group)) if (syncEnabled.value) { - api(`/api/reader/defaults/${group}`, { method: 'DELETE' }).catch(() => {}) + api(`/api/v1/reader/defaults/${group}`, { method: 'DELETE' }).catch(() => {}) } } @@ -159,7 +159,7 @@ export function useReaderDefaultSettings(format: strin if (ls) settings.value = ls if (syncEnabled.value) { - const res = await api('/api/reader/defaults') + const res = await api('/api/v1/reader/defaults') if (res.ok) { const data = await res.json() if (data[group]) { @@ -176,7 +176,7 @@ export function useReaderDefaultSettings(format: strin writeLs(lsDefaultKey(group), next) if (syncEnabled.value) { - api(`/api/reader/defaults/${group}`, { + api(`/api/v1/reader/defaults/${group}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: next }), @@ -189,7 +189,7 @@ export function useReaderDefaultSettings(format: strin removeLs(lsDefaultKey(group)) if (syncEnabled.value) { - api(`/api/reader/defaults/${group}`, { method: 'DELETE' }).catch(() => {}) + api(`/api/v1/reader/defaults/${group}`, { method: 'DELETE' }).catch(() => {}) } } diff --git a/client/src/features/settings/KoboSettings.vue b/client/src/features/settings/KoboSettings.vue index e2495f954..fd9524131 100644 --- a/client/src/features/settings/KoboSettings.vue +++ b/client/src/features/settings/KoboSettings.vue @@ -74,7 +74,7 @@ async function submitCreate() { try { const device = await createDevice(newDeviceName.value.trim()) newDeviceToken.value = device.token - newDeviceSyncUrl.value = `${window.location.origin}/api/kobo/${device.token}` + newDeviceSyncUrl.value = `${window.location.origin}/api/v1/kobo/${device.token}` showCreateForm.value = false newDeviceName.value = '' } catch (e) { diff --git a/client/src/features/settings/LibrariesSettings.vue b/client/src/features/settings/LibrariesSettings.vue index 7fcad4c7c..a91df7df2 100644 --- a/client/src/features/settings/LibrariesSettings.vue +++ b/client/src/features/settings/LibrariesSettings.vue @@ -26,7 +26,7 @@ const deleting = ref(false) async function loadAllStats() { await Promise.all( libraries.value.map(async (lib) => { - const res = await api(`/api/libraries/${lib.id}/stats`) + const res = await api(`/api/v1/libraries/${lib.id}/stats`) if (res.ok) stats.value[lib.id] = await res.json() }), ) @@ -51,7 +51,7 @@ watch(progressMap, (map) => { for (const [libraryId, event] of map) { if (event.status === 'completed' && !statsReloadedFor.has(libraryId)) { statsReloadedFor.add(libraryId) - api(`/api/libraries/${libraryId}/stats`).then(async (res) => { + api(`/api/v1/libraries/${libraryId}/stats`).then(async (res) => { if (res.ok) stats.value[libraryId] = await res.json() setTimeout(() => statsReloadedFor.delete(libraryId), 5000) }) @@ -65,7 +65,7 @@ watch(progressMap, (map) => { async function scan(lib: LibraryType) { try { - const res = await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' }) + const res = await api(`/api/v1/scanner/libraries/${lib.id}/scan`, { method: 'POST' }) if (res.ok) { toast.success(`Scan started for "${lib.name}"`) subscribeLibrary(lib.id) @@ -79,7 +79,7 @@ async function scan(lib: LibraryType) { async function refreshCovers(lib: LibraryType) { try { - const res = await api(`/api/scanner/libraries/${lib.id}/refresh-covers`, { method: 'POST' }) + const res = await api(`/api/v1/scanner/libraries/${lib.id}/refresh-covers`, { method: 'POST' }) if (!res.ok) toast.error(`Failed to refresh covers for "${lib.name}"`) } catch { toast.error(`Failed to refresh covers for "${lib.name}"`) @@ -89,7 +89,7 @@ async function refreshCovers(lib: LibraryType) { async function scanAll() { scanningAll.value = true try { - const results = await Promise.all(libraries.value.map((lib) => api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' }))) + const results = await Promise.all(libraries.value.map((lib) => api(`/api/v1/scanner/libraries/${lib.id}/scan`, { method: 'POST' }))) const failed = results.filter((r) => !r.ok).length if (failed === 0) { toast.success('Scan started for all libraries') @@ -135,7 +135,7 @@ async function confirmDelete() { const deletedId = deletingLibrary.value.id const deletedName = deletingLibrary.value.name try { - const res = await api(`/api/libraries/${deletedId}`, { method: 'DELETE' }) + const res = await api(`/api/v1/libraries/${deletedId}`, { method: 'DELETE' }) if (res.ok) { toast.success(`"${deletedName}" deleted`) deletingLibrary.value = null diff --git a/client/src/features/settings/MaintenanceSettings.vue b/client/src/features/settings/MaintenanceSettings.vue index 30fbc0df1..0d466c16e 100644 --- a/client/src/features/settings/MaintenanceSettings.vue +++ b/client/src/features/settings/MaintenanceSettings.vue @@ -13,7 +13,7 @@ async function rebuildEmbeddings() { queued.value = null error.value = null try { - const res = await api('/api/books/embed-all', { method: 'POST' }) + const res = await api('/api/v1/books/embed-all', { method: 'POST' }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: { queued: number } = await res.json() queued.value = data.queued diff --git a/client/src/features/settings/OidcSettings.vue b/client/src/features/settings/OidcSettings.vue index 6b6a30b4f..345c897c5 100644 --- a/client/src/features/settings/OidcSettings.vue +++ b/client/src/features/settings/OidcSettings.vue @@ -33,7 +33,7 @@ const form = reactive({ onMounted(async () => { try { - const res = await api('/api/app-settings/oidc') + const res = await api('/api/v1/app-settings/oidc') if (res.ok) { const data = await res.json() Object.assign(form, data) @@ -52,7 +52,7 @@ async function save() { const body: Partial = { ...form } if (!body.clientSecret) delete body.clientSecret - const res = await api('/api/app-settings/oidc', { + const res = await api('/api/v1/app-settings/oidc', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -76,7 +76,7 @@ async function testConnection() { testResult.value = null testing.value = true try { - const res = await api(`/api/app-settings/oidc/test?issuerUri=${encodeURIComponent(form.issuerUri)}`, { method: 'POST' }) + const res = await api(`/api/v1/app-settings/oidc/test?issuerUri=${encodeURIComponent(form.issuerUri)}`, { method: 'POST' }) const data = await res.json() if (data.success) { testResult.value = { success: true, message: `Connected - issuer: ${data.issuer}` } diff --git a/client/src/features/settings/OpdsSettings.vue b/client/src/features/settings/OpdsSettings.vue index 6dc902c1c..6c0281b37 100644 --- a/client/src/features/settings/OpdsSettings.vue +++ b/client/src/features/settings/OpdsSettings.vue @@ -39,7 +39,7 @@ function sortOrderLabel(value: OpdsSortOrder): string { onMounted(async () => { try { - const [settingsRes, usersRes] = await Promise.all([api('/api/app-settings'), api('/api/v1/opds-users')]) + const [settingsRes, usersRes] = await Promise.all([api('/api/v1/app-settings'), api('/api/v1/opds-users')]) if (settingsRes.ok) { const settings = await settingsRes.json() const row = settings.find((s: { key: string; value: string }) => s.key === 'opds_enabled') @@ -57,7 +57,7 @@ onMounted(async () => { async function toggleOpds() { const newVal = !opdsEnabled.value - const res = await api('/api/app-settings/opds_enabled', { + const res = await api('/api/v1/app-settings/opds_enabled', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: String(newVal) }), diff --git a/client/src/features/settings/ReaderSettings.vue b/client/src/features/settings/ReaderSettings.vue index ff2809d0d..008c07a03 100644 --- a/client/src/features/settings/ReaderSettings.vue +++ b/client/src/features/settings/ReaderSettings.vue @@ -10,7 +10,7 @@ const syncEnabled = computed(() => user.value?.settings?.syncReaderPreferences ? async function setStorageMode(sync: boolean) { if (!user.value || syncEnabled.value === sync) return - const res = await api('/api/users/me', { + const res = await api('/api/v1/users/me', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: { syncReaderPreferences: sync } }), diff --git a/client/src/features/settings/StagingSettings.vue b/client/src/features/settings/StagingSettings.vue index 751c550a0..82f44c102 100644 --- a/client/src/features/settings/StagingSettings.vue +++ b/client/src/features/settings/StagingSettings.vue @@ -19,7 +19,7 @@ const autoFinalizeFolders = computed(() => autoFinalizeLibrary.value?.folders ?? onMounted(async () => { try { - const [res] = await Promise.all([api('/api/app-settings'), fetchLibraries()]) + const [res] = await Promise.all([api('/api/v1/app-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 @@ -37,7 +37,7 @@ onMounted(async () => { }) async function saveSetting(key: string, value: string) { - await api(`/api/app-settings/${key}`, { + await api(`/api/v1/app-settings/${key}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value }), @@ -49,7 +49,7 @@ async function toggle() { const newVal = !autoFetch.value saving.value = true try { - const res = await api('/api/app-settings/staging_auto_fetch_metadata', { + const res = await api('/api/v1/app-settings/staging_auto_fetch_metadata', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: String(newVal) }), @@ -65,7 +65,7 @@ async function toggleAutoFinalize() { const newVal = !autoFinalizeEnabled.value saving.value = true try { - const res = await api('/api/app-settings/staging_auto_finalize_enabled', { + const res = await api('/api/v1/app-settings/staging_auto_finalize_enabled', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: String(newVal) }), diff --git a/client/src/features/settings/composables/useFileNamingPattern.ts b/client/src/features/settings/composables/useFileNamingPattern.ts index 3f76e0c0a..c5c88b2cd 100644 --- a/client/src/features/settings/composables/useFileNamingPattern.ts +++ b/client/src/features/settings/composables/useFileNamingPattern.ts @@ -26,7 +26,7 @@ export function useFileNamingPattern() { async function fetchGlobalPattern() { loadingGlobal.value = true try { - const res = await api('/api/app-settings/upload-pattern') + const res = await api('/api/v1/app-settings/upload-pattern') if (res.ok) { const data: { pattern: string } = await res.json() globalPattern.value = data.pattern @@ -45,7 +45,7 @@ export function useFileNamingPattern() { if (globalError.value) return savingGlobal.value = true try { - const res = await api('/api/app-settings/upload-pattern', { + const res = await api('/api/v1/app-settings/upload-pattern', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pattern: globalPattern.value }), @@ -63,7 +63,7 @@ export function useFileNamingPattern() { async function saveLibraryPattern(library: Library) { savingLibraryId.value = library.id try { - const res = await api(`/api/libraries/${library.id}`, { + const res = await api(`/api/v1/libraries/${library.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileNamingPattern: library.fileNamingPattern ?? null }), diff --git a/client/src/features/settings/metadata-preferences/composables/useMetadataPreferences.ts b/client/src/features/settings/metadata-preferences/composables/useMetadataPreferences.ts index 50ecd8c13..5f54cc618 100644 --- a/client/src/features/settings/metadata-preferences/composables/useMetadataPreferences.ts +++ b/client/src/features/settings/metadata-preferences/composables/useMetadataPreferences.ts @@ -13,7 +13,7 @@ export function useMetadataPreferences() { async function fetchGlobal() { loadingGlobal.value = true try { - const res = await api('/api/metadata-preferences/global') + const res = await api('/api/v1/metadata-preferences/global') if (res.ok) globalPrefs.value = await res.json() } finally { loadingGlobal.value = false @@ -23,7 +23,7 @@ export function useMetadataPreferences() { async function saveGlobal(prefs: MetadataFetchPreferences) { savingGlobal.value = true try { - const res = await api('/api/metadata-preferences/global', { + const res = await api('/api/v1/metadata-preferences/global', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(prefs), @@ -40,7 +40,7 @@ export function useMetadataPreferences() { } async function fetchLibrary(libraryId: number) { - const res = await api(`/api/metadata-preferences/libraries/${libraryId}`) + const res = await api(`/api/v1/metadata-preferences/libraries/${libraryId}`) if (res.ok) { const data: LibraryMetadataPreferences = await res.json() libraryPrefs.value = new Map(libraryPrefs.value).set(libraryId, data) @@ -51,7 +51,7 @@ export function useMetadataPreferences() { const key = `${libraryId}:${field}` savingField.value = key try { - const url = `/api/metadata-preferences/libraries/${libraryId}/fields/${field}` + const url = `/api/v1/metadata-preferences/libraries/${libraryId}/fields/${field}` const res = await api(url, { method: pref === null ? 'DELETE' : 'PUT', headers: pref !== null ? { 'Content-Type': 'application/json' } : undefined, @@ -68,7 +68,7 @@ export function useMetadataPreferences() { } async function resetLibrary(libraryId: number) { - const res = await api(`/api/metadata-preferences/libraries/${libraryId}`, { method: 'DELETE' }) + const res = await api(`/api/v1/metadata-preferences/libraries/${libraryId}`, { method: 'DELETE' }) if (res.ok || res.status === 204) { await fetchLibrary(libraryId) toast.success('Library reset to global defaults') diff --git a/client/src/features/settings/metadata-preferences/composables/useProviderConfig.ts b/client/src/features/settings/metadata-preferences/composables/useProviderConfig.ts index 4780f66eb..fefad7fee 100644 --- a/client/src/features/settings/metadata-preferences/composables/useProviderConfig.ts +++ b/client/src/features/settings/metadata-preferences/composables/useProviderConfig.ts @@ -12,7 +12,7 @@ export function useProviderConfig() { async function fetchConfig() { loading.value = true try { - const res = await api('/api/metadata-preferences/providers') + const res = await api('/api/v1/metadata-preferences/providers') if (!res.ok) return const data: { config: ProviderConfigurations; statuses: ProviderStatus[] } = await res.json() config.value = data.config @@ -25,7 +25,7 @@ export function useProviderConfig() { async function saveConfig(patch: Partial) { saving.value = true try { - const res = await api('/api/metadata-preferences/providers', { + const res = await api('/api/v1/metadata-preferences/providers', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), diff --git a/client/src/features/staging/components/StagingBulkEditDialog.vue b/client/src/features/staging/components/StagingBulkEditDialog.vue index fc5674413..b2eca9bab 100644 --- a/client/src/features/staging/components/StagingBulkEditDialog.vue +++ b/client/src/features/staging/components/StagingBulkEditDialog.vue @@ -60,7 +60,7 @@ async function submit() { } try { - const res = await api('/api/staging/files/bulk-edit', { + const res = await api('/api/v1/staging/files/bulk-edit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/client/src/features/staging/components/StagingFileList.vue b/client/src/features/staging/components/StagingFileList.vue index e06cb256a..a20cc9bd4 100644 --- a/client/src/features/staging/components/StagingFileList.vue +++ b/client/src/features/staging/components/StagingFileList.vue @@ -25,7 +25,7 @@ defineEmits<{ }>() function backendCoverUrl(file: StagingFile): string { - return `/api/staging/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}` + return `/api/v1/staging/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}` } function coverUrl(file: StagingFile): string { @@ -35,7 +35,7 @@ function coverUrl(file: StagingFile): string { function onCoverError(event: Event, file: StagingFile) { const img = event.target as HTMLImageElement const externalUrl = file.selectedMetadata?.coverUrl ?? file.fetchedMetadata?.coverUrl - if (img.src.includes('/api/staging/files/') && externalUrl) { + if (img.src.includes('/api/v1/staging/files/') && externalUrl) { img.src = externalUrl } else { img.style.display = 'none' diff --git a/client/src/features/staging/components/StagingFinalizeDialog.vue b/client/src/features/staging/components/StagingFinalizeDialog.vue index 576dbb58e..36e9fdbf4 100644 --- a/client/src/features/staging/components/StagingFinalizeDialog.vue +++ b/client/src/features/staging/components/StagingFinalizeDialog.vue @@ -60,7 +60,7 @@ async function fetchNamePreview(libId: number | null) { } previewLoading.value = true try { - const res = await api('/api/staging/files/preview-names', { + const res = await api('/api/v1/staging/files/preview-names', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...props.selectionPayload, defaultLibraryId: libId }), diff --git a/client/src/features/staging/components/StagingToolbar.vue b/client/src/features/staging/components/StagingToolbar.vue index a7ba6e0d5..e841dca79 100644 --- a/client/src/features/staging/components/StagingToolbar.vue +++ b/client/src/features/staging/components/StagingToolbar.vue @@ -54,7 +54,7 @@ function onFilesSelected(event: Event) { async function rescan() { rescanning.value = true try { - const res = await api('/api/staging/rescan', { method: 'POST' }) + const res = await api('/api/v1/staging/rescan', { method: 'POST' }) if (res.ok) { emit('rescan') } else { diff --git a/client/src/features/staging/composables/useStagingDetail.ts b/client/src/features/staging/composables/useStagingDetail.ts index 7f1344855..297a5d47c 100644 --- a/client/src/features/staging/composables/useStagingDetail.ts +++ b/client/src/features/staging/composables/useStagingDetail.ts @@ -14,7 +14,7 @@ export function useStagingDetail() { async function loadFile(id: number) { loading.value = true try { - const res = await api(`/api/staging/files/${id}`) + const res = await api(`/api/v1/staging/files/${id}`) if (res.ok) file.value = await res.json() } finally { loading.value = false @@ -24,7 +24,7 @@ export function useStagingDetail() { async function saveMetadata(id: number, metadata: Partial): Promise { saving.value = true try { - const res = await api(`/api/staging/files/${id}`, { + const res = await api(`/api/v1/staging/files/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selectedMetadata: metadata }), @@ -55,7 +55,7 @@ export function useStagingDetail() { } async function setTarget(id: number, libraryId: number | null, folderId: number | null) { - const res = await api(`/api/staging/files/${id}`, { + const res = await api(`/api/v1/staging/files/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetLibraryId: libraryId, targetFolderId: folderId }), @@ -64,12 +64,12 @@ export function useStagingDetail() { } async function discardFile(id: number) { - await api(`/api/staging/files/${id}`, { method: 'DELETE' }) + await api(`/api/v1/staging/files/${id}`, { method: 'DELETE' }) file.value = null } function coverUrl(id: number): string { - return `/api/staging/files/${id}/cover` + return `/api/v1/staging/files/${id}/cover` } return { file, loading, saving, saved, saveError, loadFile, saveMetadata, debouncedSave, setTarget, discardFile, coverUrl } diff --git a/client/src/features/staging/composables/useStagingFiles.ts b/client/src/features/staging/composables/useStagingFiles.ts index 01270d491..9a9788a42 100644 --- a/client/src/features/staging/composables/useStagingFiles.ts +++ b/client/src/features/staging/composables/useStagingFiles.ts @@ -36,7 +36,7 @@ export function useStagingFiles() { params.set('sort', filters.sort) params.set('order', filters.order) - const res = await api(`/api/staging/files?${params}`) + const res = await api(`/api/v1/staging/files?${params}`) if (res.ok) { const data: StagingFilesPage = await res.json() items.value = data.items diff --git a/client/src/features/staging/composables/useStagingFinalize.ts b/client/src/features/staging/composables/useStagingFinalize.ts index 2ca42e75c..68ea0cd0f 100644 --- a/client/src/features/staging/composables/useStagingFinalize.ts +++ b/client/src/features/staging/composables/useStagingFinalize.ts @@ -20,7 +20,7 @@ export function useStagingFinalize() { result.value = null try { - const res = await api('/api/staging/finalize', { + const res = await api('/api/v1/staging/finalize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), diff --git a/client/src/features/staging/composables/useStagingStatistics.ts b/client/src/features/staging/composables/useStagingStatistics.ts index a88c02f71..c1ddcbc9e 100644 --- a/client/src/features/staging/composables/useStagingStatistics.ts +++ b/client/src/features/staging/composables/useStagingStatistics.ts @@ -7,7 +7,7 @@ export function useStagingStatistics() { async function fetchStatistics() { try { - const res = await api('/api/staging/statistics') + const res = await api('/api/v1/staging/statistics') if (res.ok) statistics.value = await res.json() } catch { statistics.value = null diff --git a/client/src/features/staging/composables/useStagingSummary.ts b/client/src/features/staging/composables/useStagingSummary.ts index 9e4481201..c9a052427 100644 --- a/client/src/features/staging/composables/useStagingSummary.ts +++ b/client/src/features/staging/composables/useStagingSummary.ts @@ -11,7 +11,7 @@ const changeListeners = new Set<() => void>() async function restFetchSummary() { try { - const res = await api('/api/staging/summary') + const res = await api('/api/v1/staging/summary') if (res.ok) summary.value = await res.json() } catch { // best-effort refresh on reconnect @@ -49,7 +49,7 @@ export function useStagingSummary() { async function fetchSummary() { loading.value = true try { - const res = await api('/api/staging/summary') + const res = await api('/api/v1/staging/summary') if (res.ok) summary.value = await res.json() } finally { loading.value = false diff --git a/client/src/features/staging/composables/useStagingUpload.ts b/client/src/features/staging/composables/useStagingUpload.ts index f1d94f0f4..0d4ab7909 100644 --- a/client/src/features/staging/composables/useStagingUpload.ts +++ b/client/src/features/staging/composables/useStagingUpload.ts @@ -32,7 +32,7 @@ function uploadSingle(item: UploadItem): Promise { formData.append('file', item.file) const xhr = new XMLHttpRequest() - xhr.open('POST', '/api/staging/upload') + xhr.open('POST', '/api/v1/staging/upload') const token = getAccessToken() if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index fa4d226eb..70d16cb01 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -23,7 +23,7 @@ function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const res = await rawFetch('/api/auth/refresh', { method: 'POST' }) + const res = await rawFetch('/api/v1/auth/refresh', { method: 'POST' }) if (!res.ok) throw new Error('refresh failed') const data: RefreshResponse = await res.json() _accessToken = data.accessToken diff --git a/client/src/views/CollectionView.vue b/client/src/views/CollectionView.vue index 76a9d4211..8cca7559e 100644 --- a/client/src/views/CollectionView.vue +++ b/client/src/views/CollectionView.vue @@ -77,7 +77,7 @@ async function handleDeleteSelected() { deletingInProgress = true try { const ids = [...selectedIds.value] - const res = await api('/api/books', { + const res = await api('/api/v1/books', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookIds: ids }), diff --git a/client/src/views/HomeView.vue b/client/src/views/HomeView.vue index 4c54957bc..25961916c 100644 --- a/client/src/views/HomeView.vue +++ b/client/src/views/HomeView.vue @@ -208,7 +208,7 @@ const addToCollectionOpen = ref(false) async function handleDeleteSelected() { const ids = [...selectedIds.value] if (ids.length === 0) return - const res = await api('/api/books', { + const res = await api('/api/v1/books', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookIds: ids }), diff --git a/client/src/views/LensView.vue b/client/src/views/LensView.vue index 1398e5ecc..242cb6877 100644 --- a/client/src/views/LensView.vue +++ b/client/src/views/LensView.vue @@ -79,7 +79,7 @@ const addToCollectionOpen = ref(false) async function handleDeleteSelected() { const ids = [...selectedIds.value] if (ids.length === 0) return - const res = await api('/api/books', { + const res = await api('/api/v1/books', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookIds: ids }), diff --git a/client/src/views/StagingView.vue b/client/src/views/StagingView.vue index 63609d6f7..87a43eed4 100644 --- a/client/src/views/StagingView.vue +++ b/client/src/views/StagingView.vue @@ -120,7 +120,7 @@ const emptyMessage = computed(() => { async function handleBulkDiscard() { const payload = getSelectionPayload() - await api('/api/staging/files/discard', { + await api('/api/v1/staging/files/discard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -131,7 +131,7 @@ async function handleBulkDiscard() { async function handleApplyFetched() { const payload = getSelectionPayload() - const res = await api('/api/staging/files/apply-fetched', { + const res = await api('/api/v1/staging/files/apply-fetched', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -150,7 +150,7 @@ async function handleApplyFetched() { async function handleRetryFetch() { const payload = getSelectionPayload() - const res = await api('/api/staging/files/retry-fetch', { + const res = await api('/api/v1/staging/files/retry-fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -167,7 +167,7 @@ async function handleRetryFetch() { async function handleInlineApplyFetched(fileId: number) { const file = items.value.find((f) => f.id === fileId) if (!file?.fetchedMetadata) return - const res = await api(`/api/staging/files/${fileId}`, { + const res = await api(`/api/v1/staging/files/${fileId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selectedMetadata: file.fetchedMetadata }), diff --git a/server/package.json b/server/package.json index 38f6c54f6..36838385c 100644 --- a/server/package.json +++ b/server/package.json @@ -26,8 +26,10 @@ }, "dependencies": { "7z-wasm": "^1.2.0", + "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.0.2", "@fastify/formbody": "^8.0.2", + "@fastify/helmet": "^13.0.2", "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", @@ -39,6 +41,7 @@ "@nestjs/platform-fastify": "^11.1.14", "@nestjs/platform-socket.io": "^11.1.14", "@nestjs/schedule": "^6.1.1", + "@nestjs/terminus": "^11.1.1", "@nestjs/websockets": "^11.1.14", "@parcel/watcher": "^2.5.6", "@projectx/types": "workspace:*", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index eba1f46df..2f0e697c7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -13,6 +13,7 @@ import { PermissionGuard } from './common/guards/permission.guard'; import { DbModule } from './db/db.module'; import { AnnotationModule } from './modules/annotation/annotation.module'; import { DashboardModule } from './modules/dashboard/dashboard.module'; +import { HealthModule } from './modules/health/health.module'; import { AppSettingsModule } from './modules/app-settings/app-settings.module'; import { CollectionModule } from './modules/collection/collection.module'; import { AuthModule } from './modules/auth/auth.module'; @@ -71,6 +72,7 @@ import { UserModule } from './modules/user/user.module'; BookmarkModule, AnnotationModule, DashboardModule, + HealthModule, CbzModule, ReaderPreferencesModule, StagingModule, diff --git a/server/src/common/filters/http-exception.filter.ts b/server/src/common/filters/http-exception.filter.ts index 13945972c..448602cc2 100644 --- a/server/src/common/filters/http-exception.filter.ts +++ b/server/src/common/filters/http-exception.filter.ts @@ -32,6 +32,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { message, path: request.url, timestamp: new Date().toISOString(), + requestId: request.id, }); } } diff --git a/server/src/db/db.module.ts b/server/src/db/db.module.ts index c62d97023..84b60ae18 100644 --- a/server/src/db/db.module.ts +++ b/server/src/db/db.module.ts @@ -13,7 +13,13 @@ export const DB = Symbol('DB'); provide: DB, inject: [ConfigService], useFactory: (config: ConfigService) => { - const pool = new Pool({ connectionString: config.get('db.url') }); + const pool = new Pool({ + connectionString: config.get('db.url'), + max: 20, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + statement_timeout: 30_000, + }); return drizzle(pool, { schema }); }, }, diff --git a/server/src/main.ts b/server/src/main.ts index 192b5a187..5a2c8cf61 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -14,11 +14,18 @@ import fastifyCookie from '@fastify/cookie'; import fastifyMultipart from '@fastify/multipart'; import fastifyRateLimit from '@fastify/rate-limit'; import fastifyStatic from '@fastify/static'; +import fastifyHelmet from '@fastify/helmet'; +import fastifyCompress from '@fastify/compress'; const MAX_COVER_BYTES = 20 * 1024 * 1024; async function bootstrap() { - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 3, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 5_000, + }); await migrate(drizzle(pool), { migrationsFolder: join(__dirname, '..', 'src', 'db', 'migrations') }); await pool.end(); @@ -26,9 +33,10 @@ async function bootstrap() { const app = await NestFactory.create(AppModule, adapter, { bufferLogs: true }); app.useLogger(app.get(Logger)); + const fastify = adapter.getInstance(); + // Kobo devices send Content-Type: application/json with empty bodies on GET/DELETE. // Fastify's default JSON parser rejects empty bodies, so we inject '{}' before parsing. - const fastify = adapter.getInstance(); fastify.addHook('preParsing', (request, _reply, payload, done) => { const ct = request.headers['content-type'] ?? ''; const isJson = ct.startsWith('application/json'); @@ -43,9 +51,18 @@ async function bootstrap() { done(null, payload); }); + // Echo pino-http's request ID so clients can correlate errors with server logs. + fastify.addHook('onSend', (_request, reply, _payload, done) => { + const id = reply.request.id; + if (id !== undefined && id !== null) { + void reply.header('X-Request-Id', String(id)); + } + done(); + }); + app.useWebSocketAdapter(new IoAdapter(app)); - app.setGlobalPrefix('api'); + app.setGlobalPrefix('api/v1'); app.useGlobalPipes( new ValidationPipe({ @@ -57,6 +74,23 @@ async function bootstrap() { app.useGlobalFilters(new GlobalExceptionFilter()); + await app.register(fastifyHelmet, { + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'blob:'], + connectSrc: ["'self'", 'ws:', 'wss:'], + fontSrc: ["'self'", 'data:'], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + }); + + await app.register(fastifyCompress, { encodings: ['gzip', 'br'] }); + await app.register(fastifyCookie); await app.register(fastifyMultipart, { limits: { fileSize: MAX_COVER_BYTES } }); @@ -67,7 +101,7 @@ async function bootstrap() { max: 100, timeWindow: '1 minute', allowList: (req: { cookies?: { access_token?: string }; headers?: { authorization?: string }; url?: string }) => - !!req.cookies?.access_token || !!req.headers?.authorization || /^\/api\/kobo\//.test(req.url ?? ''), + !!req.cookies?.access_token || !!req.headers?.authorization || /^\/api\/v1\/kobo\//.test(req.url ?? ''), }); if (process.env.NODE_ENV !== 'production') { diff --git a/server/src/modules/auth/oidc/oidc-discovery.service.ts b/server/src/modules/auth/oidc/oidc-discovery.service.ts index b698091b1..fe1b97484 100644 --- a/server/src/modules/auth/oidc/oidc-discovery.service.ts +++ b/server/src/modules/auth/oidc/oidc-discovery.service.ts @@ -42,7 +42,7 @@ export class OidcDiscoveryService { const url = `${normalized}/.well-known/openid-configuration`; try { - const res = await fetch(url); + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const raw = (await res.json()) as RawDiscoveryDoc; diff --git a/server/src/modules/auth/oidc/oidc-token-client.service.ts b/server/src/modules/auth/oidc/oidc-token-client.service.ts index 3fb8dcff3..d2c6403f0 100644 --- a/server/src/modules/auth/oidc/oidc-token-client.service.ts +++ b/server/src/modules/auth/oidc/oidc-token-client.service.ts @@ -39,6 +39,7 @@ export class OidcTokenClientService { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), + signal: AbortSignal.timeout(10_000), }); if (!res.ok) { @@ -60,6 +61,7 @@ export class OidcTokenClientService { try { const res = await fetch(userinfoEndpoint, { headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(10_000), }); if (!res.ok) return {}; return (await res.json()) as Promise>; diff --git a/server/src/modules/health/health.controller.ts b/server/src/modules/health/health.controller.ts new file mode 100644 index 000000000..82da93bd8 --- /dev/null +++ b/server/src/modules/health/health.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Inject } from '@nestjs/common'; +import { HealthCheck, HealthCheckService, HealthIndicatorResult } from '@nestjs/terminus'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { sql } from 'drizzle-orm'; + +import { Public } from '../../common/decorators/public.decorator'; +import { DB } from '../../db/db.module'; + +@Controller('health') +@Public() +export class HealthController { + constructor( + private readonly health: HealthCheckService, + @Inject(DB) private readonly db: NodePgDatabase, + ) {} + + @Get() + @HealthCheck() + check() { + return this.health.check([() => this.checkDatabase()]); + } + + private async checkDatabase(): Promise { + await this.db.execute(sql`SELECT 1`); + return { database: { status: 'up' } }; + } +} diff --git a/server/src/modules/health/health.module.ts b/server/src/modules/health/health.module.ts new file mode 100644 index 000000000..9ed184e8f --- /dev/null +++ b/server/src/modules/health/health.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; + +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/server/src/modules/kobo/kobo-user.controller.ts b/server/src/modules/kobo/kobo-user.controller.ts index 01c817a2c..7063b5fe4 100644 --- a/server/src/modules/kobo/kobo-user.controller.ts +++ b/server/src/modules/kobo/kobo-user.controller.ts @@ -9,7 +9,7 @@ import { UpdateSettingsDto } from './dto/update-settings.dto'; import { KoboDeviceService } from './services/kobo-device.service'; import { KoboSettingsService } from './services/kobo-settings.service'; -@Controller('v1/kobo') +@Controller('kobo') @RequirePermission('kobo_sync') export class KoboUserController { constructor( diff --git a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts index 0202a360c..975e1c660 100644 --- a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts +++ b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts @@ -88,7 +88,7 @@ export class AmazonProvider implements IdentifiableProvider { private async fetchHtml(url: string, cookie = ''): Promise { const headers: HeadersInit = cookie ? { ...HEADERS, cookie } : HEADERS; try { - const res = await fetch(url, { headers }); + const res = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) }); if (!res.ok) { this.logger.warn(`Amazon returned ${res.status} for ${url}`); return null; diff --git a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts index 1fe450756..042fc3b47 100644 --- a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts +++ b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts @@ -79,7 +79,7 @@ export class GoodreadsProvider implements IdentifiableProvider { private async fetchHtml(url: string): Promise { try { - const res = await fetch(url, { headers: HEADERS }); + const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15_000) }); if (!res.ok) { this.logger.warn(`Goodreads returned ${res.status} for ${url}`); return null; diff --git a/server/src/modules/metadata-fetch/providers/google/google.provider.ts b/server/src/modules/metadata-fetch/providers/google/google.provider.ts index 3dba89986..92eed5604 100644 --- a/server/src/modules/metadata-fetch/providers/google/google.provider.ts +++ b/server/src/modules/metadata-fetch/providers/google/google.provider.ts @@ -31,7 +31,7 @@ export class GoogleProvider implements IdentifiableProvider { const { enabled, apiKey } = await this.providerConfig.getConfig().then((c) => c.google); if (!enabled) return null; const url = this.buildUrl(`/volumes/${providerId}`, {}, apiKey); - const res = await fetch(url); + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) { this.logger.warn(`Google Books API returned ${res.status} for lookupById(${providerId})`); return null; @@ -50,7 +50,7 @@ export class GoogleProvider implements IdentifiableProvider { private async fetchVolumes(query: string, apiKey: string): Promise { const url = this.buildUrl('/volumes', { q: query, maxResults: '10', printType: 'books' }, apiKey); - const res = await fetch(url); + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) { this.logger.warn(`Google Books API returned ${res.status} for search("${query}")`); return []; diff --git a/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.ts b/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.ts index 562bb9127..1bea3561b 100644 --- a/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.ts +++ b/server/src/modules/metadata-fetch/providers/open-library/open-library.provider.ts @@ -27,7 +27,7 @@ export class OpenLibraryProvider implements IdentifiableProvider { query.set('limit', '10'); query.set('fields', SEARCH_FIELDS); - const res = await fetch(`${BASE_URL}/search.json?${query}`); + const res = await fetch(`${BASE_URL}/search.json?${query}`, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) return []; const body = (await res.json()) as OpenLibrarySearchResponse; @@ -37,7 +37,7 @@ export class OpenLibraryProvider implements IdentifiableProvider { async lookupById(providerId: string): Promise { const { enabled } = await this.providerConfig.getConfig().then((c) => c.openLibrary); if (!enabled) return null; - const res = await fetch(`${BASE_URL}/works/${providerId}.json`); + const res = await fetch(`${BASE_URL}/works/${providerId}.json`, { signal: AbortSignal.timeout(10_000) }); if (!res.ok) return null; const work = (await res.json()) as OpenLibraryWork; diff --git a/server/src/modules/opds/opds-user.controller.ts b/server/src/modules/opds/opds-user.controller.ts index fc00ea495..3c5bdee38 100644 --- a/server/src/modules/opds/opds-user.controller.ts +++ b/server/src/modules/opds/opds-user.controller.ts @@ -7,7 +7,7 @@ import { CreateOpdsUserDto } from './dto/create-opds-user.dto'; import { UpdateOpdsUserDto } from './dto/update-opds-user.dto'; import { OpdsUserService } from './opds-user.service'; -@Controller('v1/opds-users') +@Controller('opds-users') @RequirePermission('opds_access') export class OpdsUserController { constructor(private readonly opdsUserService: OpdsUserService) {} diff --git a/server/src/modules/opds/opds.controller.ts b/server/src/modules/opds/opds.controller.ts index 7540822ea..7143531cc 100644 --- a/server/src/modules/opds/opds.controller.ts +++ b/server/src/modules/opds/opds.controller.ts @@ -14,7 +14,7 @@ import { OpdsUser } from './opds-user.decorator'; import { OpdsBookService } from './opds-book.service'; import { OpdsService } from './opds.service'; -@Controller('v1/opds') +@Controller('opds') @Public() @UseGuards(OpdsEnabledGuard, OpdsAuthGuard) export class OpdsController {