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:
parent
9f25e05cce
commit
1f4794a706
40 changed files with 2146 additions and 563 deletions
|
|
@ -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
3
.gitignore
vendored
|
|
@ -54,3 +54,6 @@ node_modules/
|
|||
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
# .agents
|
||||
.agents
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
62
src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 */}
|
||||
|
|
@ -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
327
src/app/(public)/layout.tsx
Normal 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
479
src/app/(public)/page.tsx
Normal 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 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">↑</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">¶</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">↔</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’s spoken, turning
|
||||
any document into a synchronized read-along experience. Connect
|
||||
any OpenAI-compatible TTS provider — 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">•</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">•</span>
|
||||
<div>
|
||||
<h4>Multiple voices & 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">•</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">•</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
200
src/app/(public)/privacy/page.tsx
Normal file
200
src/app/(public)/privacy/page.tsx
Normal 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 & <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 — 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
49
src/components/doclist/DocumentListSkeleton.tsx
Normal file
49
src/components/doclist/DocumentListSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
25
src/lib/server/audiobook-scope.ts
Normal file
25
src/lib/server/audiobook-scope.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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"]');
|
||||
|
|
|
|||
53
tests/landing-routing.spec.ts
Normal file
53
tests/landing-routing.spec.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
36
tests/unit/audiobook-scope.spec.ts
Normal file
36
tests/unit/audiobook-scope.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
64
tests/unit/auth-config.spec.ts
Normal file
64
tests/unit/auth-config.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue