From 1f4794a706f06cdb5bf8c4eec29af043e5688960 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 15 Feb 2026 11:12:42 -0700 Subject: [PATCH] feat(auth): implement session gatekeeping and architectural reorganization - Introduce `USE_ANONYMOUS_AUTH_SESSIONS` to make guest access an opt-in feature. - Transition root-level pages into `(app)` and `(public)` route groups for improved access control. - Adopt Figtree as the primary typeface and implement a pre-render theme initialization script. - Decouple audiobook chapter processing into a standalone API route and harden resource ownership logic. - Replace the legacy footer with a unified layout and add skeleton loading states for document lists. - Update environment documentation and test suites to align with the revised routing and auth flows. BREAKING CHANGE: The audiobook generation API has been restructured, moving specific chapter logic to `/api/audiobook/chapter`. --- .env.example | 2 + .gitignore | 3 + docs-site/docs/configure/auth.md | 9 + .../docs/reference/environment-variables.md | 10 + playwright.config.ts | 2 +- src/app/{ => (app)/app}/page.tsx | 0 src/app/{ => (app)}/epub/[id]/page.tsx | 4 +- src/app/{ => (app)}/html/[id]/page.tsx | 4 +- src/app/(app)/layout.tsx | 62 +++ src/app/{ => (app)}/pdf/[id]/page.tsx | 4 +- src/app/{ => (app)}/signin/page.tsx | 28 +- src/app/{ => (app)}/signup/page.tsx | 4 +- src/app/(public)/layout.tsx | 327 ++++++++++++ src/app/(public)/page.tsx | 479 ++++++++++++++++++ src/app/(public)/privacy/page.tsx | 200 ++++++++ src/app/api/audiobook/chapter/route.ts | 437 +++++++++++++++- src/app/api/audiobook/route.ts | 271 ++-------- src/app/api/audiobook/status/route.ts | 26 +- src/app/globals.css | 6 +- src/app/layout.tsx | 113 ++--- src/app/providers.tsx | 34 +- src/components/AudiobookExportModal.tsx | 8 +- src/components/Footer.tsx | 56 -- src/components/HomeContent.tsx | 11 +- src/components/PrivacyModal.tsx | 43 +- src/components/SettingsModal.tsx | 24 +- src/components/auth/AuthLoader.tsx | 67 ++- src/components/auth/UserMenu.tsx | 5 +- src/components/doclist/DocumentList.tsx | 3 +- .../doclist/DocumentListSkeleton.tsx | 49 ++ src/contexts/AuthRateLimitContext.tsx | 14 +- src/lib/client.ts | 2 +- src/lib/server/audiobook-scope.ts | 25 + src/lib/server/auth-config.ts | 19 + src/lib/server/auth.ts | 134 ++--- tests/export.spec.ts | 10 +- tests/helpers.ts | 61 ++- tests/landing-routing.spec.ts | 53 ++ tests/unit/audiobook-scope.spec.ts | 36 ++ tests/unit/auth-config.spec.ts | 64 +++ 40 files changed, 2146 insertions(+), 563 deletions(-) rename src/app/{ => (app)/app}/page.tsx (100%) rename src/app/{ => (app)}/epub/[id]/page.tsx (99%) rename src/app/{ => (app)}/html/[id]/page.tsx (99%) create mode 100644 src/app/(app)/layout.tsx rename src/app/{ => (app)}/pdf/[id]/page.tsx (99%) rename src/app/{ => (app)}/signin/page.tsx (93%) rename src/app/{ => (app)}/signup/page.tsx (99%) create mode 100644 src/app/(public)/layout.tsx create mode 100644 src/app/(public)/page.tsx create mode 100644 src/app/(public)/privacy/page.tsx delete mode 100644 src/components/Footer.tsx create mode 100644 src/components/doclist/DocumentListSkeleton.tsx create mode 100644 src/lib/server/audiobook-scope.ts create mode 100644 tests/landing-routing.spec.ts create mode 100644 tests/unit/audiobook-scope.spec.ts create mode 100644 tests/unit/auth-config.spec.ts diff --git a/.env.example b/.env.example index 4e1a1ad..706aaf5 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ API_KEY=api_key_optional BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32` AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted) +# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`) +USE_ANONYMOUS_AUTH_SESSIONS= # (Optional) Sign in w/ GitHub Configuration GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index 38f5d2c..6d5cc68 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ node_modules/ # vscode .vscode + +# .agents +.agents diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index fff001b..c89400c 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -9,6 +9,15 @@ This page covers application-level configuration for provider access and authent - Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. - Remove either value to disable auth. - Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`. +- Anonymous auth sessions are disabled by default. +- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows. + +## Route behavior + +- `/` is a public landing/onboarding page and remains indexable. +- `/app` is the protected app home (document list and uploader UI). +- If auth is enabled and a valid session exists (including anonymous), visiting `/` redirects to `/app`. +- Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`. ## Related docs diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 85fa157..b97350a 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -28,6 +28,7 @@ This is the single reference page for OpenReader WebUI environment variables. | `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | | `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | +| `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to enable anonymous auth sessions | | `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in | | `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in | | `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting | @@ -201,6 +202,15 @@ Additional allowed origins for auth requests. - `BASE_URL` origin is always trusted automatically - Related docs: [Auth](../configure/auth) +### USE_ANONYMOUS_AUTH_SESSIONS + +Controls whether auth-enabled deployments can create/use anonymous sessions. + +- Default: `false` (anonymous sessions disabled) +- Set `true` to allow anonymous sessions and guest-style flows +- When `false`, users must sign in or sign up with an account +- Related docs: [Auth](../configure/auth) + ### GITHUB_CLIENT_ID GitHub OAuth client ID. diff --git a/playwright.config.ts b/playwright.config.ts index c7aa815..02c4d55 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { // Disable auth rate limiting for tests to support parallel workers creating sessions - command: 'npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start', + command: `npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start`, url: 'http://localhost:3003', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/src/app/page.tsx b/src/app/(app)/app/page.tsx similarity index 100% rename from src/app/page.tsx rename to src/app/(app)/app/page.tsx diff --git a/src/app/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx similarity index 99% rename from src/app/epub/[id]/page.tsx rename to src/app/(app)/epub/[id]/page.tsx index b2b4c97..f80e05f 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -144,7 +144,7 @@ export default function EPUBPage() {

