update(auth): enhance rate limiting with device/IP backstops and improve UI

- Add device ID and IP-based rate limiting to prevent abuse
- Refactor UI components for better header menus and settings
- Update privacy popup with detailed data usage info
- Improve PDF handling and caching to prevent react-pdf warnings
- Update README with new Docker instructions and environment variables
This commit is contained in:
Richard R 2026-01-25 14:38:31 -07:00
parent 4689f3676f
commit 50c3829f98
30 changed files with 907 additions and 390 deletions

118
README.md
View file

@ -40,6 +40,7 @@ OpenReader WebUI is an open source text to speech document reader web app built
> **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below).
### 1. 🐳 Start the Docker container:
Minimal (no persistence, auth disabled unless you set auth env vars):
```bash
docker run --name openreader-webui \
--restart unless-stopped \
@ -47,20 +48,60 @@ OpenReader WebUI is an open source text to speech document reader web app built
ghcr.io/richardr1126/openreader-webui:latest
```
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
Fully featured (persistent storage + server library import + KokoroFastAPI in Docker + optional auth):
```bash
docker run --name openreader-webui \
--restart unless-stopped \
-e API_KEY=none \
-e API_BASE=http://host.docker.internal:8880/v1 \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
-v /path/to/your/library:/app/docstore/library:ro \
-e API_BASE=http://host.docker.internal:8880/v1 \
-e API_KEY=none \
-e BETTER_AUTH_URL=http://localhost:3003 \
-e BETTER_AUTH_SECRET=<paste_the_output_of_openssl_here> \
ghcr.io/richardr1126/openreader-webui:latest
```
You can remove the `/app/docstore/library` mount if you don't need server library import.
You can remove both `BETTER_AUTH_*` env vars to keep auth disabled.
> **Notes:**
> - `API_BASE` should point to your TTS API server's base URL (if running Kokoro-FastAPI locally in Docker, use `http://host.docker.internal:8880/v1`).
> - `BETTER_AUTH_URL` should be your externally-facing URL for this app (for example `https://reader.example.com` or `http://localhost:3003`).
> - To enable auth, set **both** `BETTER_AUTH_URL` and `BETTER_AUTH_SECRET` generated with `openssl rand -base64 32`.
> - If you set `POSTGRES_URL`, the container will not auto-run migrations for you; run `npx @better-auth/cli migrate -y` against your Postgres database.
<details>
<summary><strong>Docker environment variables</strong> (Click to expand)</summary>
| Variable | Purpose | Example / Notes |
| --- | --- | --- |
| `API_BASE` | Default TTS API base URL (server-side) | `http://host.docker.internal:8880/v1` |
| `API_KEY` | Default TTS API key | `none` or your provider key |
| `BETTER_AUTH_URL` | Enables auth when set with `BETTER_AUTH_SECRET` | External URL for this app, e.g. `http://localhost:3003` or `https://reader.example.com` |
| `BETTER_AUTH_SECRET` | Enables auth when set with `BETTER_AUTH_URL` | Generate with `openssl rand -base64 32` |
| `POSTGRES_URL` | Use Postgres for auth storage instead of SQLite | If set, run migrations manually (see note above) |
| `GITHUB_CLIENT_ID` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_SECRET` |
| `GITHUB_CLIENT_SECRET` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_ID` |
</details>
<details>
<summary><strong>Docker volume mounts</strong> (Click to expand)</summary>
| Mount | Type | Recommended | Purpose | Example |
| --- | --- | --- | --- | --- |
| `/app/docstore` | Docker named volume | Yes | Persists server-side storage (documents, audiobook exports, settings, SQLite DB if used) | `-v openreader_docstore:/app/docstore` |
| `/app/docstore/library` | Bind mount (host folder) | Optional + `:ro` | Exposes an existing folder of documents for **Server Library Import** | `-v /path/to/your/library:/app/docstore/library:ro` |
To import from the mounted library: **Settings → Documents → Server Library Import**
> **Note:** Every file in the mounted library is imported into the client browser's storage. Keep the library reasonably sized to avoid performance issues.
</details>
Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings.
> **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`.
### 2. ⚙️ Configure the app settings in the UI:
- Set the TTS Provider and Model in the Settings modal
- Set the TTS API Base URL and API Key if needed (more secure to set in env vars)
@ -73,60 +114,6 @@ docker rm openreader-webui && \
docker pull ghcr.io/richardr1126/openreader-webui:latest
```
### 📦 Volume mounts and Library import
By default (no volume mounts), OpenReader will store its server-side files inside the container filesystem (which is lost if you remove the container).
<details>
<summary>
**Persist server-side storage (`/app/docstore`)**
</summary>
Run the container with the volume mounted:
```bash
docker run --name openreader-webui \
--restart unless-stopped \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
ghcr.io/richardr1126/openreader-webui:latest
```
This will create a Docker named volume `openreader_docstore` to persist all server-side files, including:
- **Documents:** Stored under `/app/docstore/documents_v1`
- **Audiobook exports:** Stored under `/app/docstore/audiobooks_v1`
- Per-audiobook settings: `/app/docstore/audiobooks_v1/<bookId>-audiobook/audiobook.meta.json`
- Chapters: `0001__<title>.m4b` or `0001__<title>.mp3` (no per-chapter `.meta.json` files)
- **Settings**
This ensures that your documents, exported audiobooks, and server-side settings are retained even if the container is removed or recreated.
</details>
<details open>
<summary>
**Mount an external library folder (read-only recommended)**
</summary>
```bash
docker run --name openreader-webui \
--restart unless-stopped \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
-v /path/to/your/library:/app/docstore/library:ro \
ghcr.io/richardr1126/openreader-webui:latest
```
Separate from the main docstore volume, this mounts an external folder into the container at `/app/docstore/library` (read-only recommended) so OpenReader can use an existing library of documents.
To import from the mounted library: **Settings → Documents → Server Library Import**
> **Note:** Every file in the mounted volume is imported to the client browser's storage. Please ensure that the mounted library is not too large to avoid performance issues.
</details>
### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU)
You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version).
@ -239,12 +226,23 @@ Optionally required for different features:
cp template.env .env
# Edit .env with your configuration settings
```
Auth is recommended for contributors and is enabled when **both** values are set:
- Set `BETTER_AUTH_URL` to your local URL (default: `http://localhost:3003`)
- Generate a `BETTER_AUTH_SECRET` and paste it into `.env`:
```bash
openssl rand -base64 32
```
> Note: To disable auth, remove either `BETTER_AUTH_URL` or `BETTER_AUTH_SECRET`.
>
> Note: The base URL for the TTS API should be accessible and relative to the Next.js server
4. Run SQLite creation:
4. Run auth DB migrations (required when auth is enabled):
```bash
npx @better-auth/cli migrate -y
```
> Note: If you set `POSTGRES_URL` in `.env`, migrations will target Postgres instead of local SQLite.
5. Start the development server:

