mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(client): add native image loading via cookie and sonner toasts
This commit is contained in:
+4
-3
@@ -16,9 +16,9 @@
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@projectx/types": "workspace:*",
|
||||
"@fontsource-variable/fraunces": "^5.2.9",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@projectx/types": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -31,12 +31,13 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.3",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^22.19.5",
|
||||
"@types/node": "^22.19.6",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/eslint-plugin": "^1.6.6",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
@@ -50,7 +51,7 @@
|
||||
"jsdom": "^27.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.39.0",
|
||||
"prettier": "3.7.4",
|
||||
"prettier": "3.8.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
useThemeStore()
|
||||
const { isOpen } = useChangePasswordDialog()
|
||||
@@ -10,4 +11,5 @@ const { isOpen } = useChangePasswordDialog()
|
||||
<template>
|
||||
<RouterView />
|
||||
<ChangePasswordDialog v-if="isOpen" />
|
||||
<Toaster rich-colors />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Library, BookMarked, Clock, BookOpen, Settings, LogOut, KeyRound } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import { BookMarked, Clock, BookOpen, Settings, LogOut, KeyRound } from 'lucide-vue-next'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -18,18 +20,26 @@ import {
|
||||
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
|
||||
const { open: openSettings } = useSettingsDrawer()
|
||||
const { open: openChangePassword } = useChangePasswordDialog()
|
||||
const { user, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
|
||||
const navItems = [
|
||||
{ id: 'library', label: 'All Books', icon: Library, active: true },
|
||||
{ id: 'reading', label: 'Reading', icon: BookOpen, active: false },
|
||||
{ id: 'bookmarks', label: 'Bookmarks', icon: BookMarked, active: false },
|
||||
{ id: 'recent', label: 'Recently Added', icon: Clock, active: false },
|
||||
]
|
||||
const activeLibraryId = computed(() => {
|
||||
const id = route.params.id
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
|
||||
function getLibraryIcon(name: string | null | undefined) {
|
||||
if (name && name in Icons) return (Icons as Record<string, unknown>)[name]
|
||||
return Icons.BookCopy
|
||||
}
|
||||
|
||||
onMounted(fetchLibraries)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,23 +78,64 @@ const navItems = [
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<!-- Libraries -->
|
||||
<SidebarGroup class="pt-2">
|
||||
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
|
||||
Library
|
||||
Libraries
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in navItems" :key="item.id">
|
||||
<SidebarMenuButton :is-active="item.active" :tooltip="item.label" class="gap-2.5">
|
||||
<component :is="item.icon" :size="15" :class="item.active ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'" />
|
||||
<span :class="item.active ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
|
||||
{{ item.label }}
|
||||
<SidebarMenuItem v-for="lib in libraries" :key="lib.id">
|
||||
<SidebarMenuButton
|
||||
:is-active="activeLibraryId === lib.id"
|
||||
:tooltip="lib.name"
|
||||
class="gap-2.5"
|
||||
@click="router.push({ name: 'library', params: { id: lib.id } })"
|
||||
>
|
||||
<component
|
||||
:is="getLibraryIcon(lib.icon)"
|
||||
:size="15"
|
||||
:class="activeLibraryId === lib.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'"
|
||||
/>
|
||||
<span :class="activeLibraryId === lib.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
|
||||
{{ lib.name }}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
|
||||
|
||||
<!-- Browse -->
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
|
||||
Browse
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Reading" class="gap-2.5 opacity-40 cursor-not-allowed">
|
||||
<BookOpen :size="15" class="text-sidebar-foreground/50" />
|
||||
<span class="text-sidebar-foreground/70">Reading</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Bookmarks" class="gap-2.5 opacity-40 cursor-not-allowed">
|
||||
<BookMarked :size="15" class="text-sidebar-foreground/50" />
|
||||
<span class="text-sidebar-foreground/70">Bookmarks</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Recently Added" class="gap-2.5 opacity-40 cursor-not-allowed">
|
||||
<Clock :size="15" class="text-sidebar-foreground/50" />
|
||||
<span class="text-sidebar-foreground/70">Recently Added</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter class="border-t border-sidebar-border">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-sonner/style.css'
|
||||
import { Toaster as Sonner } from 'vue-sonner'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sonner
|
||||
class="toaster group"
|
||||
v-bind="$attrs"
|
||||
:toast-options="{
|
||||
classes: {
|
||||
toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from './Sonner.vue'
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Book, BookFile } from '../composables/useBooks'
|
||||
import { bookCoverStyle } from '../composables/useBooks'
|
||||
import { useBookCover } from '../composables/useBookCover'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -21,10 +20,8 @@ const primaryFile = computed(() => props.book.files.find((f) => f.role === 'prim
|
||||
// Only show format chips when there are multiple files
|
||||
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : []))
|
||||
|
||||
const { src: coverUrl, failed: coverFailed, load: loadCover } = useBookCover(props.book.id, 'thumbnail')
|
||||
const coverLoaded = computed(() => coverUrl.value !== null)
|
||||
|
||||
onMounted(loadCover)
|
||||
const coverLoaded = ref(false)
|
||||
const coverFailed = ref(false)
|
||||
|
||||
function openFile(file: BookFile) {
|
||||
router.push({
|
||||
@@ -47,7 +44,16 @@ function openFile(file: BookFile) {
|
||||
style="aspect-ratio: 2/3"
|
||||
:style="coverLoaded ? {} : coverStyle"
|
||||
>
|
||||
<img v-if="coverUrl && !coverFailed" :src="coverUrl" class="absolute inset-0 w-full h-full object-cover" :alt="book.title ?? ''" />
|
||||
<img
|
||||
v-if="!coverFailed"
|
||||
:src="`/api/books/${book.id}/thumbnail`"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200"
|
||||
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
loading="lazy"
|
||||
:alt="book.title ?? ''"
|
||||
@load="coverLoaded = true"
|
||||
@error="coverFailed = true"
|
||||
/>
|
||||
|
||||
<!-- Series badge -->
|
||||
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5">
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useBookCover } from '../composables/useBookCover'
|
||||
|
||||
const props = defineProps<{
|
||||
bookId: number
|
||||
type?: 'thumbnail' | 'cover'
|
||||
class?: string
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const { src, failed, load } = useBookCover(props.bookId, props.type ?? 'thumbnail')
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="src && !failed" :src="src" :class="props.class" :alt="props.alt ?? ''" />
|
||||
<div v-else :class="props.class" class="bg-muted" />
|
||||
<img :src="`/api/books/${props.bookId}/${props.type ?? 'thumbnail'}`" :class="props.class" :alt="props.alt ?? ''" loading="lazy" />
|
||||
</template>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function useBookCover(bookId: number, type: 'thumbnail' | 'cover' = 'thumbnail') {
|
||||
const src = ref<string | null>(null)
|
||||
const failed = ref(false)
|
||||
let objectUrl: string | null = null
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await api(`/api/books/${bookId}/${type}`)
|
||||
if (!res.ok) {
|
||||
failed.value = true
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
src.value = objectUrl
|
||||
} catch {
|
||||
failed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||
})
|
||||
|
||||
return { src, failed, load }
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, shallowRef, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { X, ChevronLeft, ChevronRight, Check, Info, FolderOpen, ScanLine, Clock, Users } from 'lucide-vue-next'
|
||||
import type { Library } from '@projectx/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useLibraryCreator } from '../composables/useLibraryCreator'
|
||||
import LibraryCreatorDetails from './LibraryCreatorDetails.vue'
|
||||
import LibraryCreatorFolders from './LibraryCreatorFolders.vue'
|
||||
@@ -76,9 +77,11 @@ const mobileView = ref<'nav' | 'content'>('nav')
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (props.library) {
|
||||
creator.initEdit(props.library)
|
||||
const res = await api(`/api/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
|
||||
} else {
|
||||
creator.initCreate()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Library } from '@projectx/types'
|
||||
|
||||
const libraries = ref<Library[]>([])
|
||||
|
||||
export function useLibraries() {
|
||||
async function fetchLibraries() {
|
||||
const res = await api('/api/libraries')
|
||||
if (!res.ok) return
|
||||
libraries.value = await res.json()
|
||||
}
|
||||
|
||||
return { libraries, fetchLibraries }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { FolderOpen, Plus, RefreshCw, Pencil, Library } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Library as LibraryType, LibraryStats } from '@projectx/types'
|
||||
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
|
||||
@@ -34,7 +35,14 @@ onMounted(loadLibraries)
|
||||
async function scan(lib: LibraryType) {
|
||||
scanning.value[lib.id] = true
|
||||
try {
|
||||
await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
|
||||
const res = await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
toast.success(`Scan started for "${lib.name}"`)
|
||||
} else {
|
||||
toast.error(`Failed to start scan for "${lib.name}"`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(`Failed to start scan for "${lib.name}"`)
|
||||
} finally {
|
||||
scanning.value[lib.id] = false
|
||||
}
|
||||
@@ -43,7 +51,17 @@ async function scan(lib: LibraryType) {
|
||||
async function scanAll() {
|
||||
scanningAll.value = true
|
||||
try {
|
||||
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/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')
|
||||
} else {
|
||||
toast.error(`${failed} librar${failed === 1 ? 'y' : 'ies'} failed to start`)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to start scans')
|
||||
} finally {
|
||||
scanningAll.value = false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ const router = createRouter({
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/library/:id',
|
||||
name: 'library',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/read/:bookId/:fileId',
|
||||
name: 'reader',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
@@ -9,28 +9,45 @@ import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import { useBooks } from '@/features/book/composables/useBooks'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const themeStore = useThemeStore()
|
||||
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
|
||||
const { coverSize, gridGap, viewMode } = useDisplaySettings()
|
||||
const libraryId = ref<number | null>(null)
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
|
||||
async function loadLibrary() {
|
||||
const res = await api('/api/libraries')
|
||||
if (!res.ok) return
|
||||
const libs: { id: number }[] = await res.json()
|
||||
if (libs.length > 0) libraryId.value = libs[0]!.id
|
||||
}
|
||||
const libraryId = computed<number | null>(() => {
|
||||
const id = route.params.id
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
|
||||
const title = computed(() => libraries.value.find((l) => l.id === libraryId.value)?.name ?? 'Library')
|
||||
|
||||
const { books, total, loading, error, search, hasMore, load, onSearch } = useBooks(libraryId)
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
function loadIfSentinelVisible() {
|
||||
if (!hasMore.value || loading.value) return
|
||||
const el = sentinel.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.top < window.innerHeight + 300) load()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLibrary()
|
||||
load()
|
||||
await fetchLibraries()
|
||||
|
||||
if (!libraryId.value && libraries.value.length > 0) {
|
||||
router.replace({ name: 'library', params: { id: libraries.value[0]!.id } })
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !loading.value) load()
|
||||
@@ -42,6 +59,17 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => observer?.disconnect())
|
||||
|
||||
watch(libraryId, (newId, oldId) => {
|
||||
if (newId !== null && newId !== oldId) {
|
||||
search.value = ''
|
||||
load(true)
|
||||
}
|
||||
})
|
||||
|
||||
watch(loading, (isLoading) => {
|
||||
if (!isLoading) loadIfSentinelVisible()
|
||||
})
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(search, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
@@ -56,7 +84,7 @@ watch(search, () => {
|
||||
|
||||
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
|
||||
<AppHeader
|
||||
title="Library"
|
||||
:title="title"
|
||||
:total="total"
|
||||
:loaded="books.length"
|
||||
v-model:search="search"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1768464558856,
|
||||
"when": 1768503997010,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
+2
-1
@@ -28,10 +28,11 @@ async function bootstrap() {
|
||||
|
||||
await app.register(require('@fastify/cookie'));
|
||||
|
||||
// Global IP-based rate limit; tighten per-route as needed
|
||||
// Global IP-based rate limit; image/cover endpoints are exempt (high volume, cheap reads)
|
||||
await app.register(require('@fastify/rate-limit'), {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
allowList: (req: { url: string }) => /^\/api\/books\/\d+\/(thumbnail|cover)/.test(req.url),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
||||
@@ -107,6 +107,7 @@ export class AuthService {
|
||||
const fullUser = await this.userService.findByIdWithRolesAndPermissions(user.id);
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(user.id, user.tokenVersion);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
this.setAccessCookie(reply, accessToken);
|
||||
|
||||
return { accessToken, user: this.buildUserResponse(fullUser!) };
|
||||
}
|
||||
@@ -141,6 +142,7 @@ export class AuthService {
|
||||
if (!user || !user.active) throw new UnauthorizedException();
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(userId, user.tokenVersion);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
this.setAccessCookie(reply, accessToken);
|
||||
return { accessToken, user: this.buildUserResponse(user) };
|
||||
}
|
||||
|
||||
@@ -175,6 +177,7 @@ export class AuthService {
|
||||
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
this.setAccessCookie(reply, accessToken);
|
||||
|
||||
return { accessToken };
|
||||
}
|
||||
@@ -196,6 +199,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
this.clearRefreshCookie(reply);
|
||||
this.clearAccessCookie(reply);
|
||||
|
||||
if (!userId) return {};
|
||||
|
||||
@@ -314,6 +318,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
this.clearRefreshCookie(reply);
|
||||
this.clearAccessCookie(reply);
|
||||
}
|
||||
|
||||
private async issueTokenPair(userId: number, tokenVersion: number) {
|
||||
@@ -350,4 +355,25 @@ export class AuthService {
|
||||
secure: this.config.get('app.nodeEnv') === 'production',
|
||||
});
|
||||
}
|
||||
|
||||
private setAccessCookie(reply: FastifyReply, accessToken: string) {
|
||||
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtExpiresIn') ?? '15m') / 1000;
|
||||
reply.setCookie('access_token', accessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
maxAge: ttlSeconds,
|
||||
secure: this.config.get('app.nodeEnv') === 'production',
|
||||
});
|
||||
}
|
||||
|
||||
private clearAccessCookie(reply: FastifyReply) {
|
||||
reply.setCookie('access_token', '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
maxAge: 0,
|
||||
secure: this.config.get('app.nodeEnv') === 'production',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
(req: any) => req?.cookies?.access_token ?? null,
|
||||
]),
|
||||
secretOrKey: config.get<string>('auth.jwtSecret')!,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export class BookController {
|
||||
|
||||
const ext = coverPath.split('.').pop()?.toLowerCase();
|
||||
const contentType = ext === 'png' ? 'image/png' : 'image/jpeg';
|
||||
reply.header('Cache-Control', 'private, max-age=86400');
|
||||
reply.type(contentType);
|
||||
reply.send(createReadStream(coverPath));
|
||||
}
|
||||
@@ -33,6 +34,7 @@ export class BookController {
|
||||
const thumbnailPath = await this.bookService.getThumbnailPath(id, user);
|
||||
if (!thumbnailPath) throw new NotFoundException(`No thumbnail for book ${id}`);
|
||||
|
||||
reply.header('Cache-Control', 'private, max-age=86400');
|
||||
reply.type('image/jpeg');
|
||||
reply.send(createReadStream(thumbnailPath));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user