{error}

clearCurrDoc()} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -162,7 +162,7 @@ export default function EPUBPage() {
clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" diff --git a/src/app/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx similarity index 99% rename from src/app/html/[id]/page.tsx rename to src/app/(app)/html/[id]/page.tsx index c9cfba0..f7922fd 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -113,7 +113,7 @@ export default function HTMLPage() {

{error}

{ clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -131,7 +131,7 @@ export default function HTMLPage() {
clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..b07c0c4 --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import { Toaster } from 'react-hot-toast'; + +import { Providers } from '@/app/providers'; +import ClaimDataPopup from '@/components/auth/ClaimDataModal'; +import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled } from '@/lib/server/auth-config'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + noimageindex: true, + 'max-snippet': 0, + 'max-video-preview': 0, + }, + }, +}; + +export default function AppLayout({ children }: { children: ReactNode }) { + const authEnabled = isAuthEnabled(); + const authBaseUrl = getAuthBaseUrl(); + const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled(); + + return ( + +
+ {authEnabled && } +
{children}
+
+ +
+ ); +} diff --git a/src/app/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx similarity index 99% rename from src/app/pdf/[id]/page.tsx rename to src/app/(app)/pdf/[id]/page.tsx index ff25cb8..45ca251 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -140,7 +140,7 @@ export default function PDFViewerPage() {

{error}

{ clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -158,7 +158,7 @@ export default function PDFViewerPage() {
clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" diff --git a/src/app/signin/page.tsx b/src/app/(app)/signin/page.tsx similarity index 93% rename from src/app/signin/page.tsx rename to src/app/(app)/signin/page.tsx index 5348213..c1a9962 100644 --- a/src/app/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -29,7 +29,7 @@ function SignInContent() { const [rememberMe, setRememberMe] = useState(true); const [sessionExpired, setSessionExpired] = useState(false); const [error, setError] = useState(null); - const { authEnabled, baseUrl } = useAuthConfig(); + const { authEnabled, baseUrl, allowAnonymousAuthSessions } = useAuthConfig(); const { refresh: refreshRateLimit } = useAuthRateLimit(); const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous; @@ -37,7 +37,7 @@ function SignInContent() { // Check if auth is enabled, redirect home if not useEffect(() => { if (!authEnabled) { - router.push('/'); + router.push('/app'); } }, [router, authEnabled]); @@ -79,7 +79,7 @@ function SignInContent() { // Immediately refresh rate-limit status so the banner clears without a full reload. // This is especially important when an anonymous user upgrades to an account. await refreshRateLimit(); - router.push('/'); + router.push('/app'); } } catch (err) { console.error('Sign in error:', err); @@ -95,7 +95,7 @@ function SignInContent() { const client = getAuthClient(baseUrl); await client.signIn.social({ provider: 'github', - callbackURL: '/' + callbackURL: '/app' }); } finally { setLoadingGithub(false); @@ -109,7 +109,7 @@ function SignInContent() { const client = getAuthClient(baseUrl); await client.signIn.anonymous(); await refreshRateLimit(); - router.push('/'); + router.push('/app'); } catch (e) { console.error('Anonymous sign-in failed:', e); setError('Unable to continue anonymously. Please try again.'); @@ -226,17 +226,19 @@ function SignInContent() { {/* Anonymous */} - + > + {loadingAnonymous ? : 'Continue anonymously'} + + )}
{/* Footer */} diff --git a/src/app/signup/page.tsx b/src/app/(app)/signup/page.tsx similarity index 99% rename from src/app/signup/page.tsx rename to src/app/(app)/signup/page.tsx index d18b0c2..6bc4efe 100644 --- a/src/app/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -24,7 +24,7 @@ export default function SignUpPage() { // Check if auth is enabled, redirect home if not useEffect(() => { if (!authEnabled) { - router.push('/'); + router.push('/app'); } }, [router, authEnabled]); @@ -87,7 +87,7 @@ export default function SignUpPage() { } else { await refreshRateLimit(); toast.success('Account created successfully!'); - router.push('/'); + router.push('/app'); } } } catch (err) { diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx new file mode 100644 index 0000000..b998741 --- /dev/null +++ b/src/app/(public)/layout.tsx @@ -0,0 +1,327 @@ +import type { ReactNode } from 'react'; +import Link from 'next/link'; + +export default function PublicLayout({ children }: { children: ReactNode }) { + return ( + <> + + +
+ {/* ── Ambient orbs ────────────── */} +