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`.
This commit is contained in:
Richard R 2026-02-15 11:12:42 -07:00
parent 9f25e05cce
commit 1f4794a706
40 changed files with 2146 additions and 563 deletions

View file

@ -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=

3
.gitignore vendored
View file

@ -54,3 +54,6 @@ node_modules/
# vscode
.vscode
# .agents
.agents

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -144,7 +144,7 @@ export default function EPUBPage() {
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
href="/app"
onClick={() => 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() {
<Header
left={
<Link
href="/"
href="/app"
onClick={() => 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"

View file

@ -113,7 +113,7 @@ export default function HTMLPage() {
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
href="/app"
onClick={() => { 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() {
<Header
left={
<Link
href="/"
href="/app"
onClick={() => 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"

62
src/app/(app)/layout.tsx Normal file
View file

@ -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 (
<Providers
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
>
<div className="app-shell min-h-screen flex flex-col bg-background">
{authEnabled && <ClaimDataPopup />}
<main className="flex-1 flex flex-col">{children}</main>
</div>
<Toaster
toastOptions={{
style: {
background: 'var(--offbase)',
color: 'var(--foreground)',
},
success: {
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
},
error: {
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
},
}}
/>
</Providers>
);
}

View file

@ -140,7 +140,7 @@ export default function PDFViewerPage() {
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
href="/app"
onClick={() => { 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() {
<Header
left={
<Link
href="/"
href="/app"
onClick={() => 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"

View file

@ -29,7 +29,7 @@ function SignInContent() {
const [rememberMe, setRememberMe] = useState(true);
const [sessionExpired, setSessionExpired] = useState(false);
const [error, setError] = useState<string | null>(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() {
</Button>
{/* Anonymous */}
<Button
type="button"
disabled={isAnyLoading}
onClick={handleAnonymousContinue}
className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground
{allowAnonymousAuthSessions && (
<Button
type="button"
disabled={isAnyLoading}
onClick={handleAnonymousContinue}
className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground
hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent
focus:ring-offset-2 disabled:opacity-50 border border-offbase
transform transition-transform duration-200 hover:scale-[1.02]"
>
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
</Button>
>
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
</Button>
)}
</div>
{/* Footer */}

View file

@ -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) {

327
src/app/(public)/layout.tsx Normal file
View file

@ -0,0 +1,327 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
export default function PublicLayout({ children }: { children: ReactNode }) {
return (
<>
<style>{`
/* ── Keyframes ───────────────────── */
@keyframes landing-drift {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(15px, -10px) scale(1.03); }
66% { transform: translate(-10px, 8px) scale(0.97); }
}
@keyframes landing-fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes landing-scale-in {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
/* ── Root ────────────────────────── */
.landing {
--g-display: var(--font-display), system-ui, -apple-system, sans-serif;
--g-body: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--g-system: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--g-bg: var(--background);
--g-fg: var(--foreground);
--g-surface: var(--base);
--g-border: var(--offbase);
--g-accent: var(--accent);
--g-accent2: var(--secondary-accent);
--g-muted: var(--muted);
font-family: var(--g-body);
background: var(--g-bg);
color: var(--g-fg);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
/* ── Ambient orbs ────────────────── */
.landing-orbs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.landing-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.12;
animation: landing-drift 20s ease-in-out infinite;
}
.landing-orb-1 {
width: 500px; height: 500px;
background: var(--g-accent);
top: -10%; left: -8%;
animation-delay: 0s;
}
.landing-orb-2 {
width: 400px; height: 400px;
background: var(--g-accent2);
top: 40%; right: -12%;
animation-delay: -7s;
}
.landing-orb-3 {
width: 350px; height: 350px;
background: var(--g-accent);
bottom: -5%; left: 30%;
animation-delay: -14s;
}
/* ── Content wrapper ─────────────── */
.landing-content {
position: relative;
z-index: 1;
}
/* ── Glass panel ─────────────────── */
.landing-panel {
background: color-mix(in srgb, var(--g-surface), transparent 30%);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid color-mix(in srgb, var(--g-border), transparent 50%);
border-radius: 1.25rem;
}
/* ── Sticky header wrapper ───────── */
.landing-header-wrap {
position: sticky;
top: 0;
z-index: 100;
padding: 1rem 1.5rem 0;
max-width: 72rem;
margin: 0 auto;
animation: landing-fade-up 0.6s ease-out both;
}
/* ── Header ──────────────────────── */
.landing-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
box-shadow: 0 2px 16px color-mix(in srgb, var(--g-bg), transparent 40%);
}
.landing-logo {
font-family: var(--g-display);
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--g-fg);
}
.landing-logo-icon {
width: 20px;
height: 20px;
}
.landing-header-nav {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.landing-header-nav a {
font-family: var(--g-system);
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--g-border);
background: var(--g-surface);
text-decoration: none;
color: var(--g-fg);
transform: translateZ(0);
transition: all 200ms ease-in-out;
}
.landing-header-nav a:hover {
background: var(--g-border);
transform: scale(1.09);
color: var(--g-accent);
}
.landing-header-nav a:focus {
outline: none;
box-shadow: 0 0 0 2px var(--g-accent), 0 0 0 4px var(--g-bg);
}
.landing-header-nav a.landing-primary {
background: var(--g-accent);
color: var(--g-bg);
border-color: var(--g-accent);
font-weight: 500;
}
.landing-header-nav a.landing-primary:hover {
background: var(--g-accent2);
border-color: var(--g-accent2);
color: var(--g-bg);
transform: scale(1.09);
}
/* ── Buttons ─────────────────────── */
.landing-btn {
font-family: var(--g-system);
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transform: translateZ(0);
transition: transform 200ms ease-in-out, background 200ms, box-shadow 200ms;
}
.landing-btn:hover {
transform: scale(1.02);
}
.landing-btn:focus {
outline: none;
box-shadow: 0 0 0 2px var(--g-accent), 0 0 0 4px var(--g-bg);
}
.landing-btn-accent {
background: var(--g-accent);
color: var(--g-bg);
}
.landing-btn-accent:hover {
background: var(--g-accent2);
}
.landing-btn-ghost {
color: var(--g-fg);
background: var(--g-surface);
border: 1px solid var(--g-border);
}
.landing-btn-ghost:hover {
background: var(--g-border);
color: var(--g-accent);
}
/* ── Footer ──────────────────────── */
.landing-footer {
text-align: center;
padding: 2rem 1.5rem 3rem;
font-family: var(--g-display);
font-size: 0.75rem;
font-weight: 500;
color: var(--g-muted);
letter-spacing: 0.05em;
position: relative;
z-index: 1;
}
.landing-footer-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem 0;
}
.landing-footer-dot {
display: inline-block;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--g-accent);
margin: 0 0.6rem;
vertical-align: middle;
opacity: 0.6;
}
.landing-footer a {
color: var(--g-muted);
text-decoration: none;
transition: color 0.2s;
}
.landing-footer a:hover {
color: var(--g-fg);
}
.landing-footer-link-dotted {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.landing-footer-link-bold {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 600;
}
`}</style>
<div className="landing">
{/* ── Ambient orbs ────────────── */}
<div className="landing-orbs" aria-hidden="true">
<div className="landing-orb landing-orb-1" />
<div className="landing-orb landing-orb-2" />
<div className="landing-orb landing-orb-3" />
</div>
<div className="landing-content">
{/* ── HEADER ─────────────────── */}
<div className="landing-header-wrap">
<header className="landing-header landing-panel">
<Link href="/" className="landing-logo">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="landing-logo-icon" aria-hidden="true" />
OpenReader
</Link>
<nav className="landing-header-nav">
<Link href="/app" className="landing-primary">Open App</Link>
<Link href="/signin">Sign In</Link>
<Link href="/signup">Sign Up</Link>
<Link href="https://docs.openreader.richardr.dev/">Docs</Link>
</nav>
</header>
</div>
{/* ── PAGE CONTENT ───────────── */}
{children}
{/* ── FOOTER ─────────────────── */}
<footer className="landing-footer">
<div className="landing-footer-inner">
<a
href="https://github.com/richardr1126/OpenReader-WebUI#readme"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-bold"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z"/></svg>
Self host
</a>
<span className="landing-footer-dot" />
<Link href="/privacy" className="landing-footer-link-bold">
Privacy
</Link>
<span className="landing-footer-dot" />
<span>
Powered by{' '}
<a
href="https://huggingface.co/hexgrad/Kokoro-82M"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-dotted"
>
hexgrad/Kokoro-82M
</a>
{' '}and{' '}
<a
href="https://deepinfra.com/models?type=text-to-speech"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-dotted"
>
Deepinfra
</a>
</span>
</div>
</footer>
</div>
</div>
</>
);
}

479
src/app/(public)/page.tsx Normal file
View file

@ -0,0 +1,479 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/server/auth';
import { isAuthEnabled } from '@/lib/server/auth-config';
export const metadata: Metadata = {
title: 'Read and Listen to Documents',
description:
'OpenReader lets you upload EPUB, PDF, TXT, MD, and DOCX files for synchronized text-to-speech reading with multi-provider TTS support.',
keywords:
'PDF reader, EPUB reader, text to speech, tts open ai, kokoro tts, OpenReader, TTS PDF reader, ebook reader, epub tts, document reader',
alternates: {
canonical: '/',
},
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://openreader.richardr.dev',
siteName: 'OpenReader WebUI',
title: 'OpenReader WebUI | Read and Listen to Documents',
description:
'Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with synchronized read-along playback using OpenAI-compatible TTS providers.',
images: [
{
url: '/web-app-manifest-512x512.png',
width: 512,
height: 512,
alt: 'OpenReader WebUI Logo',
},
],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
export const dynamic = 'force-dynamic';
export default async function LandingPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const params = await searchParams;
const skipRedirect = params.redirect === 'false';
if (!skipRedirect && isAuthEnabled() && auth) {
const session = await auth.api.getSession({ headers: await headers() });
if (session?.user) {
redirect('/app');
}
}
return (
<>
<style>{`
/* ── Hero ────────────────────────── */
.landing-hero {
max-width: 72rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
text-align: center;
animation: landing-fade-up 0.8s ease-out 0.15s both;
}
.landing-hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--g-display);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--g-accent);
padding: 0.4rem 1rem;
border-radius: 2rem;
border: 1px solid color-mix(in srgb, var(--g-accent), transparent 70%);
background: color-mix(in srgb, var(--g-accent), transparent 92%);
margin-bottom: 2rem;
}
.landing-hero h1 {
font-family: var(--g-display);
font-weight: 800;
font-size: clamp(2rem, 5.5vw, 4rem);
line-height: 1.1;
letter-spacing: -0.03em;
max-width: 18ch;
margin: 0 auto 1.5rem;
}
.landing-hero h1 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.landing-hero-desc {
font-family: var(--g-body);
font-size: 1.1rem;
line-height: 1.7;
color: var(--g-muted);
max-width: 52ch;
margin: 0 auto 2.5rem;
}
.landing-hero-actions {
display: flex;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
}
/* ── Features ────────────────────── */
.landing-features {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
@media (min-width: 640px) {
.landing-features {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1024px) {
.landing-features {
grid-template-columns: 1fr 1fr 1fr;
}
}
.landing-feature-card {
padding: 2rem;
animation: landing-scale-in 0.6s ease-out both;
transition: transform 0.3s, border-color 0.3s;
}
.landing-feature-card:nth-child(1) { animation-delay: 0.3s; }
.landing-feature-card:nth-child(2) { animation-delay: 0.4s; }
.landing-feature-card:nth-child(3) { animation-delay: 0.5s; }
.landing-feature-card:hover {
transform: translateY(-4px);
border-color: color-mix(in srgb, var(--g-accent), transparent 50%);
}
.landing-feature-icon {
width: 3rem; height: 3rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--g-accent), transparent 85%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
font-size: 1.25rem;
color: var(--g-accent);
font-weight: 700;
font-family: var(--g-display);
}
.landing-feature-card h3 {
font-family: var(--g-display);
font-weight: 700;
font-size: 1.15rem;
margin: 0 0 0.6rem;
letter-spacing: -0.01em;
}
.landing-feature-card p {
font-size: 0.92rem;
line-height: 1.65;
color: var(--g-muted);
}
/* ── TTS spotlight ────────────────── */
.landing-tts {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
animation: landing-fade-up 0.7s ease-out 0.55s both;
}
.landing-tts-inner {
display: grid;
grid-template-columns: 1fr;
gap: 2.5rem;
padding: 2.5rem;
}
@media (min-width: 768px) {
.landing-tts-inner {
grid-template-columns: 1fr 1fr;
}
}
.landing-tts-lead h2 {
font-family: var(--g-display);
font-weight: 700;
font-size: clamp(1.4rem, 3vw, 2rem);
letter-spacing: -0.02em;
margin: 0 0 0.75rem;
line-height: 1.2;
}
.landing-tts-lead h2 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.landing-tts-lead > p {
font-size: 0.95rem;
line-height: 1.7;
color: var(--g-muted);
}
.landing-tts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.landing-tts-list li {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.landing-tts-list-icon {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--g-accent), transparent 85%);
display: flex;
align-items: center;
justify-content: center;
color: var(--g-accent);
font-size: 0.85rem;
font-weight: 700;
font-family: var(--g-display);
margin-top: 0.1rem;
}
.landing-tts-list h4 {
font-family: var(--g-display);
font-weight: 600;
font-size: 0.92rem;
margin: 0 0 0.2rem;
}
.landing-tts-list p {
font-size: 0.84rem;
line-height: 1.55;
color: var(--g-muted);
margin: 0;
}
/* ── Formats ribbon ──────────────── */
.landing-formats {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
text-align: center;
animation: landing-fade-up 0.7s ease-out 1s both;
}
.landing-formats-label {
font-family: var(--g-display);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--g-muted);
margin-bottom: 1.25rem;
}
.landing-formats-row {
display: flex;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.landing-format-pill {
font-family: var(--g-display);
font-weight: 600;
font-size: 0.82rem;
padding: 0.5rem 1.25rem;
border-radius: 2rem;
background: color-mix(in srgb, var(--g-surface), transparent 30%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--g-border), transparent 50%);
transition: border-color 0.25s, transform 0.2s;
cursor: default;
}
.landing-format-pill:hover {
border-color: var(--g-accent);
transform: translateY(-2px);
}
/* ── CTA section ─────────────────── */
.landing-cta {
max-width: 48rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
animation: landing-fade-up 0.7s ease-out 1.1s both;
}
.landing-cta-card {
padding: 3rem 2rem;
text-align: center;
position: relative;
overflow: hidden;
}
.landing-cta-glow {
position: absolute;
width: 200px; height: 200px;
border-radius: 50%;
background: var(--g-accent);
filter: blur(80px);
opacity: 0.08;
top: -50px; right: -50px;
pointer-events: none;
}
.landing-cta-card h2 {
font-family: var(--g-display);
font-weight: 700;
font-size: clamp(1.4rem, 3vw, 2rem);
letter-spacing: -0.02em;
margin: 0 0 0.6rem;
position: relative;
}
.landing-cta-card > p {
font-size: 0.95rem;
color: var(--g-muted);
margin-bottom: 2rem;
max-width: 40ch;
margin-left: auto;
margin-right: auto;
position: relative;
}
.landing-cta-actions {
display: flex;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
position: relative;
}
`}</style>
{/* ── HERO ───────────────────── */}
<section className="landing-hero">
<div className="landing-hero-badge">
Document Reader + TTS
</div>
<h1>
Your documents, <span>read&nbsp;aloud</span>
</h1>
<p className="landing-hero-desc">
Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with your
preferred OpenAI-compatible TTS provider. Your reading progress
syncs across devices automatically.
</p>
<div className="landing-hero-actions">
<Link href="/app" className="landing-btn landing-btn-accent">
Open App
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
<Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
</div>
</section>
{/* ── FEATURES ───────────────── */}
<section className="landing-features">
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&uarr;</div>
<h3>Upload documents</h3>
<p>
Drag and drop EPUB, PDF, TXT, Markdown, or DOCX files directly
into the app. Documents process instantly for reading and
text-to-speech playback.
</p>
</div>
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&para;</div>
<h3>Your library</h3>
<p>
Build a personal library with folders. Documents sync
automatically so your collection is always within
reach.
</p>
</div>
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&harr;</div>
<h3>Cross-device sync</h3>
<p>
Reading progress, preferences, and library state sync across
devices. Pick up exactly where you left off on any browser.
</p>
</div>
</section>
{/* ── TTS SPOTLIGHT ──────────── */}
<section className="landing-tts">
<div className="landing-tts-inner landing-panel">
<div className="landing-tts-lead">
<h2>
<span>Text-to-speech</span> that follows along as you read
</h2>
<p>
OpenReader highlights every word as it&rsquo;s spoken, turning
any document into a synchronized read-along experience. Connect
any OpenAI-compatible TTS provider &mdash; including Kokoro,
Deepinfra, or your own self-hosted endpoint.
</p>
</div>
<ul className="landing-tts-list">
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Word-level highlighting</h4>
<p>Each word lights up in sync with the audio so you never lose your place.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Multiple voices &amp; providers</h4>
<p>Choose from dozens of voices across OpenAI, Kokoro, Deepinfra, or any compatible endpoint.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Speed controls</h4>
<p>Independent model speed and playback speed sliders from 0.5x to 3x.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Audiobook export</h4>
<p>Convert any document to a downloadable MP3 or M4A audiobook with chapter metadata.</p>
</div>
</li>
</ul>
</div>
</section>
{/* ── FORMATS ────────────────── */}
<section className="landing-formats">
<p className="landing-formats-label">Supported formats</p>
<div className="landing-formats-row">
<span className="landing-format-pill">EPUB</span>
<span className="landing-format-pill">PDF</span>
<span className="landing-format-pill">TXT</span>
<span className="landing-format-pill">MD</span>
<span className="landing-format-pill">DOCX</span>
</div>
</section>
{/* ── CTA ────────────────────── */}
<section className="landing-cta">
<div className="landing-cta-card landing-panel">
<div className="landing-cta-glow" aria-hidden="true" />
<h2>Start reading now</h2>
<p>
Open the app and upload a document to begin.
Your progress syncs across devices automatically.
</p>
<div className="landing-cta-actions">
<Link href="/app" className="landing-btn landing-btn-accent">Open App</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
<Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
</div>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,200 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth-config';
export const metadata: Metadata = {
title: 'Privacy & Data Usage | OpenReader WebUI',
description:
'Learn how OpenReader WebUI handles your data, what is stored in your browser, and what is sent to the server.',
alternates: {
canonical: '/privacy',
},
robots: {
index: true,
follow: true,
},
};
export default async function PrivacyPage() {
const authEnabled = isAuthEnabled();
const hdrs = await headers();
const host = hdrs.get('host') ?? 'this server';
const proto = hdrs.get('x-forwarded-proto') ?? 'https';
const origin = `${proto}://${host}`;
return (
<>
<style>{`
/* ── Privacy body ───────────────── */
.privacy-body {
max-width: 42rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
animation: landing-fade-up 0.7s ease-out 0.15s both;
}
.privacy-body h1 {
font-family: var(--g-display);
font-weight: 800;
font-size: clamp(1.5rem, 4vw, 2.25rem);
letter-spacing: -0.03em;
margin: 0 0 0.5rem;
}
.privacy-body h1 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.privacy-subtitle {
font-size: 0.95rem;
color: var(--g-muted);
margin: 0 0 2.5rem;
line-height: 1.6;
}
.privacy-card {
padding: 2rem;
margin-bottom: 1.25rem;
}
.privacy-card-label {
font-family: var(--g-display);
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--g-accent);
margin: 0 0 0.75rem;
}
.privacy-card p,
.privacy-card li {
font-size: 0.92rem;
line-height: 1.65;
color: var(--g-fg);
}
.privacy-card ul {
list-style: none;
margin: 0;
padding: 0;
}
.privacy-card li {
position: relative;
padding-left: 1.1rem;
margin-bottom: 0.4rem;
}
.privacy-card li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--g-accent);
opacity: 0.5;
}
.privacy-highlight {
background: color-mix(in srgb, var(--g-accent), transparent 88%);
border: 1px solid color-mix(in srgb, var(--g-accent), transparent 70%);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
font-size: 0.88rem;
line-height: 1.6;
color: var(--g-fg);
}
.privacy-highlight strong {
color: var(--g-accent);
font-weight: 600;
}
.privacy-note {
font-size: 0.8rem;
color: var(--g-muted);
line-height: 1.6;
margin-top: 2rem;
}
.privacy-note a {
color: var(--g-accent);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.privacy-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--g-system);
font-size: 0.875rem;
font-weight: 500;
color: var(--g-accent);
text-decoration: none;
margin-top: 2rem;
transition: opacity 0.2s;
}
.privacy-back:hover {
opacity: 0.75;
}
`}</style>
<div className="privacy-body">
<h1>Privacy &amp; <span>Data Usage</span></h1>
<p className="privacy-subtitle">
How OpenReader WebUI handles your data when hosted at this instance.
</p>
<div className="privacy-highlight">
This OpenReader instance is hosted at <strong>{origin}</strong>.
The operator of this service can access data that reaches the service.
</div>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Stored in your browser (IndexedDB)</div>
<ul>
<li>Document and preview cache</li>
<li>Settings + privacy acceptance</li>
<li>Reading progress (local fallback)</li>
</ul>
</div>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Sent to this service</div>
<ul>
<li>Uploaded files + metadata (PDF/EPUB/HTML; DOCX converted server-side)</li>
<li>TTS text + settings (optional custom API key/base URL)</li>
<li>Request metadata (IP/user agent) and optional alignment audio/text</li>
</ul>
</div>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Stored on this service</div>
<ul>
<li>Uploaded docs, metadata, and preview images</li>
<li>Generated audiobooks and temporary TTS cache</li>
{authEnabled ? (
<li>Account/session data, synced preferences/progress, and rate-limit counters</li>
) : (
<li>Auth disabled &mdash; no account or session tables</li>
)}
</ul>
</div>
<p className="privacy-note">
This site uses Vercel Analytics to collect anonymous usage data.
For maximum privacy, self-host OpenReader using the{' '}
<a
href="https://github.com/richardr1126/OpenReader-WebUI#readme"
target="_blank"
rel="noopener noreferrer"
>
open-source repository
</a>.
</p>
<Link href="/?redirect=false" className="privacy-back">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 8H3M7 4l-4 4 4 4"/></svg>
Back to home
</Link>
</div>
</>
);
}

