mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
+5
-5
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user