View file

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View file

@ -1,8 +1,10 @@
import { NextResponse } from 'next/server';
import { NextResponse, type NextRequest } from 'next/server';
import { auth } from '@/lib/server/auth';
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth-config';
import { getClientIp } from '@/lib/server/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id';
export const dynamic = 'force-dynamic';
@ -14,7 +16,7 @@ function getUtcResetTimeIso(): string {
return tomorrow.toISOString();
}
export async function GET() {
export async function GET(req: NextRequest) {
try {
// If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) {
@ -53,12 +55,21 @@ export async function GET() {
const isAnonymous = Boolean(session.user.isAnonymous);
const result = await rateLimiter.getCurrentUsage({
id: session.user.id,
isAnonymous
});
const ip = getClientIp(req);
const device = isAnonymous ? getOrCreateDeviceId(req) : null;
return NextResponse.json({
const result = await rateLimiter.getCurrentUsage(
{
id: session.user.id,
isAnonymous,
},
{
deviceId: device?.deviceId ?? null,
ip,
}
);
const response = NextResponse.json({
allowed: result.allowed,
currentCount: result.currentCount,
limit: result.limit,
@ -67,6 +78,12 @@ export async function GET() {
userType: isAnonymous ? 'anonymous' : 'authenticated',
authEnabled: true
});
if (device?.didCreate) {
setDeviceIdCookie(response, device.deviceId);
}
return response;
} catch (error) {
console.error('Error getting rate limit status:', error);
return NextResponse.json(

View file

@ -10,6 +10,8 @@ import { headers } from 'next/headers';
import { auth } from '@/lib/server/auth';
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
import { isAuthEnabled } from '@/lib/server/auth-config';
import { getClientIp } from '@/lib/server/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id';
type CustomVoice = string;
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
@ -141,6 +143,9 @@ export async function POST(req: NextRequest) {
}
// Auth and rate limiting check (only when auth is enabled)
let didCreateDeviceIdCookie = false;
let deviceIdToSet: string | null = null;
if (isAuthEnabled() && auth) {
const session = await auth.api.getSession({
headers: await headers()
@ -156,10 +161,21 @@ export async function POST(req: NextRequest) {
const isAnonymous = Boolean(session.user.isAnonymous);
const charCount = text.length;
const ip = getClientIp(req);
const device = isAnonymous ? getOrCreateDeviceId(req) : null;
if (device?.didCreate) {
didCreateDeviceIdCookie = true;
deviceIdToSet = device.deviceId;
}
// Check rate limit
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
{ id: session.user.id, isAnonymous },
charCount
charCount,
{
deviceId: device?.deviceId ?? null,
ip,
}
);
if (!rateLimitResult.allowed) {
@ -186,13 +202,19 @@ export async function POST(req: NextRequest) {
instance: req.nextUrl.pathname,
};
return new NextResponse(JSON.stringify(problem), {
const response = new NextResponse(JSON.stringify(problem), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
'Retry-After': String(retryAfterSeconds),
},
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
return response;
}
}
@ -254,7 +276,7 @@ export async function POST(req: NextRequest) {
const cachedBuffer = ttsAudioCache.get(cacheKey);
if (cachedBuffer) {
if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) {
return new NextResponse(null, {
const response = new NextResponse(null, {
status: 304,
headers: {
'ETag': etag,
@ -262,9 +284,15 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
return response;
}
console.log('TTS cache HIT for key:', cacheKey.slice(0, 8));
return new NextResponse(cachedBuffer, {
const response = new NextResponse(cachedBuffer, {
headers: {
'Content-Type': contentType,
'X-Cache': 'HIT',
@ -274,6 +302,12 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
return response;
}
// De-duplicate identical in-flight requests
@ -292,7 +326,7 @@ export async function POST(req: NextRequest) {
try {
const buffer = await existing.promise;
return new NextResponse(buffer, {
const response = new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'X-Cache': 'INFLIGHT',
@ -302,6 +336,12 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
return response;
} finally {
try { req.signal.removeEventListener('abort', onAbort); } catch { }
}
@ -340,7 +380,7 @@ export async function POST(req: NextRequest) {
try { req.signal.removeEventListener('abort', onAbort); } catch { }
}
return new NextResponse(buffer, {
const response = new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'X-Cache': 'MISS',
@ -350,6 +390,12 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
if (didCreateDeviceIdCookie && deviceIdToSet) {
setDeviceIdCookie(response, deviceIdToSet);
}
return response;
} catch (error) {
// Check if this was an abort error
if (error instanceof Error && error.name === 'AbortError') {

View file

@ -12,14 +12,14 @@ import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { ZoomControl } from '@/components/ZoomControl';
import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { DownloadIcon } from '@/components/icons/Icons';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { resolveDocumentId } from '@/lib/dexie';
import { RateLimitBanner } from '@/components/rate-limit-banner';
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { UserMenu } from '@/components/auth/UserMenu';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -154,30 +154,16 @@ export default function EPUBPage() {
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-3">
<ZoomControl
value={padPct}
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
min={0}
max={100}
<DocumentHeaderMenu
zoomLevel={padPct}
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
onOpenSettings={() => setIsSettingsOpen(true)}
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
isDev={isDev}
minZoom={0}
maxZoom={100}
/>
{isDev && (
<button
onClick={() => setIsAudiobookModalOpen(true)}
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.09] hover:text-accent"
aria-label="Open audiobook export"
title="Export Audiobook"
>
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
)}
<button
onClick={() => setIsSettingsOpen(true)}
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.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
</div>
}
/>

View file

@ -8,12 +8,11 @@ import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { HTMLViewer } from '@/components/HTMLViewer';
import { DocumentSettings } from '@/components/DocumentSettings';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { SettingsIcon } from '@/components/icons/Icons';
import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl';
import { resolveDocumentId } from '@/lib/dexie';
import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
import { RateLimitBanner } from '@/components/rate-limit-banner';
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
@ -62,7 +61,6 @@ export default function HTMLPage() {
}, [loadDocument]);
// Compute available height = viewport - (header height + tts bar height)
<RateLimitPauseButton />
useEffect(() => {
const compute = () => {
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
@ -122,20 +120,14 @@ export default function HTMLPage() {
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-3">
<ZoomControl
value={padPct}
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
min={0}
max={100}
<DocumentHeaderMenu
zoomLevel={padPct}
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
onOpenSettings={() => setIsSettingsOpen(true)}
minZoom={0}
maxZoom={100}
/>
<button
onClick={() => setIsSettingsOpen(true)}
className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
</div>
}
/>
@ -153,6 +145,7 @@ export default function HTMLPage() {
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>
</div>

View file

@ -1,3 +1,4 @@
import { Header } from '@/components/Header';
import { HomeContent } from '@/components/HomeContent';
import { SettingsModal } from '@/components/SettingsModal';
import { UserMenu } from '@/components/auth/UserMenu';
@ -9,23 +10,26 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
export default function Home() {
return (
<div className="flex flex-col h-full w-full">
<Header
title={
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="w-5 h-5" aria-hidden="true" />
<h1 className="text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">OpenReader</h1>
</div>
}
right={
<div className="flex items-center gap-2">
<SettingsModal />
<UserMenu />
</div>
}
/>
<AuthLoader>
<>
<UserMenu />
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
</p>
</div>
</section>
<section className="flex-1 px-4 pb-8 overflow-auto">
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
<div className="max-w-7xl mx-auto">
<RateLimitBanner className="mb-6" />
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
<HomeContent />
</div>
</section>

View file

@ -8,9 +8,8 @@ import { useCallback, useEffect, useState } from 'react';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
import { Header } from '@/components/Header';
import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
@ -19,6 +18,7 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { resolveDocumentId } from '@/lib/dexie';
import { RateLimitBanner } from '@/components/rate-limit-banner';
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { UserMenu } from '@/components/auth/UserMenu';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -60,7 +60,7 @@ export default function PDFViewerPage() {
router.replace(`/pdf/${resolved}`);
return;
}
setCurrentDocument(resolved);
await setCurrentDocument(resolved);
} catch (err) {
console.error('Error loading document:', err);
setError('Failed to load document');
@ -149,24 +149,16 @@ export default function PDFViewerPage() {
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-2">
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
{isDev && (
<button
onClick={() => setIsAudiobookModalOpen(true)}
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.09] hover:text-accent"
aria-label="Open audiobook export"
title="Export Audiobook"
>
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
)}
<button
onClick={() => setIsSettingsOpen(true)}
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.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
<DocumentHeaderMenu
zoomLevel={zoomLevel}
onZoomIncrease={handleZoomIn}
onZoomDecrease={handleZoomOut}
onOpenSettings={() => setIsSettingsOpen(true)}
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
isDev={isDev}
minZoom={50}
maxZoom={300}
/>
</div>
}
/>

View file

@ -27,7 +27,7 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps
<EPUBProvider>
<HTMLProvider>
{children}
<PrivacyPopup />
<PrivacyPopup authEnabled={authEnabled} />
</HTMLProvider>
</EPUBProvider>
</PDFProvider>

View file

@ -266,7 +266,7 @@ function SignInContent() {
<p className="text-xs text-muted">
By signing in, you agree to our{' '}
<button
onClick={() => showPrivacyPopup()}
onClick={() => showPrivacyPopup({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy

View file

@ -229,7 +229,7 @@ export default function SignUpPage() {
<p className="text-xs text-muted">
By creating an account, you agree to our{' '}
<button
onClick={() => showPrivacyPopup()}
onClick={() => showPrivacyPopup({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy

View file

@ -0,0 +1,149 @@
'use client';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu';
interface DocumentHeaderMenuProps {
zoomLevel: number;
onZoomIncrease: () => void;
onZoomDecrease: () => void;
onOpenSettings: () => void;
onOpenAudiobook?: () => void;
isDev?: boolean;
minZoom?: number;
maxZoom?: number;
}
export function DocumentHeaderMenu({
zoomLevel,
onZoomIncrease,
onZoomDecrease,
onOpenSettings,
onOpenAudiobook,
isDev,
minZoom = 0,
maxZoom = 100
}: DocumentHeaderMenuProps) {
// --- Desktop View ---
const DesktopView = (
<div className="hidden sm:flex items-center gap-2">
<ZoomControl
value={zoomLevel}
onIncrease={onZoomIncrease}
onDecrease={onZoomDecrease}
min={minZoom}
max={maxZoom}
/>
{isDev && onOpenAudiobook && (
<button
onClick={onOpenAudiobook}
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.09] hover:text-accent"
aria-label="Open audiobook export"
title="Export Audiobook"
>
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
)}
<button
onClick={onOpenSettings}
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.09] hover:text-accent"
aria-label="Open settings"
>
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
<UserMenu />
</div>
);
// --- Mobile View ---
const MobileView = (
<div className="sm:hidden flex items-center">
<Menu as="div" className="relative inline-block text-left">
<MenuButton
className="inline-flex items-center justify-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.09] hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
title="Menu"
>
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className="absolute right-0 mt-2 min-w-max origin-top-right divide-y divide-offbase rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
{/* Zoom Controls Section */}
<div className="px-4 py-3">
<p className="text-xs font-medium text-muted mb-2">Zoom / Padding</p>
<div className="flex justify-center">
<ZoomControl
value={zoomLevel}
onIncrease={() => {
// We wrap in a handler to stop propagation if needed,
// but ZoomControl buttons handle their own clicks.
// However, Menu might close on click?
// Headless UI Menu closes on click inside MenuItem, but these are just buttons in a div.
// It should NOT close unless we click a MenuItem.
onZoomIncrease();
}}
onDecrease={onZoomDecrease}
min={minZoom}
max={maxZoom}
/>
</div>
</div>
{/* Actions Section */}
<div className="p-1">
{isDev && onOpenAudiobook && (
<MenuItem>
{({ active }) => (
<button
onClick={onOpenAudiobook}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<DownloadIcon className="h-4 w-4" />
Export Audiobook
</button>
)}
</MenuItem>
)}
<MenuItem>
{({ active }) => (
<button
onClick={onOpenSettings}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FileSettingsIcon className="h-4 w-4" />
Settings
</button>
)}
</MenuItem>
</div>
{/* Auth Section */}
<div className="p-2 border-t border-offbase flex justify-center">
<UserMenu />
</div>
</MenuItems>
</Transition>
</Menu>
</div>
);
return (
<>
{DesktopView}
{MobileView}
</>
);
}

View file

@ -1,34 +1,33 @@
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
'use client'
import { GithubIcon } from '@/components/icons/Icons'
import { CodeBlock } from '@/components/CodeBlock'
import { showPrivacyPopup } from '@/components/privacy-popup'
import { useAuthConfig } from '@/contexts/AutoRateLimitContext'
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"
href="https://github.com/richardr1126/OpenReader-WebUI#readme"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
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>
<Popover className="flex">
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none flex items-center gap-1">
Privacy info
</PopoverButton>
<PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-xl border border-offbase z-50">
<p className='max-w-xs'>Documents are uploaded to your local browser cache.</p>
<p className='mt-3 max-w-xs'>Each paragraph of the document you are viewing is sent to Deepinfra for audio generation through a Vercel backend proxy, containing a shared caching pool.</p>
<p className='mt-3 max-w-xs'>The audio is streamed back to your browser and played in real-time.</p>
<p className='mt-3 max-w-xs font-bold'><em>Self-hosting is the recommended way to use this app for a truly secure experience.</em></p>
{/* Vercel analytics disclaimer */}
<p className='mt-3 max-w-xs'>This site uses Vercel Analytics to collect anonymous usage data to help improve the service.</p>
</PopoverPanel>
</Popover>
<button
type="button"
onClick={() => showPrivacyPopup({ authEnabled })}
className="font-bold hover:text-foreground transition-colors outline-none"
>
Privacy
</button>
<span className='w-full sm:w-fit'></span>
<span>
Powered by{' '}
@ -51,61 +50,6 @@ export function Footer() {
</a>
</span>
</div>
<div className='font-medium text-center inline-flex truncate items-center justify-center gap-1'>
<span>This is a demo app (</span>
<Popover className="relative">
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none inline">
self-host
</PopoverButton>
<PopoverPanel anchor="top" className="bg-base p-6 rounded-xl shadow-2xl border border-offbase w-[90vw] max-w-3xl z-50 backdrop-blur-md flex flex-col gap-4">
<div className="space-y-4 font-medium">
<h3 className="text-lg font-bold text-foreground">Self-Hosting Instructions</h3>
<div>
<p className="mb-2 font-medium">
1. Start the <a href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground underline decoration-dotted underline-offset-4">Kokoro-FastAPI</a> container
</p>
<CodeBlock>
{
`docker run -d \\
--name kokoro-tts \\
--restart unless-stopped \\
-p 8880:8880 \\
-e ONNX_NUM_THREADS=8 \\
-e ONNX_INTER_OP_THREADS=4 \\
-e ONNX_EXECUTION_MODE=parallel \\
-e ONNX_OPTIMIZATION_LEVEL=all \\
-e ONNX_MEMORY_PATTERN=true \\
-e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \\
-e API_LOG_LEVEL=DEBUG \\
ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4`
}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-foreground font-medium">2. Start OpenReader WebUI container</p>
<CodeBlock>
{
`docker run --name openreader-webui --rm \\
-e API_BASE=http://kokoro-tts:8880/v1 \\
-p 3003:3003 \\
-v openreader_docstore:/app/docstore \\
-v /path/to/your/library:/app/docstore/library:ro \\
ghcr.io/richardr1126/openreader-webui:latest`
}
</CodeBlock>
</div>
<p>
Visit <a href="http://localhost:3003" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">http://localhost:3003</a> to run the app and set your settings.
{' '}See the <a href="https://github.com/richardr1126/OpenReader-WebUI#readme" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">README</a> for more details.
</p>
</div>
</PopoverPanel>
</Popover>
<span> for full functionality)</span>
</div>
</div>
</footer>
)

View file

@ -15,7 +15,7 @@ export function Header({
<div className="flex items-center gap-2 min-w-0 flex-1">
{left}
{typeof title === 'string' ? (
<h1 className="text-xs sm:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
) : (
title
)}

View file

@ -49,12 +49,30 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
clearWordHighlights,
highlightWordIndex,
onDocumentLoadSuccess,
currDocId,
currDocData,
currDocPages,
currDocText,
currDocPage,
} = usePDF();
// IMPORTANT:
// - pdf.js may transfer/detach ArrayBuffers when sending them to its worker, so we must clone.
// - react-pdf warns if `file` changes by reference but is deep-equal to the previous value.
// Cache a stable `{ data: Uint8Array }` by `currDocId` so reloading the same PDF with
// identical bytes doesn't create a new `file` object and trigger the warning.
const fileCacheRef = useRef<Map<string, { data: Uint8Array }>>(new Map());
if (currDocId && currDocData && !fileCacheRef.current.has(currDocId)) {
try {
fileCacheRef.current.set(currDocId, { data: new Uint8Array(currDocData.slice(0)) });
} catch (e) {
console.error('Failed to prepare PDF data for viewer:', e);
}
}
const documentFile = currDocId ? fileCacheRef.current.get(currDocId) : undefined;
const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;
const clearSentenceHighlightTimeouts = useCallback(() => {
@ -266,9 +284,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
return (
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
<Document
key={currDocId || 'pdf'}
loading={<DocumentSkeleton />}
noData={<DocumentSkeleton />}
file={currDocData}
file={documentFile}
onLoadSuccess={(pdf) => {
onDocumentLoadSuccess(pdf);
}}

View file

@ -37,6 +37,7 @@ import { useAuthSession } from '@/hooks/useAuth';
import { markSignedOut, clearSignedOut } from '@/lib/session-utils';
import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { useRouter } from 'next/navigation';
import { showPrivacyPopup } from '@/components/privacy-popup';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -45,7 +46,7 @@ const themes = THEMES.map(id => ({
name: id.charAt(0).toUpperCase() + id.slice(1)
}));
export function SettingsModal() {
export function SettingsModal({ className = '' }: { className?: string }) {
const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme();
@ -393,11 +394,11 @@ export function SettingsModal() {
<>
<Button
onClick={() => setIsOpen(true)}
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent absolute top-2 right-2 sm:top-4 sm:right-4"
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.09] hover:text-accent ${className}`}
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform duration-200 ease-in-out hover:rotate-45" />
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</Button>
<Transition appear show={isOpen} as={Fragment}>
@ -426,13 +427,22 @@ export function SettingsModal() {
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<div className="flex items-center justify-between gap-3 mb-4">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4"
className="text-lg font-semibold leading-6 text-foreground"
>
Settings
</DialogTitle>
<Button
onClick={() => showPrivacyPopup({ authEnabled })}
className="text-sm font-medium text-muted hover:text-accent"
>
Privacy
</Button>
</div>
<TabGroup>
<TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4">
{tabs.map((tab) => (
@ -520,6 +530,24 @@ export function SettingsModal() {
</Listbox>
</div>
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="text"
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
)}
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
API Key
@ -620,24 +648,6 @@ export function SettingsModal() {
</div>
)}
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<div className="flex gap-2">
<Input
type="text"
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
)}
<div className="pt-4 flex justify-end gap-2">
<Button
type="button"
@ -863,12 +873,12 @@ export function SettingsModal() {
</p>
<div className="grid grid-cols-2 gap-3">
<Link href="/signin" className="w-full">
<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">
<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]">
Log In
</Button>
</Link>
<Link href="/signup" className="w-full">
<Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent">
<Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent 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]">
Sign Up
</Button>
</Link>

View file

@ -8,7 +8,7 @@ import { getAuthClient } from '@/lib/auth-client';
import { clearSignedOut } from '@/lib/session-utils';
import { useRouter } from 'next/navigation';
export function UserMenu() {
export function UserMenu({ className = '' }: { className?: string }) {
const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAutoRateLimit();
const { data: session, isPending } = useAuthSession();
@ -27,14 +27,14 @@ export function UserMenu() {
if (!session || session.user.isAnonymous) {
return (
<div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex gap-2">
<div className={`flex gap-2 ${className}`}>
<Link href="/signin">
<Button className="rounded-lg bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent hover:bg-accent/20">
{session?.user.isAnonymous ? 'Log In ' : 'Sign In'}
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent">
{session?.user.isAnonymous ? 'Log In' : 'Sign In'}
</Button>
</Link>
<Link href="/signup" className="hidden sm:block">
<Button className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent">
<Link href="/signup">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]">
Sign Up
</Button>
</Link>
@ -43,22 +43,22 @@ export function UserMenu() {
}
return (
<div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex items-center gap-3">
<div className="hidden sm:flex flex-col items-end">
<span className="text-xs font-medium text-foreground">
<div className={`flex items-center gap-2 ${className}`}>
<div className="hidden sm:flex flex-col items-end mr-1">
<span className="text-xs font-medium text-foreground leading-none mb-0.5">
{session.user.name || session.user.email || 'Account'}
</span>
<span className="text-[10px] text-muted truncate max-w-[120px]">
<span className="text-[10px] text-muted truncate max-w-[120px] leading-none">
{session.user.email}
</span>
</div>
<Button
onClick={handleSignOut}
className="p-2 text-foreground/70 hover:text-red-500 transition-colors"
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.09] hover:text-red-500"
title="Sign Out"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>

View file

@ -233,7 +233,7 @@ export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
strokeWidth='0.25'
{...props}
>
<path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z"/>
<path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z" />
</svg>
);
}
@ -510,3 +510,21 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function FileSettingsIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 36 36"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<title>file-settings-solid</title>
<path d="M15.55,31H6V5H26v8.78a2.37,2.37,0,0,1,2,1.57V5a2,2,0,0,0-2-2H6A2,2,0,0,0,4,5V31a2,2,0,0,0,2,2H17.16l-1-1A2.38,2.38,0,0,1,15.55,31Z" className="clr-i-solid clr-i-solid-path-1"></path>
<path d="M33.54,23.47l-2-.61a7.06,7.06,0,0,0-.58-1.41l1-1.86a.37.37,0,0,0-.07-.44L30.41,17.7a.37.37,0,0,0-.44-.07l-1.85,1A7,7,0,0,0,26.69,18l-.61-2a.37.37,0,0,0-.36-.25h-2a.37.37,0,0,0-.35.26l-.61,2a7,7,0,0,0-1.44.61l-1.82-1a.37.37,0,0,0-.44.07l-1.47,1.44a.37.37,0,0,0-.07.44l1,1.82a7,7,0,0,0-.61,1.44l-2,.61a.37.37,0,0,0-.26.35v2a.37.37,0,0,0,.26.35l2,.61a7,7,0,0,0,.61,1.41l-1,1.9a.37.37,0,0,0,.07.44L19,32a.37.37,0,0,0,.44.07l1.87-1a7.06,7.06,0,0,0,1.39.57l.61,2a.37.37,0,0,0,.35.26h2a.37.37,0,0,0,.35-.26l.61-2a7,7,0,0,0,1.38-.57l1.89,1a.37.37,0,0,0,.44-.07l1.45-1.45a.37.37,0,0,0,.07-.44l-1-1.88a7.06,7.06,0,0,0,.58-1.39l2-.61a.37.37,0,0,0,.26-.35V23.83A.37.37,0,0,0,33.54,23.47ZM24.7,28.19A3.33,3.33,0,1,1,28,24.86,3.33,3.33,0,0,1,24.7,28.19Z" className="clr-i-solid clr-i-solid-path-2"></path>
<rect x="0" y="0" width="36" height="36" fillOpacity="0" />
</svg>
);
}

View file

@ -11,12 +11,123 @@ import {
} from '@headlessui/react';
import { updateAppConfig, getAppConfig } from '@/lib/dexie';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
interface PrivacyPopupProps {
onAccept?: () => void;
authEnabled?: boolean;
}
export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
function PrivacyPopupBody({
origin,
authEnabled,
}: {
origin: string;
authEnabled: boolean;
}) {
if (!isDev) {
return (
<div className="mt-4 space-y-4 text-sm text-foreground/90">
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service operator visibility</div>
<div className="mt-2">
This OpenReader instance is hosted at <span className="font-bold">{origin || 'this server'}</span>. The operator
of this service can access data that reaches the service.
</div>
</div>
<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>
</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>
</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">
{authEnabled ? (
<li>Auth users data and IP rate limiting data are stored in the service database</li>
) : (
<li>Authentication is disabled, so no user/session database is used</li>
)}
</ul>
</div>
<div className="text-xs text-muted">
This site uses Vercel Analytics to collect anonymous usage data. For maximum privacy, use self-hosted mode.
</div>
</div>
);
}
return (
<div className="mt-4 space-y-4 text-sm text-foreground/90">
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Server owner visibility</div>
<div className="mt-2">
This OpenReader instance is hosted at <span className="font-bold">{origin || 'this server'}</span>. The operator
of this server can access data that reaches the server.
</div>
</div>
<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>
</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>
</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>
{authEnabled ? (
<li>Auth users data and IP rate limiting data are stored in the server's database</li>
) : (
<li>Authentication is disabled on this server, so no server-side user/session database is used</li>
)}
</ul>
</div>
<div className="text-xs text-muted">
Tip: If you are behind a reverse proxy, the proxy operator may also have access to request logs.
</div>
</div>
);
}
export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProps) {
const [isOpen, setIsOpen] = useState(false);
const [origin, setOrigin] = useState('');
const checkPrivacyAccepted = useCallback(async () => {
const config = await getAppConfig();
@ -29,6 +140,11 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
checkPrivacyAccepted();
}, [checkPrivacyAccepted]);
useEffect(() => {
if (typeof window === 'undefined') return;
setOrigin(window.location.origin);
}, []);
const handleAccept = async () => {
await updateAppConfig({ privacyAccepted: true });
setIsOpen(false);
@ -72,21 +188,7 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
Privacy & Data Usage
</DialogTitle>
<div className="mt-4 space-y-3 text-sm text-foreground/90">
<p>Documents are uploaded to your local browser cache.</p>
<p>
Each paragraph of the document you are viewing is sent to Deepinfra
for audio generation through a Vercel backend proxy, containing a
shared caching pool.
</p>
<p>The audio is streamed back to your browser and played in real-time.</p>
<p className="font-semibold italic">
Self-hosting is the recommended way to use this app for a truly secure experience.
</p>
<p className="text-xs text-muted">
This site uses Vercel Analytics to collect anonymous usage data to help improve the service.
</p>
</div>
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
<div className="mt-6 flex justify-end">
<Button
@ -113,12 +215,15 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
* Function to programmatically show the privacy popup
* This can be called from signin/signup components
*/
export function showPrivacyPopup(): void {
export function showPrivacyPopup(options?: { authEnabled?: boolean }): void {
// Create a temporary container for the popup
const container = document.createElement('div');
container.id = 'privacy-popup-container';
document.body.appendChild(container);
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const authEnabled = Boolean(options?.authEnabled);
// Import React and render the popup
import('react-dom/client').then(({ createRoot }) => {
import('react').then((React) => {
@ -171,21 +276,7 @@ export function showPrivacyPopup(): void {
Privacy & Data Usage
</DialogTitle>
<div className="mt-4 space-y-3 text-sm text-foreground/90">
<p>Documents are uploaded to your local browser cache.</p>
<p>
Each paragraph of the document you are viewing is sent to Deepinfra
for audio generation through a Vercel backend proxy, containing a
shared caching pool.
</p>
<p>The audio is streamed back to your browser and played in real-time.</p>
<p className="font-semibold italic">
Self-hosting is the recommended way to use this app for a truly secure experience.
</p>
<p className="text-xs text-muted">
This site uses Vercel Analytics to collect anonymous usage data.
</p>
</div>
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
<div className="mt-6 flex justify-end">
<Button

View file

@ -58,6 +58,7 @@ import type {
*/
interface PDFContextType {
// Current document state
currDocId: string | undefined;
currDocData: ArrayBuffer | undefined;
currDocName: string | undefined;
currDocPages: number | undefined;
@ -136,6 +137,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
} = useConfig();
// Current document state
const [currDocId, setCurrDocId] = useState<string>();
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
@ -144,6 +146,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
// Used to cancel/ignore in-flight text extraction when the document changes
// or when react-pdf tears down and recreates its internal worker.
const pdfDocGenerationRef = useRef(0);
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
useEffect(() => {
pdfDocumentRef.current = pdfDocument;
}, [pdfDocument]);
useEffect(() => {
setCurrDocPage(currDocPageNumber);
}, [currDocPageNumber]);
@ -155,9 +166,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
*/
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
console.log('Document loaded:', pdf.numPages);
pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = pdf;
setCurrDocPages(pdf.numPages);
setPdfDocument(pdf);
}, [setCurrDocPages]);
}, [setCurrDocPages, setPdfDocument]);
/**
* Loads and processes text from the current document page
@ -167,7 +180,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
*/
const loadCurrDocText = useCallback(async () => {
try {
if (!pdfDocument) return;
const generation = pdfDocGenerationRef.current;
const currentPdf = pdfDocumentRef.current;
if (!currentPdf) return;
const margins = {
header: headerMargin,
@ -177,6 +192,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
};
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
// Ignore stale/in-flight work if the document or worker changed.
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
throw new DOMException('Stale PDF extraction', 'AbortError');
}
if (pageTextCacheRef.current.has(pageNumber)) {
const cached = pageTextCacheRef.current.get(pageNumber)!;
if (!shouldCache) {
@ -185,14 +205,19 @@ export function PDFProvider({ children }: { children: ReactNode }) {
return cached;
}
const extracted = await extractTextFromPDF(pdfDocument, pageNumber, margins);
const extracted = await extractTextFromPDF(currentPdf, pageNumber, margins);
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
throw new DOMException('Stale PDF extraction', 'AbortError');
}
if (shouldCache) {
pageTextCacheRef.current.set(pageNumber, extracted);
}
return extracted;
};
const totalPages = currDocPages ?? pdfDocument.numPages;
const totalPages = currDocPages ?? currentPdf.numPages;
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
const [text, nextText] = await Promise.all([
@ -200,6 +225,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined),
]);
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return;
}
if (text !== currDocText || text === '') {
setCurrDocText(text);
setTTSText(text, {
@ -209,10 +238,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
});
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
console.error('Error loading PDF text:', error);
}
}, [
pdfDocument,
currDocPageNumber,
currDocPages,
setTTSText,
@ -228,10 +259,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
* Triggers text extraction and processing when either the document URL or page changes
*/
useEffect(() => {
if (currDocData) {
if (currDocData && pdfDocument) {
loadCurrDocText();
}
}, [currDocPageNumber, currDocData, loadCurrDocText]);
}, [currDocPageNumber, currDocData, pdfDocument, loadCurrDocText]);
/**
* Sets the current document based on its ID
@ -242,21 +273,38 @@ export function PDFProvider({ children }: { children: ReactNode }) {
*/
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
try {
// Reset any state tied to the previously loaded PDF. This prevents calling
// `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects
// or fast refresh.
pdfDocGenerationRef.current += 1;
pageTextCacheRef.current.clear();
setPdfDocument(undefined);
setCurrDocPages(undefined);
setCurrDocText(undefined);
setCurrDocId(id);
setCurrDocName(undefined);
setCurrDocData(undefined);
const doc = await getPdfDocument(id);
if (doc) {
setCurrDocName(doc.name);
setCurrDocData(doc.data);
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
// buffer passed into the worker; we always pass clones to react-pdf.
setCurrDocData(doc.data.slice(0));
}
} catch (error) {
console.error('Failed to get document:', error);
}
}, []);
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]);
/**
* Clears the current document state
* Resets all document-related states and stops any ongoing TTS playback
*/
const clearCurrDoc = useCallback(() => {
pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = undefined;
setCurrDocId(undefined);
setCurrDocName(undefined);
setCurrDocData(undefined);
setCurrDocText(undefined);
@ -264,7 +312,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
setPdfDocument(undefined);
pageTextCacheRef.current.clear();
stop();
}, [setCurrDocPages, stop]);
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]);
/**
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
@ -618,6 +666,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
() => ({
onDocumentLoadSuccess,
setCurrentDocument,
currDocId,
currDocData,
currDocName,
currDocPages,
@ -636,6 +685,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
[
onDocumentLoadSuccess,
setCurrentDocument,
currDocId,
currDocData,
currDocName,
currDocPages,

View file

@ -754,7 +754,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* @param {string} sentence - The sentence to generate audio for
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer
*/
const getAudio = useCallback(async (sentence: string): Promise<TTSAudioBuffer | undefined> => {
const getAudio = useCallback(async (sentence: string, preload = false): Promise<TTSAudioBuffer | undefined> => {
const alignmentEnabledForCurrentDoc =
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
@ -897,7 +897,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return;
}
// If a preload request fails, we should not flip the global playback state.
// Otherwise the UI can lose the pause button while the current sentence
// continues playing.
if (!preload) {
setIsPlaying(false);
}
// Handle daily quota exceeded (429 + Problem Details code)
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
@ -973,7 +978,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Create the audio processing promise
const processPromise = (async () => {
try {
const audioBuffer = await getAudio(sentence);
const audioBuffer = await getAudio(sentence, preload);
if (!audioBuffer) {
// If quota or other handled error returns undefined, ensure we don't throw "No audio data"
// Just return empty string to signal graceful failure/skip

View file

@ -298,8 +298,22 @@ export async function extractTextFromPDF(
return pageText.replace(/\s+/g, ' ').trim();
} catch (error) {
// During Next.js fast refresh / route transitions, react-pdf can tear down the
// underlying worker and pdf.js may throw a TypeError like:
// "null is not an object (evaluating 'this.messageHandler.sendWithPromise')".
// Treat this as a cancellation so the app can ignore it.
if (
error instanceof TypeError &&
typeof error.message === 'string' &&
error.message.includes('messageHandler') &&
error.message.includes('sendWithPromise')
) {
throw new DOMException('PDF worker torn down', 'AbortError');
}
console.error('Error extracting text from PDF:', error);
throw new Error('Failed to extract text from PDF');
// Preserve the original error so callers can decide whether to retry/ignore.
throw error;
}
}

View file

@ -0,0 +1,41 @@
import type { NextRequest } from 'next/server';
import { randomUUID } from 'crypto';
export const DEVICE_ID_COOKIE = 'or_device_id';
function isPlausibleDeviceId(value: string | undefined | null): value is string {
if (!value) return false;
// UUID v4 is typical here, but accept any reasonably sized token.
return value.length >= 16 && value.length <= 128;
}
export function getDeviceId(req: NextRequest): string | null {
const existing = req.cookies.get(DEVICE_ID_COOKIE)?.value;
return isPlausibleDeviceId(existing) ? existing : null;
}
/**
* Returns a stable anonymous device identifier.
*
* This survives localStorage/IndexedDB clears, but not full cookie clears.
*/
export function getOrCreateDeviceId(req: NextRequest): { deviceId: string; didCreate: boolean } {
const existing = getDeviceId(req);
if (existing) return { deviceId: existing, didCreate: false };
return { deviceId: randomUUID(), didCreate: true };
}
// NextResponse.cookies.set has an overloaded signature; keep this loosely typed.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setDeviceIdCookie(res: { cookies: { set: (...args: any[]) => any } }, deviceId: string): void {
res.cookies.set({
name: DEVICE_ID_COOKIE,
value: deviceId,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
// ~2 years
maxAge: 60 * 60 * 24 * 365 * 2,
});
}

View file

@ -4,7 +4,12 @@ import { isAuthEnabled } from '@/lib/server/auth-config';
// Rate limits configuration - character counts per day
export const RATE_LIMITS = {
ANONYMOUS: 50_000, // 50K characters per day for anonymous users
AUTHENTICATED: 500_000 // 500K characters per day for authenticated users
AUTHENTICATED: 500_000, // 500K characters per day for authenticated users
// IP-based backstop limits to make it harder to reset limits by creating new accounts
// or clearing storage/cookies. These are intentionally conservative defaults and can
// be tuned via env vars.
IP_ANONYMOUS: Number(process.env.TTS_IP_DAILY_LIMIT_ANONYMOUS || 100_000),
IP_AUTHENTICATED: Number(process.env.TTS_IP_DAILY_LIMIT_AUTHENTICATED || 1_000_000),
} as const;
// Singleton flag to ensure we only initialize the table once per process
@ -61,6 +66,63 @@ export interface UserInfo {
isPro?: boolean;
}
export interface RateLimitBackstops {
/** Stable device identifier cookie value (server-issued). */
deviceId?: string | null;
/** Best-effort client IP (from proxy headers). */
ip?: string | null;
}
type Bucket = {
key: string;
limit: number;
};
function normalizeBackstopKey(prefix: string, value: string): string {
// Keep the key reasonably bounded; prevent extremely long identifiers from bloating DB.
const trimmed = value.trim();
const safe = trimmed.length > 128 ? trimmed.slice(0, 128) : trimmed;
return `${prefix}:${safe}`;
}
function pickEffectiveResult(results: Array<{ currentCount: number; limit: number }>): {
currentCount: number;
limit: number;
remainingChars: number;
allowed: boolean;
} {
if (results.length === 0) {
return {
allowed: true,
currentCount: 0,
limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
};
}
let binding = results[0];
let bindingRemaining = Math.max(0, binding.limit - binding.currentCount);
for (const r of results) {
const remaining = Math.max(0, r.limit - r.currentCount);
if (remaining < bindingRemaining) {
binding = r;
bindingRemaining = remaining;
}
}
return {
allowed: results.every(r => r.currentCount < r.limit),
currentCount: binding.currentCount,
limit: binding.limit,
remainingChars: bindingRemaining,
};
}
class RateLimitExceeded extends Error {
name = 'RateLimitExceeded' as const;
}
export class RateLimiter {
constructor() { }
@ -68,7 +130,7 @@ export class RateLimiter {
* Check if a user can use TTS and increment their char count if allowed
* @param charCount - Number of characters to add
*/
async checkAndIncrementLimit(user: UserInfo, charCount: number): Promise<RateLimitResult> {
async checkAndIncrementLimit(user: UserInfo, charCount: number, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
// If auth is not enabled, always allow
if (!isAuthEnabled()) {
return {
@ -82,64 +144,87 @@ export class RateLimiter {
await initializeRateLimitTable();
return db.transaction(async (client) => {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
// Get or create today's record for this user
// Postgres supports RETURNING, but SQLite behavior can vary with ON CONFLICT
// We'll use a standard upsert pattern compatible with both via the adapter logic or simple queries
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
// First, ensure record exists
// SQLite/Postgres compatible UPSERT
const deviceId = backstops?.deviceId?.toString() || null;
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
});
}
try {
return await db.transaction(async (client) => {
// Ensure records exist for each bucket
for (const bucket of buckets) {
await client.query(`
INSERT INTO user_tts_chars (user_id, date, char_count)
VALUES ($1, $2, 0)
ON CONFLICT (user_id, date)
DO UPDATE SET updated_at = CURRENT_TIMESTAMP
`, [user.id, today]);
`, [bucket.key, today]);
}
// Allow the request that crosses the limit, but block any requests once the user is
// already at/over the limit. Do this atomically to avoid concurrent over-limit requests.
// Attempt to increment each bucket. If any bucket is already at/over the limit,
// throw to force a transaction rollback (no partial increments).
for (const bucket of buckets) {
const updateResult = await client.query(`
UPDATE user_tts_chars
SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND date = $2 AND char_count < $4
`, [user.id, today, charCount, limit]);
`, [bucket.key, today, charCount, bucket.limit]);
// Get current count after the attempted update (works for both success and failure)
const updated = (updateResult.rowCount ?? 0) > 0;
if (!updated) {
throw new RateLimitExceeded();
}
}
// Fetch current counts for all buckets after increment
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
for (const bucket of buckets) {
const result = await client.query(`
SELECT char_count FROM user_tts_chars
WHERE user_id = $1 AND date = $2
`, [user.id, today]);
`, [bucket.key, today]);
const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10);
const updated = (updateResult.rowCount ?? 0) > 0;
if (!updated) {
return {
allowed: false,
currentCount,
limit,
resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - currentCount)
};
bucketResults.push({ currentCount, limit: bucket.limit });
}
const effective = pickEffectiveResult(bucketResults);
return {
allowed: true,
currentCount,
limit,
currentCount: effective.currentCount,
limit: effective.limit,
resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - currentCount)
remainingChars: effective.remainingChars,
};
});
} catch (error) {
if (error instanceof RateLimitExceeded) {
const current = await this.getCurrentUsage(user, backstops);
return { ...current, allowed: false };
}
throw error;
}
}
/**
* Get current usage for a user without incrementing
*/
async getCurrentUsage(user: UserInfo): Promise<RateLimitResult> {
async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
// If auth is not enabled, return unlimited
if (!isAuthEnabled()) {
return {
@ -154,21 +239,44 @@ export class RateLimiter {
await initializeRateLimitTable();
const today = new Date().toISOString().split('T')[0];
const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
const deviceId = backstops?.deviceId?.toString() || null;
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
});
}
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
for (const bucket of buckets) {
const result = await db.query(
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2',
[user.id, today]
[bucket.key, today]
);
const currentCount = result.rows.length > 0
? parseInt(((result.rows[0] as unknown) as DBCharCountRow).char_count.toString(), 10)
: 0;
bucketResults.push({ currentCount, limit: bucket.limit });
}
const currentCount = result.rows.length > 0 ? parseInt(((result.rows[0] as unknown) as DBCharCountRow).char_count.toString(), 10) : 0;
const effective = pickEffectiveResult(bucketResults);
return {
allowed: currentCount < limit,
currentCount,
limit,
allowed: effective.allowed,
currentCount: effective.currentCount,
limit: effective.limit,
resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - currentCount)
remainingChars: effective.remainingChars,
};
}

View file

@ -0,0 +1,28 @@
import type { NextRequest } from 'next/server';
/**
* Best-effort client IP extraction that works on Vercel and typical reverse proxies.
*
* Note: IP-based limits are a backstop only; they are not perfectly reliable.
*/
export function getClientIp(req: NextRequest): string | null {
// Standard proxy header. Vercel also sets this.
const forwardedFor = req.headers.get('x-forwarded-for');
if (forwardedFor) {
const first = forwardedFor.split(',')[0]?.trim();
if (first) return first;
}
const realIp = req.headers.get('x-real-ip');
if (realIp) return realIp.trim();
// Some proxies use this.
const cfConnectingIp = req.headers.get('cf-connecting-ip');
if (cfConnectingIp) return cfConnectingIp.trim();
// NextRequest may expose ip depending on runtime.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reqAny = req as any;
const ip = typeof reqAny.ip === 'string' ? (reqAny.ip as string) : null;
return ip?.trim() || null;
}

View file

@ -1,11 +1,20 @@
NEXT_PUBLIC_NODE_ENV=development
# Determins the environment mode, `production` limits many features
NEXT_PUBLIC_NODE_ENV=development # Not availble in Docker containers (due to being build-time only)
# OpenAI API Key for Text-to-Speech functionality
API_KEY=api_key_here_if_needed
# Local / OpenAI TTS API Configuration (default)
# Suggest using https://github.com/remsky/Kokoro-FastAPI
API_BASE=http://localhost:8880/v1
API_KEY=api_key_optional
# OpenAI API Base URL (default)
# To use a local TTS model server, I suggest using https://github.com/remsky/Kokoro-FastAPI
API_BASE=https://api.openai.com/v1
# Path to your local whisper.cpp CLI binary
# Path to your local whisper.cpp CLI binary for STT timestamp generation
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
# Auth (recommended for contributors and public instances)
# (Optional) Auth is only enabled when **both** BETTER_AUTH_SECRET and BETTER_AUTH_URL are set
BETTER_AUTH_URL=http://localhost:3003 # Your externally facing URL for this app
BETTER_AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32`
# (Optional) Backend DB used only when authentication is enabled, defaults to SQLite when not set
POSTGRES_URL=
# (Optional) Sign in w/ GitHub Configuration
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=