View file

@ -1,4 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
@ -8,13 +13,45 @@ import {
getAudiobookObjectBuffer,
isMissingBlobError,
listAudiobookObjects,
putAudiobookObject,
} from '@/lib/server/audiobooks-blobstore';
import { decodeChapterFileName } from '@/lib/server/audiobook';
import {
decodeChapterFileName,
encodeChapterFileName,
encodeChapterTitleTag,
ffprobeAudio,
} from '@/lib/server/audiobook';
import { isS3Configured } from '@/lib/server/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobook-scope';
import { getFFmpegPath, getFFprobePath } from '@/lib/server/ffmpeg-bin';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
export const dynamic = 'force-dynamic';
interface ConversionRequest {
chapterTitle: string;
buffer: TTSAudioBytes;
bookId?: string;
format?: TTSAudiobookFormat;
chapterIndex?: number;
settings?: AudiobookGenerationSettings;
}
type ChapterObject = {
index: number;
title: string;
format: TTSAudiobookFormat;
fileName: string;
};
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
function isSafeId(value: string): boolean {
return SAFE_ID_REGEX.test(value);
}
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Audiobooks storage is not configured. Set S3_* environment variables.' },
@ -22,6 +59,44 @@ function s3NotConfiguredResponse(): NextResponse {
);
}
function chapterFileMimeType(format: TTSAudiobookFormat): string {
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
}
function buildAtempoFilter(speed: number): string {
const clamped = Math.max(0.5, Math.min(speed, 3));
if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`;
const second = clamped / 2;
return `atempo=2.0,atempo=${second.toFixed(3)}`;
}
function listChapterObjects(objectNames: string[]): ChapterObject[] {
const chapters = objectNames
.filter((name) => !name.startsWith('complete.'))
.map((fileName) => {
const decoded = decodeChapterFileName(fileName);
if (!decoded) return null;
return {
index: decoded.index,
title: decoded.title,
format: decoded.format,
fileName,
} satisfies ChapterObject;
})
.filter((value): value is ChapterObject => Boolean(value))
.sort((a, b) => a.index - b.index);
const deduped = new Map<number, ChapterObject>();
for (const chapter of chapters) {
const existing = deduped.get(chapter.index);
if (!existing || chapter.fileName > existing.fileName) {
deduped.set(chapter.index, chapter);
}
}
return Array.from(deduped.values()).sort((a, b) => a.index - b.index);
}
function streamBuffer(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
@ -31,6 +106,114 @@ function streamBuffer(buffer: Buffer): ReadableStream<Uint8Array> {
});
}
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const ffmpeg = spawn(getFFmpegPath(), args);
let finished = false;
const onAbort = () => {
if (finished) return;
finished = true;
try {
ffmpeg.kill('SIGKILL');
} catch {}
reject(new Error('ABORTED'));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
ffmpeg.stderr.on('data', (data) => {
console.error(`ffmpeg stderr: ${data}`);
});
ffmpeg.on('close', (code) => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
if (code === 0) {
resolve();
} else {
reject(new Error(`FFmpeg process exited with code ${code}`));
}
});
ffmpeg.on('error', (err) => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
reject(err);
});
});
}
async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> {
return new Promise((resolve, reject) => {
const ffprobe = spawn(getFFprobePath(), [
'-i',
filePath,
'-show_entries',
'format=duration',
'-v',
'quiet',
'-of',
'csv=p=0',
]);
let output = '';
let finished = false;
const onAbort = () => {
if (finished) return;
finished = true;
try {
ffprobe.kill('SIGKILL');
} catch {}
reject(new Error('ABORTED'));
};
const cleanup = () => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.on('close', (code) => {
if (finished) return;
cleanup();
if (code === 0) {
const duration = parseFloat(output.trim());
resolve(duration);
} else {
reject(new Error(`ffprobe process exited with code ${code}`));
}
});
ffprobe.on('error', (err) => {
if (finished) return;
cleanup();
reject(err);
});
});
}
function findChapterFileNameByIndex(fileNames: string[], index: number): { fileName: string; title: string; format: 'mp3' | 'm4b' } | null {
const matches = fileNames
.map((fileName) => {
@ -45,6 +228,221 @@ function findChapterFileNameByIndex(fileNames: string[], index: number): { fileN
return matches.at(-1) ?? null;
}
export async function POST(request: NextRequest) {
let workDir: string | null = null;
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const data: ConversionRequest = await request.json();
const requestedFormat = data.format || 'm4b';
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const bookId = data.bookId || randomUUID();
if (!isSafeId(bookId)) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const storageUserId =
pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
) ?? preferredUserId;
await db
.insert(audiobooks)
.values({
id: bookId,
userId: storageUserId,
title: data.chapterTitle || 'Untitled Audiobook',
})
.onConflictDoNothing();
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
const objectNames = objects.map((item) => item.fileName);
const existingChapters = listChapterObjects(objectNames);
const hasChapters = existingChapters.length > 0;
let existingSettings: AudiobookGenerationSettings | null = null;
try {
existingSettings = JSON.parse(
(await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'),
) as AudiobookGenerationSettings;
} catch (error) {
if (!isMissingBlobError(error)) throw error;
existingSettings = null;
}
const incomingSettings = data.settings;
if (existingSettings && hasChapters && incomingSettings) {
const mismatch =
existingSettings.ttsProvider !== incomingSettings.ttsProvider ||
existingSettings.ttsModel !== incomingSettings.ttsModel ||
existingSettings.voice !== incomingSettings.voice ||
existingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
existingSettings.postSpeed !== incomingSettings.postSpeed ||
existingSettings.format !== incomingSettings.format;
if (mismatch) {
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 });
}
}
const existingFormats = new Set(existingChapters.map((chapter) => chapter.format));
if (existingFormats.size > 1) {
return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 });
}
const format: TTSAudiobookFormat =
(existingFormats.values().next().value as TTSAudiobookFormat | undefined) ??
existingSettings?.format ??
incomingSettings?.format ??
requestedFormat;
const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1;
const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1;
let chapterIndex: number;
if (data.chapterIndex !== undefined) {
const normalized = Number(data.chapterIndex);
if (!Number.isInteger(normalized) || normalized < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 });
}
chapterIndex = normalized;
} else {
const indices = existingChapters.map((c) => c.index);
let next = 0;
for (const idx of indices) {
if (idx === next) {
next++;
} else if (idx > next) {
break;
}
}
chapterIndex = next;
}
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-'));
const inputPath = join(workDir, `${chapterIndex}-input.mp3`);
const chapterOutputTempPath = join(workDir, `${chapterIndex}-chapter.tmp.${format}`);
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
if (format === 'mp3') {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'libmp3lame',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
chapterOutputTempPath,
],
request.signal,
);
} else {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'aac',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
'-f',
'mp4',
chapterOutputTempPath,
],
request.signal,
);
}
const probe = await ffprobeAudio(chapterOutputTempPath, request.signal);
const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal));
const finalChapterName = encodeChapterFileName(chapterIndex, data.chapterTitle, format);
const finalChapterBytes = await readFile(chapterOutputTempPath);
await putAudiobookObject(bookId, storageUserId, finalChapterName, finalChapterBytes, chapterFileMimeType(format), testNamespace);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
for (const fileName of objectNames) {
if (!fileName.startsWith(chapterPrefix)) continue;
if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue;
if (fileName === finalChapterName) continue;
await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {});
}
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
if (!existingSettings && incomingSettings) {
await putAudiobookObject(
bookId,
storageUserId,
'audiobook.meta.json',
Buffer.from(JSON.stringify(incomingSettings, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);
}
await db
.insert(audiobookChapters)
.values({
id: `${bookId}-${chapterIndex}`,
bookId,
userId: storageUserId,
chapterIndex,
title: data.chapterTitle,
duration,
format,
filePath: finalChapterName,
})
.onConflictDoUpdate({
target: [audiobookChapters.id, audiobookChapters.userId],
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName },
});
return NextResponse.json({
index: chapterIndex,
title: data.chapterTitle,
duration,
status: 'completed' as const,
bookId,
format,
});
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
}
console.error('Error processing audio chapter:', error);
return NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
export async function GET(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
@ -67,19 +465,22 @@ export async function GET(request: NextRequest) {
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = userId ?? unclaimedUserId;
const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const [existingBook] = await db
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const existingBookUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBook) {
if (!existingBookUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace);
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
const chapter = findChapterFileNameByIndex(
objects.map((object) => object.fileName),
chapterIndex,
@ -91,7 +492,7 @@ export async function GET(request: NextRequest) {
.where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, existingBook.userId),
eq(audiobookChapters.userId, existingBookUserId),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
@ -100,7 +501,7 @@ export async function GET(request: NextRequest) {
let buffer: Buffer;
try {
buffer = await getAudiobookObjectBuffer(bookId, existingBook.userId, chapter.fileName, testNamespace);
buffer = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
} catch (error) {
if (isMissingBlobError(error)) {
await db
@ -108,7 +509,7 @@ export async function GET(request: NextRequest) {
.where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, existingBook.userId),
eq(audiobookChapters.userId, existingBookUserId),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
@ -152,15 +553,21 @@ export async function DELETE(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace);
const [existingBook] = await db
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const storageUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBook) {
if (!storageUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}

View file

@ -3,7 +3,6 @@ import { spawn } from 'child_process';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
@ -13,34 +12,22 @@ import {
deleteAudiobookObject,
deleteAudiobookPrefix,
getAudiobookObjectBuffer,
isMissingBlobError,
listAudiobookObjects,
putAudiobookObject,
} from '@/lib/server/audiobooks-blobstore';
import {
decodeChapterFileName,
encodeChapterFileName,
encodeChapterTitleTag,
escapeFFMetadata,
ffprobeAudio,
} from '@/lib/server/audiobook';
import { isS3Configured } from '@/lib/server/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { getFFmpegPath, getFFprobePath } from '@/lib/server/ffmpeg-bin';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobook-scope';
import type { TTSAudiobookFormat } from '@/types/tts';
export const dynamic = 'force-dynamic';
interface ConversionRequest {
chapterTitle: string;
buffer: TTSAudioBytes;
bookId?: string;
format?: TTSAudiobookFormat;
chapterIndex?: number;
settings?: AudiobookGenerationSettings;
}
type ChapterObject = {
index: number;
title: string;
@ -65,13 +52,6 @@ function chapterFileMimeType(format: TTSAudiobookFormat): string {
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
}
function buildAtempoFilter(speed: number): string {
const clamped = Math.max(0.5, Math.min(speed, 3));
if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`;
const second = clamped / 2;
return `atempo=2.0,atempo=${second.toFixed(3)}`;
}
function listChapterObjects(objectNames: string[]): ChapterObject[] {
const chapters = objectNames
.filter((name) => !name.startsWith('complete.'))
@ -220,206 +200,6 @@ async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise
});
}
export async function POST(request: NextRequest) {
let workDir: string | null = null;
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const data: ConversionRequest = await request.json();
const requestedFormat = data.format || 'm4b';
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace);
const bookId = data.bookId || randomUUID();
if (!isSafeId(bookId)) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
await db
.insert(audiobooks)
.values({
id: bookId,
userId: storageUserId,
title: data.chapterTitle || 'Untitled Audiobook',
})
.onConflictDoNothing();
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
const objectNames = objects.map((item) => item.fileName);
const existingChapters = listChapterObjects(objectNames);
const hasChapters = existingChapters.length > 0;
let existingSettings: AudiobookGenerationSettings | null = null;
try {
existingSettings = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
} catch (error) {
if (!isMissingBlobError(error)) throw error;
existingSettings = null;
}
const incomingSettings = data.settings;
if (existingSettings && hasChapters && incomingSettings) {
const mismatch =
existingSettings.ttsProvider !== incomingSettings.ttsProvider ||
existingSettings.ttsModel !== incomingSettings.ttsModel ||
existingSettings.voice !== incomingSettings.voice ||
existingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
existingSettings.postSpeed !== incomingSettings.postSpeed ||
existingSettings.format !== incomingSettings.format;
if (mismatch) {
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 });
}
}
const existingFormats = new Set(existingChapters.map((chapter) => chapter.format));
if (existingFormats.size > 1) {
return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 });
}
const format: TTSAudiobookFormat =
(existingFormats.values().next().value as TTSAudiobookFormat | undefined) ??
existingSettings?.format ??
incomingSettings?.format ??
requestedFormat;
const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1;
const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1;
let chapterIndex: number;
if (data.chapterIndex !== undefined) {
const normalized = Number(data.chapterIndex);
if (!Number.isInteger(normalized) || normalized < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 });
}
chapterIndex = normalized;
} else {
const indices = existingChapters.map((c) => c.index);
let next = 0;
for (const idx of indices) {
if (idx === next) {
next++;
} else if (idx > next) {
break;
}
}
chapterIndex = next;
}
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-'));
const inputPath = join(workDir, `${chapterIndex}-input.mp3`);
const chapterOutputTempPath = join(workDir, `${chapterIndex}-chapter.tmp.${format}`);
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
if (format === 'mp3') {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'libmp3lame',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
chapterOutputTempPath,
],
request.signal,
);
} else {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'aac',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
'-f',
'mp4',
chapterOutputTempPath,
],
request.signal,
);
}
const probe = await ffprobeAudio(chapterOutputTempPath, request.signal);
const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal));
const finalChapterName = encodeChapterFileName(chapterIndex, data.chapterTitle, format);
const finalChapterBytes = await readFile(chapterOutputTempPath);
await putAudiobookObject(bookId, storageUserId, finalChapterName, finalChapterBytes, chapterFileMimeType(format), testNamespace);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
for (const fileName of objectNames) {
if (!fileName.startsWith(chapterPrefix)) continue;
if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue;
if (fileName === finalChapterName) continue;
await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {});
}
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
if (!existingSettings && incomingSettings) {
await putAudiobookObject(
bookId,
storageUserId,
'audiobook.meta.json',
Buffer.from(JSON.stringify(incomingSettings, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);
}
await db
.insert(audiobookChapters)
.values({
id: `${bookId}-${chapterIndex}`,
bookId,
userId: storageUserId,
chapterIndex,
title: data.chapterTitle,
duration,
format,
filePath: finalChapterName,
})
.onConflictDoUpdate({
target: [audiobookChapters.id, audiobookChapters.userId],
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName },
});
return NextResponse.json({
index: chapterIndex,
title: data.chapterTitle,
duration,
status: 'completed' as const,
bookId,
format,
});
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
}
console.error('Error processing audio chapter:', error);
return NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
export async function GET(request: NextRequest) {
let workDir: string | null = null;
try {
@ -440,18 +220,21 @@ export async function GET(request: NextRequest) {
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = userId ?? unclaimedUserId;
const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const [existingBook] = await db
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
if (!existingBook) {
const existingBookUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBookUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace);
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
const objectNames = objects.map((item) => item.fileName);
const chapters = listChapterObjects(objectNames);
if (chapters.length === 0) {
@ -470,9 +253,9 @@ export async function GET(request: NextRequest) {
if (objectNames.includes(completeName) && objectNames.includes(manifestName)) {
try {
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBook.userId, manifestName, testNamespace)).toString('utf8'));
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, manifestName, testNamespace)).toString('utf8'));
if (JSON.stringify(manifest) === JSON.stringify(signature)) {
const cached = await getAudiobookObjectBuffer(bookId, existingBook.userId, completeName, testNamespace);
const cached = await getAudiobookObjectBuffer(bookId, existingBookUserId, completeName, testNamespace);
return new NextResponse(streamBuffer(cached), {
headers: {
'Content-Type': chapterFileMimeType(format),
@ -485,14 +268,14 @@ export async function GET(request: NextRequest) {
// Force regeneration below.
}
await deleteAudiobookObject(bookId, existingBook.userId, completeName, testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, existingBook.userId, manifestName, testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, existingBookUserId, completeName, testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, existingBookUserId, manifestName, testNamespace).catch(() => {});
}
const chapterRows = await db
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
.from(audiobookChapters)
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId)));
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
const durationByIndex = new Map<number, number>();
for (const row of chapterRows) {
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
@ -506,7 +289,7 @@ export async function GET(request: NextRequest) {
const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = [];
for (const chapter of chapters) {
const localPath = join(workDir, chapter.fileName);
const bytes = await getAudiobookObjectBuffer(bookId, existingBook.userId, chapter.fileName, testNamespace);
const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
await writeFile(localPath, bytes);
let duration = durationByIndex.get(chapter.index) ?? 0;
@ -576,10 +359,10 @@ export async function GET(request: NextRequest) {
}
const outputBytes = await readFile(outputPath);
await putAudiobookObject(bookId, existingBook.userId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
await putAudiobookObject(
bookId,
existingBook.userId,
existingBookUserId,
manifestName,
Buffer.from(JSON.stringify(signature, null, 2), 'utf8'),
'application/json; charset=utf-8',
@ -618,15 +401,21 @@ export async function DELETE(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace);
const [existingBook] = await db
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const storageUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBook) {
if (!storageUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}

View file

@ -8,6 +8,7 @@ import { decodeChapterFileName } from '@/lib/server/audiobook';
import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobook-prune';
import { isS3Configured } from '@/lib/server/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobook-scope';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
@ -74,15 +75,18 @@ export async function GET(request: NextRequest) {
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = userId ?? unclaimedUserId;
const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const [existingBook] = await db
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const existingBookUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBook) {
if (!existingBookUserId) {
return NextResponse.json({
chapters: [],
exists: false,
@ -92,20 +96,20 @@ export async function GET(request: NextRequest) {
});
}
const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace);
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
const objectNames = objects.map((object) => object.fileName);
const chapterObjects = listChapterObjects(objectNames);
await pruneAudiobookChaptersNotOnDisk(
bookId,
existingBook.userId,
existingBookUserId,
chapterObjects.map((chapter) => chapter.index),
);
const chapterRows = await db
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
.from(audiobookChapters)
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId)));
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
const durationByIndex = new Map<number, number>();
for (const row of chapterRows) {
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
@ -122,7 +126,7 @@ export async function GET(request: NextRequest) {
let settings: AudiobookGenerationSettings | null = null;
try {
settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBook.userId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
} catch (error) {
if (!isMissingBlobError(error)) throw error;
settings = null;
@ -132,8 +136,8 @@ export async function GET(request: NextRequest) {
const exists = chapters.length > 0 || hasComplete || settings !== null;
if (!exists) {
await db.delete(audiobookChapters).where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId)));
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBook.userId)));
await db.delete(audiobookChapters).where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId)));
return NextResponse.json({
chapters: [],
exists: false,

View file

@ -112,13 +112,17 @@ html.mint {
body {
color: var(--foreground);
background: var(--background);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
font-family: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display), system-ui, -apple-system, sans-serif;
}
/*
* PDF page turn smoothing
* - Keep a stable paint surface (prevents background flash)

View file

@ -1,102 +1,53 @@
import "./globals.css";
import { ReactNode } from "react";
import { Providers } from "@/app/providers";
import { Metadata } from "next";
import { Footer } from "@/components/Footer";
import { Toaster } from 'react-hot-toast';
import { Analytics } from "@vercel/analytics/next";
import { isAuthEnabled, getAuthBaseUrl } from '@/lib/server/auth-config';
import ClaimDataPopup from '@/components/auth/ClaimDataModal';
import { Figtree } from "next/font/google";
const figtree = Figtree({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700", "800"],
display: "swap",
variable: "--font-display",
});
const themeInitScript = `
(() => {
const themes = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint'];
const root = document.documentElement;
const stored = localStorage.getItem('theme');
const selected = stored && themes.includes(stored) ? stored : 'system';
const effective = selected === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: selected;
root.classList.remove(...themes);
root.classList.add(effective);
root.style.colorScheme = effective === 'dark' ? 'dark' : 'light';
})();
`;
export const metadata: Metadata = {
title: "OpenReader WebUI",
description: 'A "bring your own TTS api" web interface for reading PDF and EPUB documents with high quality text-to-speech voices. Read books with ease, listen to articles on the go, or study like you have your own lecturer, all in one place.',
keywords: "PDF reader, EPUB reader, text to speech, tts open ai, kokoro tts, Kokoro-82M, OpenReader, TTS PDF reader, ebook reader, epub tts, high quality text to speech",
authors: [{ name: "Richard Roberson" }],
title: {
default: "OpenReader WebUI",
template: "%s | OpenReader WebUI",
},
manifest: "/manifest.json",
metadataBase: new URL("https://openreader.richardr.dev"), // Replace with your domain
openGraph: {
type: "website",
locale: "en_US",
url: "https://openreader.richardr.dev",
siteName: "OpenReader WebUI",
title: "OpenReader WebUI",
description: 'A "bring your own TTS api" web interface for reading PDF and EPUB documents with high quality text-to-speech voices. Read books with ease, listen to articles on the go, or study like you have your own lecturer, all in one place.',
images: [
{
url: "/web-app-manifest-512x512.png",
width: 512,
height: 512,
alt: "OpenReader WebUI Logo",
},
],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
metadataBase: new URL("https://openreader.richardr.dev"),
verification: {
google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U",
},
};
// Force dynamic rendering so env vars are read at runtime, not build time
export const dynamic = 'force-dynamic';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
export default function RootLayout({ children }: { children: ReactNode }) {
// Read auth config inside the component to ensure runtime evaluation
const authEnabled = isAuthEnabled();
const authBaseUrl = getAuthBaseUrl();
return (
<html lang="en">
<html lang="en" className={figtree.variable} suppressHydrationWarning>
<head>
<meta name="color-scheme" content="light dark" />
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="antialiased">
<Providers authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
<div className="app-shell min-h-screen flex flex-col bg-background">
{authEnabled && <ClaimDataPopup />}
<main className="flex-1 flex flex-col">
{children}
<Analytics />
</main>
{!isDev && (
<div className="px-4 py-3">
<Footer />
</div>
)}
</div>
<Toaster
toastOptions={{
style: {
background: 'var(--offbase)',
color: 'var(--foreground)',
},
success: {
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
},
error: {
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
},
}}
/>
</Providers>
{children}
<Analytics />
</body>
</html>
);

View file

@ -1,4 +1,7 @@
'use client';
import { ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import { DocumentProvider } from '@/contexts/DocumentContext';
import { PDFProvider } from '@/contexts/PDFContext';
@ -16,11 +19,38 @@ interface ProvidersProps {
children: ReactNode;
authEnabled: boolean;
authBaseUrl: string | null;
allowAnonymousAuthSessions: boolean;
}
export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps) {
export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions }: ProvidersProps) {
const pathname = usePathname();
const isAuthPage = pathname === '/signin' || pathname === '/signup';
if (isAuthPage) {
return (
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
>
<ThemeProvider>
<AuthLoader>
<>
{children}
<PrivacyModal authEnabled={authEnabled} />
</>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
);
}
return (
<AuthRateLimitProvider authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
>
<ThemeProvider>
<AuthLoader>
<ConfigProvider>

View file

@ -703,7 +703,7 @@ export function AudiobookExportModal({
<h4 className="text-sm font-medium text-foreground">Chapters</h4>
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
</div>
{displayChapters.map((chapter, index) => (
{displayChapters.map((chapter) => (
<div
key={chapter.index}
className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
@ -751,7 +751,11 @@ export function AudiobookExportModal({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className={`absolute right-0 w-44 rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-10 p-1 ${index < 2 ? 'top-full mt-2 origin-top-right' : 'bottom-full mb-2 origin-bottom-right'}`}>
<MenuItems
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
portal
className="w-44 rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-[70] p-1 origin-top-right"
>
{chapter.status === 'completed' && (
<>
<MenuItem>

View file

@ -1,56 +0,0 @@
'use client'
import { GithubIcon } from '@/components/icons/Icons'
import { showPrivacyModal } from '@/components/PrivacyModal'
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'
export function Footer() {
const { authEnabled } = useAuthConfig();
return (
<footer className="m-8 mb-2 text-sm text-muted">
<div className="flex flex-col items-center space-y-4">
<div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3">
<a
href="https://github.com/richardr1126/OpenReader-WebUI#readme"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 font-bold hover:text-foreground transition-colors"
>
<GithubIcon className="w-5 h-5" />
<span>Self host</span>
</a>
<span className='w-full sm:w-fit'></span>
<button
type="button"
onClick={() => showPrivacyModal({ authEnabled })}
className="font-bold hover:text-foreground transition-colors outline-none"
>
Privacy
</button>
<span className='w-full sm:w-fit'></span>
<span>
Powered by{' '}
<a
href="https://huggingface.co/hexgrad/Kokoro-82M"
target="_blank"
rel="noopener noreferrer"
className="font-bold hover:text-foreground transition-colors underline decoration-dotted underline-offset-4"
>
hexgrad/Kokoro-82M
</a>
{' '}and{' '}
<a
href="https://deepinfra.com/models?type=text-to-speech"
target="_blank"
rel="noopener noreferrer"
className="font-bold hover:text-foreground transition-colors underline decoration-dotted underline-offset-4"
>
Deepinfra
</a>
</span>
</div>
</div>
</footer>
)
}

View file

@ -2,12 +2,21 @@
import { DocumentUploader } from '@/components/DocumentUploader';
import { DocumentList } from '@/components/doclist/DocumentList';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
import { useDocuments } from '@/contexts/DocumentContext';
export function HomeContent() {
const { pdfDocs, epubDocs, htmlDocs } = useDocuments();
const { pdfDocs, epubDocs, htmlDocs, isPDFLoading } = useDocuments();
const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0);
if (isPDFLoading) {
return (
<div className="w-full">
<DocumentListSkeleton />
</div>
);
}
if (totalDocs === 0) {
return (
<div className="w-full">

View file

@ -39,30 +39,30 @@ function PrivacyModalBody({
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored in your browser (IndexedDB)</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Uploaded documents (local library)</li>
<li>Reading progress (last location)</li>
<li>App settings (voice/speed/provider/base URL)</li>
<li>Privacy notice acceptance</li>
<li>Document and preview cache</li>
<li>Settings + privacy acceptance</li>
<li>Reading progress (local fallback)</li>
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Sent to this service</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Text for audio generation and associated metadata</li>
<li>Standard request metadata (e.g. IP address, user agent)</li>
<li>Text is forwarded to a TTS provider (Deepinfra) to generate audio</li>
<li>Some generated audio may be cached server-side to reduce cost/latency</li>
<li>Uploaded files + metadata (PDF/EPUB/HTML; DOCX converted server-side)</li>
<li>TTS text + settings (optional custom API key/base URL)</li>
<li>Request metadata (IP/user agent) and optional alignment audio/text</li>
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored on this service</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Uploaded docs, metadata, and preview images</li>
<li>Generated audiobooks and temporary TTS cache</li>
{authEnabled ? (
<li>Auth users data and IP rate limiting data are stored in the service database</li>
<li>Account/session data, synced preferences/progress, and rate-limit counters</li>
) : (
<li>Authentication is disabled, so no user/session database is used</li>
<li>Auth disabled: no account/session tables</li>
)}
</ul>
</div>
@ -87,33 +87,30 @@ function PrivacyModalBody({
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored in your browser (IndexedDB)</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Uploaded documents (local library)</li>
<li>Reading progress (last location)</li>
<li>App settings (voice/speed/provider/base URL)</li>
<li>Privacy notice acceptance</li>
<li>Document and preview cache</li>
<li>Settings + privacy acceptance</li>
<li>Reading progress (local fallback)</li>
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Sent to this server</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Text for audio generation and associated metadata</li>
<li>DOCX document upload and conversion only</li>
<li>Your IP address and device ID cookie used for rate limiting</li>
<li>(Optionally) Generated audio for word-by-word timestamps</li>
<li>(Optionally) Your TTS API key so the server can call your TTS provider</li>
<li>Uploaded files + metadata (PDF/EPUB/HTML; DOCX converted server-side)</li>
<li>TTS text + settings (optional custom API key/base URL)</li>
<li>Request metadata (IP/user agent; device ID if rate limiting is enabled) and optional alignment audio/text</li>
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored on this server</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Documents synced between your browser and this server</li>
<li>Generated audiobooks</li>
<li>Uploaded docs, metadata, and preview images</li>
<li>Generated audiobooks and temporary TTS cache</li>
{authEnabled ? (
<li>Auth users data and IP rate limiting data are stored in the server&apos;s database</li>
<li>Account/session data, synced preferences/progress, and rate-limit counters</li>
) : (
<li>Authentication is disabled on this server, so no server-side user/session database is used</li>
<li>Auth disabled: no account/session tables</li>
)}
</ul>
</div>

View file

@ -81,7 +81,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const [deleteDocsMode, setDeleteDocsMode] = useState<'user' | 'unclaimed'>('user');
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions } = useAuthConfig();
const { data: session } = useAuthSession();
const router = useRouter();
const isBusy = isImportingLibrary;
@ -312,11 +312,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const handleSignOut = async () => {
const client = getAuthClient(authBaseUrl);
// "Sign out" here means: disconnect the email/social account and return to a fresh
// anonymous session. The app should not be able to end up truly signed out.
await client.signOut();
// AuthLoader will create the next anonymous session and refresh rate limit state.
router.refresh();
router.push('/signin');
};
const handleDeleteAccount = async () => {
@ -327,9 +324,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
// Sign out locally
const client = getAuthClient(authBaseUrl);
await client.signOut();
// After account deletion, we return to a fresh anonymous session.
// AuthLoader will create the session if one isn't present yet.
window.location.href = '/';
window.location.href = '/signup';
} catch (error) {
console.error('Failed to delete account:', error);
}
@ -872,14 +867,18 @@ export function SettingsModal({ className = '' }: { className?: string }) {
Delete Account
</Button>
<p className="text-xs text-muted mt-2 text-center">
This will permanently delete your account and data. You will be returned to a fresh anonymous session.
This will permanently delete your account and data. You will be redirected to sign up.
</p>
</div>
</>
) : (
<div className="pt-2 border-t border-offbase">
<p className="text-sm text-muted mb-3">
You are using an anonymous session. Sign up to save your progress permanently.
{session?.user?.isAnonymous
? (allowAnonymousAuthSessions
? 'You are using an anonymous session. Sign up to save your progress permanently.'
: 'Anonymous sessions are disabled. Please sign in or create an account.')
: 'No active session. Please sign in or create an account.'}
</p>
<div className="grid grid-cols-2 gap-3">
<Link href="/signin" className="w-full">
@ -893,6 +892,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</Button>
</Link>
</div>
<Link href="/?redirect=false" className="block mt-3">
<Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
Back to landing page
</Button>
</Link>
</div>
)}
</div>

View file

@ -2,6 +2,7 @@
import { useEffect, useRef, useState, ReactNode } from 'react';
import type { BetterFetchError } from 'better-auth/react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/auth-client';
@ -89,33 +90,64 @@ function isRateLimited(info: ErrorInfo | null): boolean {
}
export function AuthLoader({ children }: { children: ReactNode }) {
const { authEnabled, baseUrl } = useAuthConfig();
const { authEnabled, baseUrl, allowAnonymousAuthSessions } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit();
const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession();
const router = useRouter();
const pathname = usePathname();
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false);
const [retryNonce, setRetryNonce] = useState(0);
const attemptedForNullSessionRef = useRef(false);
const clearingDisallowedAnonymousRef = useRef(false);
const isAuthPage = pathname === '/signin' || pathname === '/signup';
// If the auth base URL changes, re-run the bootstrap logic.
useEffect(() => {
attemptedForNullSessionRef.current = false;
setBootstrapError(null);
}, [authEnabled, baseUrl]);
setIsRedirecting(false);
}, [authEnabled, baseUrl, allowAnonymousAuthSessions, pathname]);
useEffect(() => {
// This app does not have a real "signed out" state when auth is enabled.
// If we ever observe "no session", we immediately start an anonymous session.
const checkStatus = async () => {
if (!authEnabled) return;
if (isPending) return;
if (session) {
if (!allowAnonymousAuthSessions && session.user.isAnonymous) {
if (clearingDisallowedAnonymousRef.current) return;
clearingDisallowedAnonymousRef.current = true;
setIsRedirecting(true);
try {
const client = getAuthClient(baseUrl);
await client.signOut();
} catch (err) {
console.error('[AuthLoader] failed to clear disallowed anonymous session', err);
} finally {
clearingDisallowedAnonymousRef.current = false;
router.replace('/signin');
return;
}
}
clearingDisallowedAnonymousRef.current = false;
attemptedForNullSessionRef.current = false;
setBootstrapError(null);
return;
}
if (!allowAnonymousAuthSessions) {
setIsAutoLoggingIn(false);
setBootstrapError(null);
if (!isAuthPage) {
setIsRedirecting(true);
router.replace('/signin');
}
return;
}
// Avoid double-calling anonymous sign-in (e.g. React strict mode).
if (attemptedForNullSessionRef.current) return;
attemptedForNullSessionRef.current = true;
@ -189,7 +221,18 @@ export function AuthLoader({ children }: { children: ReactNode }) {
};
checkStatus();
}, [session, isPending, authEnabled, baseUrl, refreshRateLimit, refetchSession, retryNonce]);
}, [
session,
isPending,
authEnabled,
baseUrl,
allowAnonymousAuthSessions,
refreshRateLimit,
refetchSession,
retryNonce,
isAuthPage,
router,
]);
useEffect(() => {
if (!authEnabled) return;
@ -198,10 +241,16 @@ export function AuthLoader({ children }: { children: ReactNode }) {
}
}, [authEnabled, sessionError]);
// Show loader if:
// 1. Auth client is initializing (isPending) AND auth is enabled
// 2. We are actively creating an anonymous session
const isLoading = authEnabled && (isPending || isAutoLoggingIn || !session);
const shouldBlockForProtectedNoSession =
authEnabled && !allowAnonymousAuthSessions && !isAuthPage && !session;
const shouldBlockForDisallowedAnonymous =
authEnabled && !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous);
const isLoading = authEnabled && (
(allowAnonymousAuthSessions && (isPending || isAutoLoggingIn || !session)) ||
(!allowAnonymousAuthSessions && !isAuthPage && (
isPending || isRedirecting || shouldBlockForProtectedNoSession || shouldBlockForDisallowedAnonymous
))
);
if (isLoading) {
return (

View file

@ -16,11 +16,8 @@ export function UserMenu({ className = '' }: { className?: string }) {
const handleDisconnectAccount = async () => {
const client = getAuthClient(baseUrl);
// "Sign out" here means: end the email/social session and immediately
// start a fresh anonymous session. The app should never be left without a session.
await client.signOut();
// AuthLoader will create the next anonymous session.
router.refresh();
router.push('/signin');
};
if (!session || session.user.isAnonymous) {

View file

@ -11,6 +11,7 @@ import { DocumentListItem } from '@/components/doclist/DocumentListItem';
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
import { SortControls } from '@/components/doclist/SortControls';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
import { Button } from '@headlessui/react';
import { DocumentUploader } from '@/components/DocumentUploader';
@ -285,7 +286,7 @@ export function DocumentList() {
}, [createFolder]);
if (isPDFLoading || isEPUBLoading || isHTMLLoading) {
return <div className="w-full text-center text-muted">Loading documents...</div>;
return <DocumentListSkeleton viewMode={viewMode} />;
}
if (allDocuments.length === 0) {

View file

@ -0,0 +1,49 @@
'use client';
interface DocumentListSkeletonProps {
viewMode?: 'list' | 'grid';
}
export function DocumentListSkeleton({ viewMode = 'grid' }: DocumentListSkeletonProps) {
const placeholders = Array.from({ length: viewMode === 'grid' ? 10 : 6 });
return (
<div className="w-full mx-auto animate-pulse" aria-label="Loading documents" aria-busy="true">
<div className="flex items-center justify-between mb-2">
<div className="h-6 w-36 rounded bg-offbase" />
<div className="h-6 w-44 rounded bg-offbase" />
</div>
<div className="h-3 w-48 rounded bg-offbase mb-3" />
<div className="h-9 w-full rounded-lg border-2 border-dashed border-offbase bg-base mb-3" />
<div
className={
viewMode === 'grid'
? 'grid w-full grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 lg:grid-cols-5'
: 'w-full space-y-1'
}
>
{placeholders.map((_, index) => (
<div
key={index}
className={
viewMode === 'grid'
? 'overflow-hidden rounded-md border border-offbase bg-base'
: 'h-12 rounded-md border border-offbase bg-base'
}
>
{viewMode === 'grid' ? (
<>
<div className="aspect-[3/4] w-full bg-offbase" />
<div className="p-2">
<div className="h-3 w-4/5 rounded bg-offbase" />
<div className="mt-1 h-2.5 w-1/3 rounded bg-offbase" />
</div>
</>
) : null}
</div>
))}
</div>
</div>
);
}

View file

@ -16,6 +16,7 @@ interface AuthRateLimitContextType {
// Auth Config
authEnabled: boolean;
authBaseUrl: string | null;
allowAnonymousAuthSessions: boolean;
// Rate Limit
status: RateLimitStatus | null;
@ -42,8 +43,8 @@ export function useAuthRateLimit(): AuthRateLimitContextType {
// Re-export specific hooks for backward compatibility or convenience if needed
export function useAuthConfig() {
const { authEnabled, authBaseUrl } = useAuthRateLimit();
return { authEnabled, baseUrl: authBaseUrl };
const { authEnabled, authBaseUrl, allowAnonymousAuthSessions } = useAuthRateLimit();
return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions };
}
export function useRateLimit() {
@ -86,9 +87,15 @@ interface AuthRateLimitProviderProps {
children: ReactNode;
authEnabled: boolean;
authBaseUrl: string | null;
allowAnonymousAuthSessions: boolean;
}
export function AuthRateLimitProvider({ children, authEnabled, authBaseUrl }: AuthRateLimitProviderProps) {
export function AuthRateLimitProvider({
children,
authEnabled,
authBaseUrl,
allowAnonymousAuthSessions,
}: AuthRateLimitProviderProps) {
const [status, setStatus] = useState<RateLimitStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -212,6 +219,7 @@ export function AuthRateLimitProvider({ children, authEnabled, authBaseUrl }: Au
const contextValue: AuthRateLimitContextType = {
authEnabled,
authBaseUrl,
allowAnonymousAuthSessions,
status,
loading,
error,

View file

@ -97,7 +97,7 @@ export const createAudiobookChapter = async (
payload: CreateChapterPayload,
signal?: AbortSignal
): Promise<TTSAudiobookChapter> => {
const response = await fetch(`/api/audiobook`, {
const response = await fetch(`/api/audiobook/chapter`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View file

@ -0,0 +1,25 @@
export function buildAllowedAudiobookUserIds(
authEnabled: boolean,
userId: string | null,
unclaimedUserId: string,
): { preferredUserId: string; allowedUserIds: string[] } {
if (!authEnabled) {
return { preferredUserId: unclaimedUserId, allowedUserIds: [unclaimedUserId] };
}
const preferredUserId = userId ?? unclaimedUserId;
const allowedUserIds = Array.from(new Set([preferredUserId, unclaimedUserId]));
return { preferredUserId, allowedUserIds };
}
export function pickAudiobookOwner(
existingUserIds: string[],
preferredUserId: string,
unclaimedUserId: string,
): string | null {
const existing = new Set(existingUserIds);
// Keep resumed writes on unclaimed scope when the book already exists there.
if (existing.has(unclaimedUserId)) return unclaimedUserId;
if (existing.has(preferredUserId)) return preferredUserId;
return existingUserIds[0] ?? null;
}

View file

@ -6,6 +6,25 @@ export function isAuthEnabled(): boolean {
return !!(process.env.AUTH_SECRET && process.env.BASE_URL);
}
function parseBooleanEnv(name: string, defaultValue: boolean): boolean {
const raw = process.env[name];
if (!raw || raw.trim() === '') return defaultValue;
const normalized = raw.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
return defaultValue;
}
/**
* Anonymous sessions are opt-in.
* Defaults to false when unset or invalid.
*/
export function isAnonymousAuthSessionsEnabled(): boolean {
if (!isAuthEnabled()) return false;
return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false);
}
/**
* Get the auth base URL if auth is enabled, otherwise null.
*/

View file

@ -6,7 +6,7 @@ import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { rateLimiter } from "@/lib/server/rate-limiter";
import { isAuthEnabled } from "@/lib/server/auth-config";
import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth-config";
import {
transferUserAudiobooks,
transferUserDocuments,
@ -100,75 +100,79 @@ const createAuth = () => betterAuth({
},
plugins: [
nextCookies(), // Enable Next.js cookie handling
anonymous({
onLinkAccount: async ({ anonymousUser, newUser }) => {
try {
// Log when anonymous user links to a real account
console.log("Anonymous user linked to account:", {
anonymousUserId: anonymousUser.user.id,
newUserId: newUser.user.id,
newUserEmail: newUser.user.email,
});
...(isAnonymousAuthSessionsEnabled()
? [
anonymous({
onLinkAccount: async ({ anonymousUser, newUser }) => {
try {
// Log when anonymous user links to a real account
console.log("Anonymous user linked to account:", {
anonymousUserId: anonymousUser.user.id,
newUserId: newUser.user.id,
newUserEmail: newUser.user.email,
});
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try {
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
} catch (error) {
console.error("Error transferring rate limit data during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try {
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
} catch (error) {
console.error("Error transferring rate limit data during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer audiobooks from anonymous user to new authenticated user
try {
const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring audiobooks during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer audiobooks from anonymous user to new authenticated user
try {
const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring audiobooks during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer documents from anonymous user to new authenticated user
try {
const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring documents during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer documents from anonymous user to new authenticated user
try {
const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring documents during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer preferences from anonymous user to new authenticated user
try {
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring preferences during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer preferences from anonymous user to new authenticated user
try {
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring preferences during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer reading progress from anonymous user to new authenticated user
try {
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring reading progress during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
console.error("Error in onLinkAccount callback:", error);
// Don't throw here to prevent blocking the account linking process
}
// Note: Anonymous user will be automatically deleted after this callback completes
},
}),
// Transfer reading progress from anonymous user to new authenticated user
try {
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring reading progress during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
console.error("Error in onLinkAccount callback:", error);
// Don't throw here to prevent blocking the account linking process
}
// Note: Anonymous user will be automatically deleted after this callback completes
},
}),
]
: []),
],
});

View file

@ -232,6 +232,8 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({
});
test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => {
test.setTimeout(120_000);
await setupTest(page, testInfo);
// Upload and open the sample EPUB in the viewer
@ -284,9 +286,11 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
// Wait for the UI to reflect the final backend chapter count to avoid race
// conditions between the modal's soft refresh and our assertions.
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
// UI refresh can lag behind backend stabilization after cancellation; require at
// least the pre-cancel chapter count instead of exact backend parity.
await expect
.poll(async () => chapterActionsButtons.count(), { timeout: 60_000 })
.toBeGreaterThanOrEqual(chapterCountBeforeCancel);
// The Full Download button should still be available for the partially generated audiobook
await withDownloadedFullAudiobook(page, async ({ filePath }) => {

View file

@ -1,11 +1,16 @@
import { Page, expect, type TestInfo, type Locator } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { createHash } from 'crypto';
const DIR = './tests/files/';
const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3');
let ttsMockBuffer: Buffer | null = null;
function isAuthEnabledForTests() {
return Boolean(process.env.AUTH_SECRET && process.env.BASE_URL);
}
async function ensureTtsRouteMock(page: Page) {
if (!ttsMockBuffer) {
ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH);
@ -30,6 +35,14 @@ function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function fixturePath(fileName: string) {
return path.join(__dirname, 'files', fileName);
}
function sha256HexOfFile(filePath: string) {
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
/**
* Upload a sample epub or pdf
*/
@ -66,13 +79,15 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
} catch {
// ignore
}
const pdfName = fileName.replace(/\.docx$/i, '.pdf');
await page.getByRole('link', { name: new RegExp(escapeRegExp(pdfName), 'i') }).click();
const expectedId = sha256HexOfFile(fixturePath(fileName));
const targetLink = page.locator(`a[href$="/pdf/${expectedId}"]`).first();
await expect(targetLink).toBeVisible({ timeout: 15000 });
await targetLink.click();
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
return;
}
await page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).click();
await page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first().click();
if (lower.endsWith('.pdf')) {
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
@ -121,13 +136,37 @@ export async function pauseTTSAndVerify(page: Page) {
* Common test setup function
*/
export async function setupTest(page: Page, testInfo?: TestInfo) {
if (testInfo) {
const namespace = testInfo ? `${testInfo.project.name}-worker${testInfo.workerIndex}` : null;
if (namespace) {
// Isolate server-side storage per Playwright worker to avoid cross-test flake
// when running with multiple workers (server-first document storage).
const namespace = `${testInfo.project.name}-worker${testInfo.workerIndex}`;
await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace });
}
// In no-auth mode, all tests in a worker share the same server-side unclaimed identity.
// Clear docs at setup to avoid cross-test collisions on duplicate filenames.
if (!isAuthEnabledForTests()) {
const headers = namespace ? { 'x-openreader-test-namespace': namespace } : undefined;
let cleared = false;
let attempts = 0;
while (!cleared && attempts < 3) {
attempts += 1;
try {
const res = await page.request.delete('/api/documents', { ...(headers ? { headers } : {}) });
if (res.ok()) {
cleared = true;
break;
}
} catch {
// retry
}
await page.waitForTimeout(200);
}
if (!cleared) {
throw new Error('Failed to clear server documents before test setup');
}
}
// Mock the TTS API so tests don't hit the real TTS service.
await ensureTtsRouteMock(page);
@ -136,8 +175,8 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
// server routes that require auth don't intermittently 401 during app startup.
// await ensureAnonymousSession(page);
// Navigate to the home page before each test
await page.goto('/');
// Navigate to the protected app home before each test
await page.goto('/app');
await page.waitForLoadState('networkidle');
// AuthLoader may show a full-screen overlay while session is loading.
@ -216,7 +255,7 @@ export async function dispatchHtml5DragAndDrop(page: Page, source: Locator, targ
// Assert a document link containing the given filename appears in the list
export async function expectDocumentListed(page: Page, fileName: string) {
await expect(
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first()
).toBeVisible({ timeout: 10000 });
}
@ -291,11 +330,9 @@ export async function openSettingsDocumentsTab(page: Page) {
// Delete all local documents through Settings and close dialogs
export async function deleteAllLocalDocuments(page: Page) {
await openSettingsDocumentsTab(page);
// When auth is enabled in tests, button label is "Delete all user docs".
// In our tests, auth is enabled and sessions start anonymous, so label is "Delete anonymous docs".
await page.getByRole('button', { name: 'Delete anonymous docs' }).click();
await page.getByRole('button', { name: /Delete (anonymous docs|all user docs|server docs)/i }).click();
const heading = page.getByRole('heading', { name: 'Delete Anonymous Docs' });
const heading = page.getByRole('heading', { name: /Delete (Anonymous Docs|All User Docs|Server Docs)/i });
await expect(heading).toBeVisible({ timeout: 10000 });
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');

View file

@ -0,0 +1,53 @@
import { expect, test } from '@playwright/test';
import { setupTest, uploadAndDisplay } from './helpers';
const AUTH_ENABLED = Boolean(process.env.AUTH_SECRET && process.env.BASE_URL);
test.describe('Landing and app routing', () => {
test('public landing renders without anonymous auth bootstrap call', async ({ page }) => {
let anonymousSignInCalls = 0;
await page.route('**/api/auth/**', async (route) => {
if (route.request().url().includes('/sign-in/anonymous')) {
anonymousSignInCalls += 1;
}
await route.continue();
});
await page.goto('/');
await expect(page.getByRole('heading', { name: /your documents,\s*read aloud/i })).toBeVisible({
timeout: 10000,
});
// Let in-flight requests settle before asserting no anonymous bootstrap happened.
await page.waitForTimeout(500);
expect(anonymousSignInCalls).toBe(0);
});
test('existing authenticated session visiting / redirects to /app', async ({ page }) => {
test.skip(!AUTH_ENABLED);
await page.goto('/app');
await page.waitForLoadState('networkidle');
await page.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15000 }).catch(() => {});
await page.goto('/');
await expect(page).toHaveURL(/\/app$/);
});
test('documents back link returns to /app', async ({ page }, testInfo) => {
await setupTest(page, testInfo);
await uploadAndDisplay(page, 'sample.pdf');
await page.getByRole('link', { name: 'Documents' }).click();
await expect(page).toHaveURL(/\/app$/);
});
test('protected app routes redirect to /signin when anonymous auth is disabled', async ({ page }) => {
test.skip(!AUTH_ENABLED || process.env.USE_ANONYMOUS_AUTH_SESSIONS !== 'false');
await page.goto('/app');
await expect(page).toHaveURL(/\/signin$/);
});
});

View file

@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobook-scope';
test.describe('audiobook scope selection', () => {
test('uses only unclaimed scope when auth is disabled', () => {
const result = buildAllowedAudiobookUserIds(false, null, 'unclaimed::ns');
expect(result.preferredUserId).toBe('unclaimed::ns');
expect(result.allowedUserIds).toEqual(['unclaimed::ns']);
});
test('includes both preferred and unclaimed scopes when auth is enabled', () => {
const result = buildAllowedAudiobookUserIds(true, 'user-123', 'unclaimed::ns');
expect(result.preferredUserId).toBe('user-123');
expect(result.allowedUserIds).toEqual(['user-123', 'unclaimed::ns']);
});
test('deduplicates preferred/unclaimed ids when they are the same', () => {
const result = buildAllowedAudiobookUserIds(true, 'unclaimed::ns', 'unclaimed::ns');
expect(result.allowedUserIds).toEqual(['unclaimed::ns']);
});
test('prefers unclaimed owner when both scopes exist', () => {
const owner = pickAudiobookOwner(['user-123', 'unclaimed::ns'], 'user-123', 'unclaimed::ns');
expect(owner).toBe('unclaimed::ns');
});
test('falls back to preferred owner when unclaimed is missing', () => {
const owner = pickAudiobookOwner(['user-123'], 'user-123', 'unclaimed::ns');
expect(owner).toBe('user-123');
});
test('returns null when no matching owners exist', () => {
const owner = pickAudiobookOwner([], 'user-123', 'unclaimed::ns');
expect(owner).toBeNull();
});
});

View file

@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import { isAnonymousAuthSessionsEnabled } from '../../src/lib/server/auth-config';
const ORIGINAL_BASE_URL = process.env.BASE_URL;
const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET;
const ORIGINAL_USE_ANON = process.env.USE_ANONYMOUS_AUTH_SESSIONS;
function restoreEnv() {
if (ORIGINAL_BASE_URL === undefined) delete process.env.BASE_URL;
else process.env.BASE_URL = ORIGINAL_BASE_URL;
if (ORIGINAL_AUTH_SECRET === undefined) delete process.env.AUTH_SECRET;
else process.env.AUTH_SECRET = ORIGINAL_AUTH_SECRET;
if (ORIGINAL_USE_ANON === undefined) delete process.env.USE_ANONYMOUS_AUTH_SESSIONS;
else process.env.USE_ANONYMOUS_AUTH_SESSIONS = ORIGINAL_USE_ANON;
}
function setAuthEnabledEnv() {
process.env.BASE_URL = 'http://localhost:3003';
process.env.AUTH_SECRET = 'test-secret';
}
test.describe('auth config anonymous-session flag', () => {
test.afterEach(() => {
restoreEnv();
});
test('returns false when auth is disabled', () => {
delete process.env.BASE_URL;
delete process.env.AUTH_SECRET;
process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'true';
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
test('defaults to false when env var is unset', () => {
setAuthEnabledEnv();
delete process.env.USE_ANONYMOUS_AUTH_SESSIONS;
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
test('returns true only when env var is true', () => {
setAuthEnabledEnv();
process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'true';
expect(isAnonymousAuthSessionsEnabled()).toBe(true);
});
test('returns false when env var is false', () => {
setAuthEnabledEnv();
process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'false';
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
test('falls back to false for invalid values', () => {
setAuthEnabledEnv();
process.env.USE_ANONYMOUS_AUTH_SESSIONS = '1';
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
});