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:
parent
4689f3676f
commit
50c3829f98
30 changed files with 907 additions and 390 deletions
118
README.md
118
README.md
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
149
src/components/DocumentHeaderMenu.tsx
Normal file
149
src/components/DocumentHeaderMenu.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
41
src/lib/server/device-id.ts
Normal file
41
src/lib/server/device-id.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
28
src/lib/server/request-ip.ts
Normal file
28
src/lib/server/request-ip.ts
Normal 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;
|
||||||
|
}
|
||||||
25
template.env
25
template.env
|
|
@ -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=
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue