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). > **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: ### 1. 🐳 Start the Docker container:
Minimal (no persistence, auth disabled unless you set auth env vars):
```bash ```bash
docker run --name openreader-webui \ docker run --name openreader-webui \
--restart unless-stopped \ --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 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 ```bash
docker run --name openreader-webui \ docker run --name openreader-webui \
--restart unless-stopped \ --restart unless-stopped \
-e API_KEY=none \
-e API_BASE=http://host.docker.internal:8880/v1 \
-p 3003:3003 \ -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 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. 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: ### 2. ⚙️ Configure the app settings in the UI:
- Set the TTS Provider and Model in the Settings modal - 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) - 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 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) ### 🗣️ 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). 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 cp template.env .env
# Edit .env with your configuration settings # 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 > 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 ```bash
npx @better-auth/cli migrate -y 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: 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 { auth } from '@/lib/server/auth';
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth-config'; 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'; export const dynamic = 'force-dynamic';
@ -14,7 +16,7 @@ function getUtcResetTimeIso(): string {
return tomorrow.toISOString(); return tomorrow.toISOString();
} }
export async function GET() { export async function GET(req: NextRequest) {
try { try {
// If auth is not enabled, return unlimited status // If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) { if (!isAuthEnabled() || !auth) {
@ -53,12 +55,21 @@ export async function GET() {
const isAnonymous = Boolean(session.user.isAnonymous); const isAnonymous = Boolean(session.user.isAnonymous);
const result = await rateLimiter.getCurrentUsage({ const ip = getClientIp(req);
id: session.user.id, const device = isAnonymous ? getOrCreateDeviceId(req) : null;
isAnonymous
});
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, allowed: result.allowed,
currentCount: result.currentCount, currentCount: result.currentCount,
limit: result.limit, limit: result.limit,
@ -67,6 +78,12 @@ export async function GET() {
userType: isAnonymous ? 'anonymous' : 'authenticated', userType: isAnonymous ? 'anonymous' : 'authenticated',
authEnabled: true authEnabled: true
}); });
if (device?.didCreate) {
setDeviceIdCookie(response, device.deviceId);
}
return response;
} catch (error) { } catch (error) {
console.error('Error getting rate limit status:', error); console.error('Error getting rate limit status:', error);
return NextResponse.json( return NextResponse.json(

View file

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

View file

@ -12,14 +12,14 @@ import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext"; import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { ZoomControl } from '@/components/ZoomControl'; import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { DownloadIcon } from '@/components/icons/Icons';
import type { TTSAudiobookChapter } from '@/types/tts'; import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import { resolveDocumentId } from '@/lib/dexie'; import { resolveDocumentId } from '@/lib/dexie';
import { RateLimitBanner } from '@/components/rate-limit-banner'; import { RateLimitBanner } from '@/components/rate-limit-banner';
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; 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; 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 || '')} title={isLoading ? 'Loading…' : (currDocName || '')}
right={ right={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ZoomControl <DocumentHeaderMenu
value={padPct} zoomLevel={padPct}
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
min={0} onOpenSettings={() => setIsSettingsOpen(true)}
max={100} 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> </div>
} }
/> />

View file

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

View file

@ -1,3 +1,4 @@
import { Header } from '@/components/Header';
import { HomeContent } from '@/components/HomeContent'; import { HomeContent } from '@/components/HomeContent';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { UserMenu } from '@/components/auth/UserMenu'; 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() { export default function Home() {
return ( return (
<div className="flex flex-col h-full w-full"> <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 /> <SettingsModal />
<UserMenu />
</div>
}
/>
<AuthLoader> <AuthLoader>
<> <>
<UserMenu /> <section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
<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">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<RateLimitBanner className="mb-6" /> <RateLimitBanner className="mb-6" />
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
<HomeContent /> <HomeContent />
</div> </div>
</section> </section>

View file

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

View file

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

View file

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

View file

@ -229,7 +229,7 @@ export default function SignUpPage() {
<p className="text-xs text-muted"> <p className="text-xs text-muted">
By creating an account, you agree to our{' '} By creating an account, you agree to our{' '}
<button <button
onClick={() => showPrivacyPopup()} onClick={() => showPrivacyPopup({ authEnabled })}
className="underline hover:text-foreground" className="underline hover:text-foreground"
> >
Privacy Policy 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 { GithubIcon } from '@/components/icons/Icons'
import { CodeBlock } from '@/components/CodeBlock' import { showPrivacyPopup } from '@/components/privacy-popup'
import { useAuthConfig } from '@/contexts/AutoRateLimitContext'
export function Footer() { export function Footer() {
const { authEnabled } = useAuthConfig();
return ( return (
<footer className="m-8 mb-2 text-sm text-muted"> <footer className="m-8 mb-2 text-sm text-muted">
<div className="flex flex-col items-center space-y-4"> <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"> <div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3">
<a <a
href="https://github.com/richardr1126/OpenReader-WebUI" href="https://github.com/richardr1126/OpenReader-WebUI#readme"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" /> <GithubIcon className="w-5 h-5" />
<span>Self host</span>
</a> </a>
<span className='w-full sm:w-fit'></span> <span className='w-full sm:w-fit'></span>
<Popover className="flex"> <button
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none flex items-center gap-1"> type="button"
Privacy info onClick={() => showPrivacyPopup({ authEnabled })}
</PopoverButton> className="font-bold hover:text-foreground transition-colors outline-none"
<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> Privacy
<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> </button>
<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>
<span className='w-full sm:w-fit'></span> <span className='w-full sm:w-fit'></span>
<span> <span>
Powered by{' '} Powered by{' '}
@ -51,61 +50,6 @@ export function Footer() {
</a> </a>
</span> </span>
</div> </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> </div>
</footer> </footer>
) )

View file

@ -15,7 +15,7 @@ export function Header({
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
{left} {left}
{typeof title === 'string' ? ( {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 title
)} )}

View file

@ -49,12 +49,30 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
clearWordHighlights, clearWordHighlights,
highlightWordIndex, highlightWordIndex,
onDocumentLoadSuccess, onDocumentLoadSuccess,
currDocId,
currDocData, currDocData,
currDocPages, currDocPages,
currDocText, currDocText,
currDocPage, currDocPage,
} = usePDF(); } = 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 layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;
const clearSentenceHighlightTimeouts = useCallback(() => { const clearSentenceHighlightTimeouts = useCallback(() => {
@ -266,9 +284,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
return ( return (
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full"> <div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
<Document <Document
key={currDocId || 'pdf'}
loading={<DocumentSkeleton />} loading={<DocumentSkeleton />}
noData={<DocumentSkeleton />} noData={<DocumentSkeleton />}
file={currDocData} file={documentFile}
onLoadSuccess={(pdf) => { onLoadSuccess={(pdf) => {
onDocumentLoadSuccess(pdf); onDocumentLoadSuccess(pdf);
}} }}

View file

@ -37,6 +37,7 @@ import { useAuthSession } from '@/hooks/useAuth';
import { markSignedOut, clearSignedOut } from '@/lib/session-utils'; import { markSignedOut, clearSignedOut } from '@/lib/session-utils';
import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { useRouter } from 'next/navigation'; 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; 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) name: id.charAt(0).toUpperCase() + id.slice(1)
})); }));
export function SettingsModal() { export function SettingsModal({ className = '' }: { className?: string }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@ -393,11 +394,11 @@ export function SettingsModal() {
<> <>
<Button <Button
onClick={() => setIsOpen(true)} 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" aria-label="Settings"
tabIndex={0} 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> </Button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
@ -426,13 +427,22 @@ export function SettingsModal() {
leaveTo="opacity-0 scale-95" 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"> <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 <DialogTitle
as="h3" as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4" className="text-lg font-semibold leading-6 text-foreground"
> >
Settings Settings
</DialogTitle> </DialogTitle>
<Button
onClick={() => showPrivacyPopup({ authEnabled })}
className="text-sm font-medium text-muted hover:text-accent"
>
Privacy
</Button>
</div>
<TabGroup> <TabGroup>
<TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4"> <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) => ( {tabs.map((tab) => (
@ -520,6 +530,24 @@ export function SettingsModal() {
</Listbox> </Listbox>
</div> </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"> <div className="space-y-1">
<label className="block text-sm font-medium text-foreground"> <label className="block text-sm font-medium text-foreground">
API Key API Key
@ -620,24 +648,6 @@ export function SettingsModal() {
</div> </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"> <div className="pt-4 flex justify-end gap-2">
<Button <Button
type="button" type="button"
@ -863,12 +873,12 @@ export function SettingsModal() {
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Link href="/signin" className="w-full"> <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 Log In
</Button> </Button>
</Link> </Link>
<Link href="/signup" className="w-full"> <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 Sign Up
</Button> </Button>
</Link> </Link>

View file

@ -8,7 +8,7 @@ import { getAuthClient } from '@/lib/auth-client';
import { clearSignedOut } from '@/lib/session-utils'; import { clearSignedOut } from '@/lib/session-utils';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export function UserMenu() { export function UserMenu({ className = '' }: { className?: string }) {
const { authEnabled, baseUrl } = useAuthConfig(); const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAutoRateLimit(); const { refresh: refreshRateLimit } = useAutoRateLimit();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
@ -27,14 +27,14 @@ export function UserMenu() {
if (!session || session.user.isAnonymous) { if (!session || session.user.isAnonymous) {
return ( 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"> <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"> <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'} {session?.user.isAnonymous ? 'Log In' : 'Sign In'}
</Button> </Button>
</Link> </Link>
<Link href="/signup" className="hidden sm:block"> <Link href="/signup">
<Button className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent"> <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 Sign Up
</Button> </Button>
</Link> </Link>
@ -43,22 +43,22 @@ export function UserMenu() {
} }
return ( return (
<div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex items-center gap-3"> <div className={`flex items-center gap-2 ${className}`}>
<div className="hidden sm:flex flex-col items-end"> <div className="hidden sm:flex flex-col items-end mr-1">
<span className="text-xs font-medium text-foreground"> <span className="text-xs font-medium text-foreground leading-none mb-0.5">
{session.user.name || session.user.email || 'Account'} {session.user.name || session.user.email || 'Account'}
</span> </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} {session.user.email}
</span> </span>
</div> </div>
<Button <Button
onClick={handleSignOut} 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" 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> <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> <polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line> <line x1="21" y1="12" x2="9" y2="12"></line>

View file

@ -510,3 +510,21 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
</svg> </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'; } from '@headlessui/react';
import { updateAppConfig, getAppConfig } from '@/lib/dexie'; import { updateAppConfig, getAppConfig } from '@/lib/dexie';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
interface PrivacyPopupProps { interface PrivacyPopupProps {
onAccept?: () => void; 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 [isOpen, setIsOpen] = useState(false);
const [origin, setOrigin] = useState('');
const checkPrivacyAccepted = useCallback(async () => { const checkPrivacyAccepted = useCallback(async () => {
const config = await getAppConfig(); const config = await getAppConfig();
@ -29,6 +140,11 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
checkPrivacyAccepted(); checkPrivacyAccepted();
}, [checkPrivacyAccepted]); }, [checkPrivacyAccepted]);
useEffect(() => {
if (typeof window === 'undefined') return;
setOrigin(window.location.origin);
}, []);
const handleAccept = async () => { const handleAccept = async () => {
await updateAppConfig({ privacyAccepted: true }); await updateAppConfig({ privacyAccepted: true });
setIsOpen(false); setIsOpen(false);
@ -72,21 +188,7 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
Privacy & Data Usage Privacy & Data Usage
</DialogTitle> </DialogTitle>
<div className="mt-4 space-y-3 text-sm text-foreground/90"> <PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
<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>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button <Button
@ -113,12 +215,15 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
* Function to programmatically show the privacy popup * Function to programmatically show the privacy popup
* This can be called from signin/signup components * 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 // Create a temporary container for the popup
const container = document.createElement('div'); const container = document.createElement('div');
container.id = 'privacy-popup-container'; container.id = 'privacy-popup-container';
document.body.appendChild(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 and render the popup
import('react-dom/client').then(({ createRoot }) => { import('react-dom/client').then(({ createRoot }) => {
import('react').then((React) => { import('react').then((React) => {
@ -171,21 +276,7 @@ export function showPrivacyPopup(): void {
Privacy & Data Usage Privacy & Data Usage
</DialogTitle> </DialogTitle>
<div className="mt-4 space-y-3 text-sm text-foreground/90"> <PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
<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>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button <Button

View file

@ -58,6 +58,7 @@ import type {
*/ */
interface PDFContextType { interface PDFContextType {
// Current document state // Current document state
currDocId: string | undefined;
currDocData: ArrayBuffer | undefined; currDocData: ArrayBuffer | undefined;
currDocName: string | undefined; currDocName: string | undefined;
currDocPages: number | undefined; currDocPages: number | undefined;
@ -136,6 +137,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
} = useConfig(); } = useConfig();
// Current document state // Current document state
const [currDocId, setCurrDocId] = useState<string>();
const [currDocData, setCurrDocData] = useState<ArrayBuffer>(); const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
const [currDocName, setCurrDocName] = useState<string>(); const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = 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 pageTextCacheRef = useRef<Map<number, string>>(new Map());
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber); 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(() => { useEffect(() => {
setCurrDocPage(currDocPageNumber); setCurrDocPage(currDocPageNumber);
}, [currDocPageNumber]); }, [currDocPageNumber]);
@ -155,9 +166,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
*/ */
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => { const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
console.log('Document loaded:', pdf.numPages); console.log('Document loaded:', pdf.numPages);
pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = pdf;
setCurrDocPages(pdf.numPages); setCurrDocPages(pdf.numPages);
setPdfDocument(pdf); setPdfDocument(pdf);
}, [setCurrDocPages]); }, [setCurrDocPages, setPdfDocument]);
/** /**
* Loads and processes text from the current document page * Loads and processes text from the current document page
@ -167,7 +180,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
*/ */
const loadCurrDocText = useCallback(async () => { const loadCurrDocText = useCallback(async () => {
try { try {
if (!pdfDocument) return; const generation = pdfDocGenerationRef.current;
const currentPdf = pdfDocumentRef.current;
if (!currentPdf) return;
const margins = { const margins = {
header: headerMargin, header: headerMargin,
@ -177,6 +192,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}; };
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => { 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)) { if (pageTextCacheRef.current.has(pageNumber)) {
const cached = pageTextCacheRef.current.get(pageNumber)!; const cached = pageTextCacheRef.current.get(pageNumber)!;
if (!shouldCache) { if (!shouldCache) {
@ -185,14 +205,19 @@ export function PDFProvider({ children }: { children: ReactNode }) {
return cached; 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) { if (shouldCache) {
pageTextCacheRef.current.set(pageNumber, extracted); pageTextCacheRef.current.set(pageNumber, extracted);
} }
return extracted; return extracted;
}; };
const totalPages = currDocPages ?? pdfDocument.numPages; const totalPages = currDocPages ?? currentPdf.numPages;
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined; const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
const [text, nextText] = await Promise.all([ 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), nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined),
]); ]);
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
return;
}
if (text !== currDocText || text === '') { if (text !== currDocText || text === '') {
setCurrDocText(text); setCurrDocText(text);
setTTSText(text, { setTTSText(text, {
@ -209,10 +238,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}); });
} }
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
console.error('Error loading PDF text:', error); console.error('Error loading PDF text:', error);
} }
}, [ }, [
pdfDocument,
currDocPageNumber, currDocPageNumber,
currDocPages, currDocPages,
setTTSText, setTTSText,
@ -228,10 +259,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
* Triggers text extraction and processing when either the document URL or page changes * Triggers text extraction and processing when either the document URL or page changes
*/ */
useEffect(() => { useEffect(() => {
if (currDocData) { if (currDocData && pdfDocument) {
loadCurrDocText(); loadCurrDocText();
} }
}, [currDocPageNumber, currDocData, loadCurrDocText]); }, [currDocPageNumber, currDocData, pdfDocument, loadCurrDocText]);
/** /**
* Sets the current document based on its ID * 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> => { const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
try { 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); const doc = await getPdfDocument(id);
if (doc) { if (doc) {
setCurrDocName(doc.name); 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) { } catch (error) {
console.error('Failed to get document:', error); console.error('Failed to get document:', error);
} }
}, []); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]);
/** /**
* Clears the current document state * Clears the current document state
* Resets all document-related states and stops any ongoing TTS playback * Resets all document-related states and stops any ongoing TTS playback
*/ */
const clearCurrDoc = useCallback(() => { const clearCurrDoc = useCallback(() => {
pdfDocGenerationRef.current += 1;
pdfDocumentRef.current = undefined;
setCurrDocId(undefined);
setCurrDocName(undefined); setCurrDocName(undefined);
setCurrDocData(undefined); setCurrDocData(undefined);
setCurrDocText(undefined); setCurrDocText(undefined);
@ -264,7 +312,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
setPdfDocument(undefined); setPdfDocument(undefined);
pageTextCacheRef.current.clear(); pageTextCacheRef.current.clear();
stop(); stop();
}, [setCurrDocPages, stop]); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]);
/** /**
* Creates a complete audiobook by processing all PDF pages through NLP and TTS * Creates a complete audiobook by processing all PDF pages through NLP and TTS
@ -618,6 +666,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
() => ({ () => ({
onDocumentLoadSuccess, onDocumentLoadSuccess,
setCurrentDocument, setCurrentDocument,
currDocId,
currDocData, currDocData,
currDocName, currDocName,
currDocPages, currDocPages,
@ -636,6 +685,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
[ [
onDocumentLoadSuccess, onDocumentLoadSuccess,
setCurrentDocument, setCurrentDocument,
currDocId,
currDocData, currDocData,
currDocName, currDocName,
currDocPages, currDocPages,

View file

@ -754,7 +754,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* @param {string} sentence - The sentence to generate audio for * @param {string} sentence - The sentence to generate audio for
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer * @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 = const alignmentEnabledForCurrentDoc =
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || (!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled); (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
@ -897,7 +897,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return; 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); setIsPlaying(false);
}
// Handle daily quota exceeded (429 + Problem Details code) // Handle daily quota exceeded (429 + Problem Details code)
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
@ -973,7 +978,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Create the audio processing promise // Create the audio processing promise
const processPromise = (async () => { const processPromise = (async () => {
try { try {
const audioBuffer = await getAudio(sentence); const audioBuffer = await getAudio(sentence, preload);
if (!audioBuffer) { if (!audioBuffer) {
// If quota or other handled error returns undefined, ensure we don't throw "No audio data" // 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 // 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(); return pageText.replace(/\s+/g, ' ').trim();
} catch (error) { } 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); 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 // Rate limits configuration - character counts per day
export const RATE_LIMITS = { export const RATE_LIMITS = {
ANONYMOUS: 50_000, // 50K characters per day for anonymous users 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; } as const;
// Singleton flag to ensure we only initialize the table once per process // Singleton flag to ensure we only initialize the table once per process
@ -61,6 +66,63 @@ export interface UserInfo {
isPro?: boolean; 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 { export class RateLimiter {
constructor() { } constructor() { }
@ -68,7 +130,7 @@ export class RateLimiter {
* Check if a user can use TTS and increment their char count if allowed * Check if a user can use TTS and increment their char count if allowed
* @param charCount - Number of characters to add * @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 auth is not enabled, always allow
if (!isAuthEnabled()) { if (!isAuthEnabled()) {
return { return {
@ -82,64 +144,87 @@ export class RateLimiter {
await initializeRateLimitTable(); await initializeRateLimitTable();
return db.transaction(async (client) => {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format 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 const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
// 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
// First, ensure record exists const deviceId = backstops?.deviceId?.toString() || null;
// SQLite/Postgres compatible UPSERT 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(` await client.query(`
INSERT INTO user_tts_chars (user_id, date, char_count) INSERT INTO user_tts_chars (user_id, date, char_count)
VALUES ($1, $2, 0) VALUES ($1, $2, 0)
ON CONFLICT (user_id, date) ON CONFLICT (user_id, date)
DO UPDATE SET updated_at = CURRENT_TIMESTAMP 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 // Attempt to increment each bucket. If any bucket is already at/over the limit,
// already at/over the limit. Do this atomically to avoid concurrent over-limit requests. // throw to force a transaction rollback (no partial increments).
for (const bucket of buckets) {
const updateResult = await client.query(` const updateResult = await client.query(`
UPDATE user_tts_chars UPDATE user_tts_chars
SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND date = $2 AND char_count < $4 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(` const result = await client.query(`
SELECT char_count FROM user_tts_chars SELECT char_count FROM user_tts_chars
WHERE user_id = $1 AND date = $2 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 currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10);
const updated = (updateResult.rowCount ?? 0) > 0; bucketResults.push({ currentCount, limit: bucket.limit });
if (!updated) {
return {
allowed: false,
currentCount,
limit,
resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - currentCount)
};
} }
const effective = pickEffectiveResult(bucketResults);
return { return {
allowed: true, allowed: true,
currentCount, currentCount: effective.currentCount,
limit, limit: effective.limit,
resetTime: this.getResetTime(), 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 * 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 auth is not enabled, return unlimited
if (!isAuthEnabled()) { if (!isAuthEnabled()) {
return { return {
@ -154,21 +239,44 @@ export class RateLimiter {
await initializeRateLimitTable(); await initializeRateLimitTable();
const today = new Date().toISOString().split('T')[0]; 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( const result = await db.query(
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2', '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 { return {
allowed: currentCount < limit, allowed: effective.allowed,
currentCount, currentCount: effective.currentCount,
limit, limit: effective.limit,
resetTime: this.getResetTime(), 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 # Local / OpenAI TTS API Configuration (default)
API_KEY=api_key_here_if_needed # Suggest using https://github.com/remsky/Kokoro-FastAPI
API_BASE=http://localhost:8880/v1
API_KEY=api_key_optional
# OpenAI API Base URL (default) # Path to your local whisper.cpp CLI binary for STT timestamp generation
# 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
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli 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=