diff --git a/next.config.ts b/next.config.ts index 46689e3..5179aeb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,30 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'self' https://*.huggingface.co https://huggingface.co", + }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, +]; + const nextConfig: NextConfig = { + async headers() { + return [ + { + // Apply security headers to all routes + source: '/(.*)', + headers: securityHeaders, + }, + ]; + }, turbopack: { resolveAlias: { canvas: '@napi-rs/canvas', diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 3bf2877..5ba5c1c 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -683,6 +683,16 @@ export function EPUBProvider({ children }: { children: ReactNode }) { if (!bookRef.current?.isOpen || !renditionRef.current) return; + // If the location is a CFI string that doesn't match the current rendered position, + // navigate there and let the subsequent locationChanged callback handle text extraction. + if (typeof location === 'string' && location !== 'next' && location !== 'prev' && renditionRef.current?.location) { + const currentStartCfi = renditionRef.current.location?.start?.cfi; + if (currentStartCfi && location !== currentStartCfi) { + renditionRef.current.display(location); + return; + } + } + // Handle special 'next' and 'prev' cases if (location === 'next' && renditionRef.current) { shouldPauseRef.current = false; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index bd82629..8b6b429 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -103,7 +103,7 @@ const createAuth = () => betterAuth({ expiresIn: 60 * 60 * 24 * 7, // 7 days (reasonable for user experience) updateAge: 60 * 60 * 1, // 1 hour (refresh more frequently) cookieCache: { - maxAge: 60 * 60 * 24 * 7, // 7 days for cookie cache + maxAge: 60 * 5, // 5 minutes – revalidate session against DB regularly }, }, plugins: [ diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..ffc5ae2 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * Better Auth session cookie name (default prefix + session_token). + * @see https://www.better-auth.com/docs/concepts/session-management + */ +const SESSION_COOKIE = 'better-auth.session_token'; + +/** + * Routes that never require a session cookie. + * Static assets and Next.js internals are excluded via the matcher config below. + */ +const PUBLIC_PATH_PREFIXES = [ + '/api/auth', // Better Auth endpoints (sign-in, sign-up, callbacks, etc.) + '/signin', + '/signup', + '/privacy', +]; + +function isPublicPath(pathname: string): boolean { + // Root landing page + if (pathname === '/') return true; + + return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + +function isAuthEnabled(): boolean { + return !!(process.env.AUTH_SECRET && process.env.BASE_URL); +} + +function isAnonymousAuthEnabled(): boolean { + if (!isAuthEnabled()) return false; + const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS; + return raw?.trim().toLowerCase() === 'true'; +} + +export function middleware(request: NextRequest) { + // When auth is disabled entirely, let everything through. + if (!isAuthEnabled()) { + return NextResponse.next(); + } + + const { pathname } = request.nextUrl; + + // Public routes are always accessible. + if (isPublicPath(pathname)) { + return NextResponse.next(); + } + + // When anonymous auth is enabled, unauthenticated users need to reach + // the page so AuthLoader.tsx can bootstrap an anonymous session client-side. + if (isAnonymousAuthEnabled()) { + return NextResponse.next(); + } + + // Check for the presence of a session cookie. + const hasSession = request.cookies.has(SESSION_COOKIE); + + if (!hasSession) { + // API routes get a 401 instead of a redirect. + if (pathname.startsWith('/api/')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Page routes redirect to sign-in. + const signInUrl = request.nextUrl.clone(); + signInUrl.pathname = '/signin'; + return NextResponse.redirect(signInUrl); + } + + return NextResponse.next(); +} + +/** + * Match all routes except static assets and Next.js internals. + */ +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon\\.ico|icon\\.png|icon\\.svg|apple-icon\\.png|manifest\\.json).*)', + ], +};