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
This commit is contained in:
neon
2026-02-28 16:02:15 -06:00
parent f66bb0358f
commit 51dd3d30f3
87 changed files with 246 additions and 161 deletions
@@ -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()
}
</script>
+4 -4
View File
@@ -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({
+2 -2
View File
@@ -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()
}
+4 -4
View File
@@ -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({
+3 -3
View File
@@ -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'
@@ -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 }),
@@ -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 }),
@@ -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 }),
@@ -17,7 +17,7 @@ setOnAuthFailure(() => {
})
async function me(): Promise<void> {
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<void> {
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<void> {
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<void> {
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(() => ({}))
@@ -32,7 +32,7 @@ function generateNonce(): string {
export function useOidc() {
async function getPublicConfig(): Promise<OidcPublicConfig | null> {
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',
@@ -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 }),
@@ -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()
}
@@ -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()
}
@@ -3,7 +3,7 @@ import { api } from '@/lib/api'
export function useAuthorSearch() {
async function search(q: string): Promise<string[]> {
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)
@@ -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) {
@@ -33,7 +33,7 @@ export function useBookQuery(libraryId: Ref<number | null>) {
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),
@@ -53,7 +53,7 @@ export function useBooks(libraryId: Ref<number | null>) {
})
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()
@@ -41,10 +41,10 @@ export function useCoverEditor(bookId: Ref<number>) {
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<number>) {
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)
@@ -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
}
@@ -25,7 +25,7 @@ export function useGlobalSearch(query: Ref<string>) {
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 {
@@ -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),
@@ -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
@@ -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 {
@@ -26,7 +26,7 @@ export function useRefreshMetadata() {
async function callRefresh<T>(bookId: number, preview: boolean): Promise<T | null> {
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
@@ -3,7 +3,7 @@ import { api } from '@/lib/api'
function useMetadataSearch(endpoint: string) {
async function search(q: string): Promise<string[]> {
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)
@@ -20,7 +20,7 @@ export function useCollectionBooks(collectionId: Ref<number>) {
}
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]
@@ -6,19 +6,19 @@ const collections = ref<Collection[]>([])
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<Collection[]> {
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<Collection> {
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<Collection> {
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<Collection> {
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<Collection> {
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 }),
@@ -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 {
@@ -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),
@@ -22,7 +22,7 @@ export function useLens(lensId: Ref<number>) {
}
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()
@@ -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<Lens> {
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<CreateLensPayload>): Promise<Lens> {
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<void> {
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)
}
@@ -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 ?? []
@@ -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 {
@@ -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()
}
@@ -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] }),
@@ -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
@@ -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
@@ -6,7 +6,7 @@ const libraries = ref<Library[]>([])
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()
}
@@ -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),
@@ -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<Annotation | null> {
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) {
@@ -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) {
@@ -8,11 +8,11 @@ export function useCbz(fileId: number, bookId: number) {
const error = ref<string | null>(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<void> {
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
@@ -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 })
@@ -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,
@@ -14,7 +14,7 @@ export function useReaderProgress(bookId: number, fileId: number) {
let saveTimer: ReturnType<typeof setTimeout> | 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 }),
@@ -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<T extends ReaderSettings>(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<T extends ReaderSettings>(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<T extends ReaderSettings>(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(() => {})
}
}
@@ -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) {
@@ -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
@@ -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
@@ -33,7 +33,7 @@ const form = reactive<OidcConfig>({
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<OidcConfig> = { ...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}` }
@@ -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) }),
@@ -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 } }),
@@ -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) }),
@@ -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 }),
@@ -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')
@@ -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<ProviderConfigurations>) {
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),
@@ -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({
@@ -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'
@@ -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 }),
@@ -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 {
@@ -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<StagingMetadata>): Promise<StagingFile | null> {
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 }
@@ -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
@@ -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),
@@ -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
@@ -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
@@ -32,7 +32,7 @@ function uploadSingle(item: UploadItem): Promise<void> {
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}`)
+1 -1
View File
@@ -23,7 +23,7 @@ function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Respons
}
async function attemptRefresh(): Promise<void> {
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
+1 -1
View File
@@ -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 }),
+1 -1
View File
@@ -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 }),
+1 -1
View File
@@ -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 }),
+4 -4
View File
@@ -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 }),
+3
View File
@@ -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:*",
+2
View File
@@ -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,
@@ -32,6 +32,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
message,
path: request.url,
timestamp: new Date().toISOString(),
requestId: request.id,
});
}
}
+7 -1
View File
@@ -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 });
},
},
+38 -4
View File
@@ -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<NestFastifyApplication>(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') {
@@ -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;
@@ -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<Record<string, unknown>>;
@@ -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<HealthIndicatorResult> {
await this.db.execute(sql`SELECT 1`);
return { database: { status: 'up' } };
}
}
@@ -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 {}
@@ -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(
@@ -88,7 +88,7 @@ export class AmazonProvider implements IdentifiableProvider {
private async fetchHtml(url: string, cookie = ''): Promise<string | null> {
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;
@@ -79,7 +79,7 @@ export class GoodreadsProvider implements IdentifiableProvider {
private async fetchHtml(url: string): Promise<string | null> {
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;
@@ -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<MetadataCandidate[]> {
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 [];
@@ -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<MetadataCandidate | null> {
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;
@@ -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) {}
+1 -1
View File
@@ -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 {