Merge pull request #80 from richardr1126/better-auth

Major overhaul: implement auth, sqldb for metadata, s3-like storage, and lessen reliance on fs

New Features

Added user authentication system with sign-in, sign-up, and account management
Added TTS rate-limiting with per-user character limits and daily resets
Added user data export functionality
Added support for PostgreSQL database and external S3-compatible object storage
Documentation

Launched comprehensive documentation site with deployment guides, configuration references, and TTS provider integration guides
Chores

Rebranded project from "OpenReader-WebUI" to "OpenReader"
This commit is contained in:
Richard R 2026-02-19 10:28:51 -07:00 committed by GitHub
commit 3555561d3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
233 changed files with 37341 additions and 4107 deletions

81
.env.example Normal file
View file

@ -0,0 +1,81 @@
# Local / OpenAI TTS API Configuration (default)
# Suggest using https://github.com/remsky/Kokoro-FastAPI
API_BASE=http://localhost:8880/v1
API_KEY=api_key_optional
# (Optional) TTS request/cache tuning (leave unset to use defaults)
# TTS_CACHE_MAX_SIZE_BYTES=268435456 # 256MB
# TTS_CACHE_TTL_MS=1800000 # 30 minutes
# TTS_MAX_RETRIES=2
# TTS_RETRY_INITIAL_MS=250
# TTS_RETRY_MAX_MS=2000
# TTS_RETRY_BACKOFF=2
# (Optional) Enable TTS character rate limiting (default is `false`)
# TTS_ENABLE_RATE_LIMIT=true
# (Optional) TTS per-user daily limits (leave unset to use defaults)
# TTS_DAILY_LIMIT_ANONYMOUS=50000
# TTS_DAILY_LIMIT_AUTHENTICATED=500000
# (Optional) TTS IP backstop daily limits (leave unset to use defaults)
# TTS_IP_DAILY_LIMIT_ANONYMOUS=100000
# TTS_IP_DAILY_LIMIT_AUTHENTICATED=1000000
# Auth configuration (recommended for contributors and public instances)
# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set
BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network)
AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32`
AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted)
# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`)
USE_ANONYMOUS_AUTH_SESSIONS=
# (Optional) Sign in w/ GitHub Configuration
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# (Optional) Disable Better Auth built-in rate limiting (useful for testing)
DISABLE_AUTH_RATE_LIMIT=
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables.
# Defaults to SQLite at docstore/sqlite3.db when not set.
POSTGRES_URL=
# Embedded SeaweedFS weed mini config
# (Optional) Enable embedded weed mini for local S3-compatible storage (default: `true`)
USE_EMBEDDED_WEED_MINI=
WEED_MINI_DIR=
WEED_MINI_WAIT_SEC=
# S3 storage config (use with embedded weed mini or external S3-compatible storage)
# (Optional) For embedded weed mini, set explicit keys if you want stable credentials across restarts.
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
S3_REGION=
# (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host.
S3_ENDPOINT=
S3_FORCE_PATH_STYLE=
S3_PREFIX=
# Migrations configuration
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
RUN_DRIZZLE_MIGRATIONS=
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
RUN_FS_MIGRATIONS=
# (Optional) Server library import roots (uses /docstore/library by default)
IMPORT_LIBRARY_DIR=
IMPORT_LIBRARY_DIRS=
# (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
# (Optional) Override ffmpeg binary path used for audiobook processing
FFMPEG_BIN=
# (Optional) Client feature flags (does not work in Docker containers due to being build-time only)
# NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true
# NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true
# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai
# NEXT_PUBLIC_DEFAULT_TTS_MODEL=kokoro
# NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=true
# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false

View file

@ -11,7 +11,7 @@ assignees: richardr1126
A description of what the bug is, which explains how you got there.
**Environment info:**
- OpenReader-WebUI version: [e.g. v0.2.5 (found in the terminal when starting)]
- OpenReader version: [e.g. v0.2.5 (found in the terminal when starting)]
- Using Docker: yes/no
- Browser: [e.g. Safari, Firefox, Chromium]
- Device: [e.g. iPhone16, Macbook Pro, Intel/AMD PC]

View file

@ -23,18 +23,24 @@ jobs:
runs-on: ubuntu-24.04
outputs:
image_name: ${{ steps.image-name.outputs.image_name }}
legacy_image_name: ${{ steps.image-name.outputs.legacy_image_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
steps:
- name: Compute lowercase image name
- name: Compute Docker image names
id: image-name
run: echo "image_name=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
run: |
owner="${GITHUB_REPOSITORY_OWNER,,}"
echo "image_name=${owner}/openreader" >> "$GITHUB_OUTPUT"
echo "legacy_image_name=${owner}/openreader-webui" >> "$GITHUB_OUTPUT"
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
images: |
${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
${{ env.REGISTRY }}/${{ steps.image-name.outputs.legacy_image_name }}
tags: |
type=raw,value=latest,enable=${{ (github.event_name == 'push' && !contains(github.ref, '-pre')) || (github.event_name == 'workflow_dispatch' && inputs.use_latest_tag == true) }},priority=1000
type=semver,pattern={{version}}
@ -86,7 +92,7 @@ jobs:
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
provenance: false
outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }},${{ env.REGISTRY }}/${{ needs.prepare.outputs.legacy_image_name }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |

37
.github/workflows/docs-check.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Docs Check
on:
pull_request:
branches: [main, master]
paths:
- 'docs-site/**'
- '.github/workflows/docs-check.yml'
- '.github/workflows/docs-deploy.yml'
- 'README.md'
- 'package.json'
jobs:
docs-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: docs-site/pnpm-lock.yaml
- name: Install docs dependencies
run: pnpm --dir docs-site install --frozen-lockfile
- name: Build docs
run: pnpm --dir docs-site build

61
.github/workflows/docs-deploy.yml vendored Normal file
View file

@ -0,0 +1,61 @@
name: Docs Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: docs-pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: docs-site/pnpm-lock.yaml
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Install docs dependencies
run: pnpm --dir docs-site install --frozen-lockfile
- name: Build docs
run: pnpm --dir docs-site build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs-site/build
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -0,0 +1,69 @@
name: Version Docs on Tag
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
pull-requests: write
jobs:
version-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: docs-site/pnpm-lock.yaml
- name: Install docs dependencies
run: pnpm --dir docs-site install --frozen-lockfile
- name: Snapshot docs version
env:
VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -f docs-site/versions.json ] && grep -q "\"${VERSION}\"" docs-site/versions.json; then
echo "Version ${VERSION} already exists in versions.json; skipping docs:version"
else
pnpm --dir docs-site docusaurus docs:version "${VERSION}"
fi
- name: Build docs
run: pnpm --dir docs-site build
- name: Create docs version PR
uses: peter-evans/create-pull-request@v6
with:
commit-message: "docs: snapshot ${{ github.ref_name }}"
title: "docs: snapshot ${{ github.ref_name }}"
body: |
Automated docs version snapshot for `${{ github.ref_name }}`.
- Ran `docusaurus docs:version`
- Added versioned docs/sidebar snapshot files
branch: docs/version-${{ github.ref_name }}
base: main
delete-branch: true
labels: docs,automation
add-paths: |
docs-site/versions.json
docs-site/versioned_docs/**
docs-site/versioned_sidebars/**

View file

@ -8,6 +8,11 @@ jobs:
e2e-testing:
timeout-minutes: 60
runs-on: ubuntu-latest
env:
FFMPEG_BIN: /usr/bin/ffmpeg
USE_EMBEDDED_WEED_MINI: true
BASE_URL: http://127.0.0.1:3003
S3_ENDPOINT: http://127.0.0.1:8333
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -20,6 +25,15 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y libreoffice-writer ffmpeg
- name: Install SeaweedFS weed binary
run: |
WEED_PATH=$(docker run --rm --entrypoint sh chrislusf/seaweedfs:latest -lc 'command -v weed')
CID=$(docker create chrislusf/seaweedfs:latest)
docker cp "${CID}:${WEED_PATH}" ./weed
docker rm "${CID}"
sudo install -m 0755 ./weed /usr/local/bin/weed
rm -f ./weed
weed version
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify ffprobe
@ -28,7 +42,6 @@ jobs:
run: pnpm exec playwright install --with-deps
- name: Run Playwright tests
env:
NEXT_PUBLIC_NODE_ENV: test
API_BASE: https://api.deepinfra.com/v1/openai
API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
run: pnpm exec playwright test --reporter=list,github,html

6
.gitignore vendored
View file

@ -2,6 +2,7 @@
# dependencies
/node_modules
/.pnpm-store
/.pnp
.pnp.*
.yarn/*
@ -52,4 +53,7 @@ node_modules/
/playwright/.cache/
# vscode
.vscode
.vscode
# .agents
.agents

1
.npmrc Normal file
View file

@ -0,0 +1 @@
node-linker=hoisted

View file

@ -12,6 +12,10 @@ RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF $( [ "$TARGETARCH" = "arm64" ] && echo "-DGGML_CPU_ARM_ARCH=armv8-a" || true ) && \
cmake --build build -j
# Stage 1b: extract seaweedfs weed binary (for optional embedded weed mini)
FROM chrislusf/seaweedfs:latest AS seaweedfs-builder
RUN cp "$(command -v weed)" /tmp/weed
# Stage 2: build the Next.js app
FROM node:lts-alpine AS app-builder
@ -40,9 +44,9 @@ RUN pnpm build
FROM node:lts-alpine AS runner
# Add runtime OS dependencies:
# - ffmpeg: required for audiobook export and word-by-word alignment (/api/whisper)
# - libreoffice-writer: required for DOCX → PDF conversion
RUN apk add --no-cache ffmpeg libreoffice-writer
# ffmpeg is provided by ffmpeg-static from node_modules.
RUN apk add --no-cache ca-certificates libreoffice-writer
# Install pnpm globally for running the app
RUN npm install -g pnpm
@ -56,6 +60,9 @@ COPY --from=app-builder /app ./
# Copy the compiled whisper.cpp build output into the runtime image
# (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so)
COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build
# Copy seaweedfs weed binary for optional embedded local S3.
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
RUN chmod +x /usr/local/bin/weed
# Point the app at the compiled whisper-cli binary and ensure its libs are discoverable
ENV WHISPER_CPP_BIN=/opt/whisper.cpp/build/bin/whisper-cli
@ -65,4 +72,5 @@ ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build
EXPOSE 3003
# Start the application
CMD ["pnpm", "start"]
ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"]
CMD ["pnpm", "start:raw"]

View file

@ -1,4 +1,4 @@
OpenReader-WebUI
OpenReader
Third-Party Notices
===================

344
README.md
View file

@ -1,319 +1,51 @@
[![GitHub Stars](https://img.shields.io/github/stars/richardr1126/OpenReader-WebUI)](../../stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/richardr1126/OpenReader-WebUI)](../../network/members)
[![GitHub Watchers](https://img.shields.io/github/watchers/richardr1126/OpenReader-WebUI)](../../watchers)
[![GitHub Issues](https://img.shields.io/github/issues/richardr1126/OpenReader-WebUI)](../../issues)
[![GitHub Last Commit](https://img.shields.io/github/last-commit/richardr1126/OpenReader-WebUI)](../../commits)
[![GitHub Release](https://img.shields.io/github/v/release/richardr1126/OpenReader-WebUI)](../../releases)
[![GitHub Release](https://img.shields.io/github/v/release/richardr1126/openreader)](https://github.com/richardr1126/openreader/releases)
[![License](https://img.shields.io/github/license/richardr1126/openreader)](LICENSE)
[![Docs](https://img.shields.io/badge/docs-openreader-0a6c74)](https://docs.openreader.richardr.dev/)
[![Playwright Tests](https://github.com/richardr1126/openreader/actions/workflows/playwright.yml/badge.svg)](https://github.com/richardr1126/openreader/actions/workflows/playwright.yml)
[![Docs Check](https://github.com/richardr1126/openreader/actions/workflows/docs-check.yml/badge.svg)](https://github.com/richardr1126/openreader/actions/workflows/docs-check.yml)
[![Discussions](https://img.shields.io/badge/Discussions-Ask%20a%20Question-blue)](../../discussions)
[![GitHub Stars](https://img.shields.io/github/stars/richardr1126/openreader)](https://github.com/richardr1126/openreader/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/richardr1126/openreader)](https://github.com/richardr1126/openreader/network/members)
[![Discussions](https://img.shields.io/badge/Discussions-Ask%20a%20Question-blue)](https://github.com/richardr1126/openreader/discussions)
# 📄🔊 OpenReader WebUI
# 📄🔊 OpenReader
OpenReader WebUI is an open source text to speech document reader web app built using Next.js, offering a TTS read along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
OpenReader is an open source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with synchronized read-along playback.
- 🎯 **Multi-Provider TTS Support**
- [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): Supporting multi-voice combinations (like `af_heart+af_bella`)
- [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI)
- **Custom OpenAI-compatible**: Any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints
- **Cloud TTS Providers (requiring API keys)**
- [**Deepinfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M + models with support for cloned voices and more
- [**OpenAI API ($$)**](https://platform.openai.com/docs/pricing#transcription-and-speech): tts-1, tts-1-hd, and gpt-4o-mini-tts w/ instructions
- 🛜 *(Updated)* **Server-side Sync and Storage**
- *(New)* **External Library Import** enables importing documents to the browser's storage from a folder mounted on the server
- *(Updated)* **Sync documents** between the browser and server to get them on other browsers or devices
- 🎧 **Server-side Audiobook Export** in **m4b/mp3**, with resumable, chapter-based export and regeneration
- 📖 **Read Along Experience** providing real-time text highlighting during playback (PDF/EPUB)
- *(New)* **Word-by-word** highlighting uses word-by-word timestamps generated server-side with [*whisper.cpp*](https://github.com/ggml-org/whisper.cpp) (optional)
- 🧠 **Smart Sentence-Aware Narration** merges sentences across pages/chapters for smoother TTS
- 🚀 **Optimized Next.js TTS Proxy** with audio caching and optimized repeat playback
- 🎨 **Customizable Experience**
- 🎨 Multiple app theme options
- ⚙️ Various TTS and document handling settings
- And more ...
> Previously named **OpenReader-WebUI**.
## 🐳 Docker Quick Start
> **Get started in the [docs](https://docs.openreader.richardr.dev/)**.
### Prerequisites
- Recent version of Docker installed on your machine
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
## ✨ Highlights
> **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below).
- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints (OpenAI, DeepInfra, Kokoro, Orpheus, custom).
- 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration.
- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps.
- 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders.
- 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends.
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.
- 🐳 **Self-host friendly** with Docker, optional auth, and automatic startup migrations.
### 1. 🐳 Start the Docker container:
```bash
docker run --name openreader-webui \
--restart unless-stopped \
-p 3003:3003 \
ghcr.io/richardr1126/openreader-webui:latest
```
## 🚀 Start Here
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
```bash
docker run --name openreader-webui \
--restart unless-stopped \
-e API_KEY=none \
-e API_BASE=http://host.docker.internal:8880/v1 \
-p 3003:3003 \
ghcr.io/richardr1126/openreader-webui:latest
```
| Goal | Link |
| --- | --- |
| Run with Docker | [Docker Quick Start](https://docs.openreader.richardr.dev/getting-started/docker-quick-start) |
| Deploy on Vercel | [Vercel Deployment](https://docs.openreader.richardr.dev/getting-started/vercel-deployment) |
| Develop locally | [Local Development](https://docs.openreader.richardr.dev/getting-started/local-development) |
| Configure auth | [Auth](https://docs.openreader.richardr.dev/guides/configuration) |
| Configure SQL database | [Database and Migrations](https://docs.openreader.richardr.dev/operations/database-and-migrations) |
| Configure object storage | [Object / Blob Storage](https://docs.openreader.richardr.dev/guides/storage-and-blob-behavior) |
| Configure TTS providers | [TTS Providers](https://docs.openreader.richardr.dev/guides/tts-providers) |
| Run Kokoro locally | [Kokoro-FastAPI](https://docs.openreader.richardr.dev/integrations/kokoro-fastapi) |
| Get support or contribute | [Support and Contributing](https://docs.openreader.richardr.dev/community/support) |
Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings.
## 🧭 Community
> **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`.
- Questions and ideas: [GitHub Discussions](https://github.com/richardr1126/openreader/discussions)
- Bug reports: [GitHub Issues](https://github.com/richardr1126/openreader/issues)
- Contributions: open a pull request
### 2. ⚙️ Configure the app settings in the UI:
- Set the TTS Provider and Model in the Settings modal
- Set the TTS API Base URL and API Key if needed (more secure to set in env vars)
- Select your model's voice from the dropdown (voices try to be fetched from TTS Provider API)
## 📜 License
### 3. ⬆️ Updating Docker Image
```bash
docker stop openreader-webui && \
docker rm openreader-webui && \
docker pull ghcr.io/richardr1126/openreader-webui:latest
```
### 📦 Volume mounts and Library import
By default (no volume mounts), OpenReader will store its server-side files inside the container filesystem (which is lost if you remove the container).
<details>
<summary>
**Persist server-side storage (`/app/docstore`)**
</summary>
Run the container with the volume mounted:
```bash
docker run --name openreader-webui \
--restart unless-stopped \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
ghcr.io/richardr1126/openreader-webui:latest
```
This will create a Docker named volume `openreader_docstore` to persist all server-side files, including:
- **Documents:** Stored under `/app/docstore/documents_v1`
- **Audiobook exports:** Stored under `/app/docstore/audiobooks_v1`
- Per-audiobook settings: `/app/docstore/audiobooks_v1/<bookId>-audiobook/audiobook.meta.json`
- Chapters: `0001__<title>.m4b` or `0001__<title>.mp3` (no per-chapter `.meta.json` files)
- **Settings**
This ensures that your documents, exported audiobooks, and server-side settings are retained even if the container is removed or recreated.
</details>
<details open>
<summary>
**Mount an external library folder (read-only recommended)**
</summary>
```bash
docker run --name openreader-webui \
--restart unless-stopped \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
-v /path/to/your/library:/app/docstore/library:ro \
ghcr.io/richardr1126/openreader-webui:latest
```
Separate from the main docstore volume, this mounts an external folder into the container at `/app/docstore/library` (read-only recommended) so OpenReader can use an existing library of documents.
To import from the mounted library: **Settings → Documents → Server Library Import**
> **Note:** Every file in the mounted volume is imported to the client browser's storage. Please ensure that the mounted library is not too large to avoid performance issues.
</details>
### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU)
You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version).
<details>
<summary>
**Kokoro-FastAPI (CPU)**
</summary>
```bash
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
```
> Adjust environment variables as needed for your hardware and use case.
</details>
<details>
<summary>
**Kokoro-FastAPI (GPU)**
</summary>
```bash
docker run -d \
--name kokoro-tts \
--gpus all \
--user 1001:1001 \
--restart unless-stopped \
-p 8880:8880 \
-e USE_GPU=true \
-e PYTHONUNBUFFERED=1 \
-e API_LOG_LEVEL=DEBUG \
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4
```
> Adjust environment variables as needed for your hardware and use case.
</details>
> **⚠️ Important Notes:**
> - For best results, set the `-e API_BASE=` for OpenReader's Docker to `http://kokoro-tts:8880/v1`
> - For issues or support, see the [Kokoro-FastAPI repository](https://github.com/remsky/Kokoro-FastAPI).
> - The GPU version requires NVIDIA Docker support and works best with NVIDIA GPUs. The CPU version works best on Apple Silicon or modern x86 CPUs.
## Local Development Installation
### Prerequisites
- Node.js (recommended: use [nvm](https://github.com/nvm-sh/nvm))
- pnpm (recommended) or npm
```bash
npm install -g pnpm
```
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
Optionally required for different features:
- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only)
```bash
brew install ffmpeg
```
- [libreoffice](https://www.libreoffice.org) (required for DOCX files)
```bash
brew install libreoffice
```
- [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, required for word-by-word highlighting)
```bash
# clone and build whisper.cpp (no model download needed OpenReader handles that)
git clone https://github.com/ggml-org/whisper.cpp.git
cd whisper.cpp
cmake -B build
cmake --build build -j --config Release
# point OpenReader to the compiled whisper-cli binary
echo WHISPER_CPP_BIN=\"$(pwd)/build/bin/whisper-cli\"
```
> **Note:** The `WHISPER_CPP_BIN` path should be set in your `.env` file for OpenReader to use word-by-word highlighting features.
### Steps
1. Clone the repository:
```bash
git clone https://github.com/richardr1126/OpenReader-WebUI.git
cd OpenReader-WebUI
```
2. Install dependencies:
With pnpm (recommended):
```bash
pnpm i # or npm i
```
3. Configure the environment:
```bash
cp template.env .env
# Edit .env with your configuration settings
```
> Note: The base URL for the TTS API should be accessible and relative to the Next.js server
4. Start the development server:
With pnpm (recommended):
```bash
pnpm dev # or npm run dev
```
or build and run the production server:
With pnpm:
```bash
pnpm build # or npm run build
pnpm start # or npm start
```
Visit [http://localhost:3003](http://localhost:3003) to run the app.
## 💡 Feature requests
For feature requests or ideas you have for the project, please use the [Discussions](https://github.com/richardr1126/OpenReader-WebUI/discussions) tab.
## 🙋‍♂️ Support and issues
If you encounter issues, please open an issue on GitHub following the template (which is very light).
## 👥 Contributing
Contributions are welcome! Fork the repository and submit a pull request with your changes.
## ❤️ Acknowledgements
This project would not be possible without standing on the shoulders of these giants:
- [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) model
- [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI)
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
- [ffmpeg](https://ffmpeg.org)
- [react-pdf](https://github.com/wojtekmaj/react-pdf) npm package
- [react-reader](https://github.com/happyr/react-reader) npm package
## Docker Supported Architectures
- linux/amd64 (x86_64)
- linux/arm64 (Apple Silicon, Raspberry Pi, SBCs, etc.)
## Stack
- **Framework:** Next.js (React)
- **Containerization:** Docker
- **Storage:**
- [Dexie.js](https://dexie.org/) IndexedDB wrapper for client-side storage
- **PDF:**
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
- [pdf.js](https://mozilla.github.io/pdf.js/)
- **EPUB:**
- [react-reader](https://github.com/gerhardsletten/react-reader)
- [epubjs](https://github.com/futurepress/epub.js/)
- **Markdown/Text:**
- [react-markdown](https://github.com/remarkjs/react-markdown)
- [remark-gfm](https://github.com/remarkjs/remark-gfm)
- **UI:**
- [Tailwind CSS](https://tailwindcss.com)
- [Headless UI](https://headlessui.com)
- [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin)
- **TTS:** (tested on)
- [Deepinfra API](https://deepinfra.com) (Kokoro-82M, Orpheus-3B, Sesame-1B)
- [Kokoro FastAPI TTS](https://github.com/remsky/Kokoro-FastAPI/tree/v0.0.5post1-stable)
- [Orpheus FastAPI TTS](https://github.com/Lex-au/Orpheus-FastAPI)
- **NLP:**
- [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting
- [cmpstr](https://github.com/remsky/cmpstr) String comparison library
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for TTS timestamps (word-by-word highlighting)
## License
This project is licensed under the MIT License.
MIT. See [LICENSE](LICENSE).

3
docs-site/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.docusaurus
build
node_modules

134
docs-site/custom.css Normal file
View file

@ -0,0 +1,134 @@
:root {
/* OpenReader default light theme colors */
--ifm-color-primary: #ef4444;
--ifm-color-primary-dark: #dc2626;
--ifm-color-primary-darker: #b91c1c;
--ifm-color-primary-darkest: #991b1b;
--ifm-color-primary-light: #f87171;
--ifm-color-primary-lighter: #fca5a5;
--ifm-color-primary-lightest: #fecaca;
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #f7fafc;
--ifm-navbar-background-color: #f7fafc;
--ifm-footer-background-color: #f7fafc;
--ifm-font-color-base: #2d3748;
--ifm-color-content: #2d3748;
--ifm-color-content-secondary: #718096;
--ifm-toc-border-color: #e2e8f0;
--ifm-hr-background-color: #e2e8f0;
--ifm-code-background: #e2e8f0;
--ifm-code-font-size: 95%;
}
[data-theme='dark'] {
/* OpenReader default dark theme colors */
--ifm-color-primary: #f87171;
--ifm-color-primary-dark: #ef4444;
--ifm-color-primary-darker: #dc2626;
--ifm-color-primary-darkest: #b91c1c;
--ifm-color-primary-light: #fb8c8c;
--ifm-color-primary-lighter: #fca5a5;
--ifm-color-primary-lightest: #fecaca;
--ifm-background-color: #111111;
--ifm-background-surface-color: #171717;
--ifm-navbar-background-color: #171717;
--ifm-footer-background-color: #171717;
--ifm-font-color-base: #ededed;
--ifm-color-content: #ededed;
--ifm-color-content-secondary: #a3a3a3;
--ifm-toc-border-color: #343434;
--ifm-hr-background-color: #343434;
--ifm-code-background: #343434;
}
.navbar {
border-bottom: 1px solid var(--ifm-toc-border-color);
}
.footer {
border-top: 1px solid var(--ifm-toc-border-color);
background: var(--ifm-background-surface-color);
}
.footer__title {
color: var(--ifm-font-color-base);
font-weight: 600;
}
.footer__link-item {
color: var(--ifm-color-content-secondary);
}
.footer__link-item:hover {
color: var(--ifm-color-primary);
text-decoration: none;
}
.footer__copyright {
border-top: 1px solid var(--ifm-toc-border-color);
color: var(--ifm-color-content-secondary);
margin-top: 1rem;
padding-top: 1rem;
}
/* Force override Infima defaults (which use high-specificity selectors). */
:root:not(#\#):not(#\#):not(#\#) {
--ifm-color-primary: #ef4444 !important;
--ifm-color-primary-dark: #dc2626 !important;
--ifm-color-primary-darker: #b91c1c !important;
--ifm-color-primary-darkest: #991b1b !important;
--ifm-color-primary-light: #f87171 !important;
--ifm-color-primary-lighter: #fca5a5 !important;
--ifm-color-primary-lightest: #fecaca !important;
--ifm-background-color: #ffffff !important;
--ifm-background-surface-color: #f7fafc !important;
--ifm-navbar-background-color: #f7fafc !important;
--ifm-footer-background-color: #f7fafc !important;
--ifm-font-color-base: #2d3748 !important;
--ifm-color-content: #2d3748 !important;
--ifm-color-content-secondary: #718096 !important;
--ifm-toc-border-color: #e2e8f0 !important;
--ifm-hr-background-color: #e2e8f0 !important;
--ifm-code-background: #e2e8f0 !important;
}
html[data-theme='dark']:not(#\#):not(#\#):not(#\#) {
--ifm-color-primary: #f87171 !important;
--ifm-color-primary-dark: #ef4444 !important;
--ifm-color-primary-darker: #dc2626 !important;
--ifm-color-primary-darkest: #b91c1c !important;
--ifm-color-primary-light: #fb8c8c !important;
--ifm-color-primary-lighter: #fca5a5 !important;
--ifm-color-primary-lightest: #fecaca !important;
--ifm-background-color: #111111 !important;
--ifm-background-surface-color: #171717 !important;
--ifm-navbar-background-color: #171717 !important;
--ifm-footer-background-color: #171717 !important;
--ifm-font-color-base: #ededed !important;
--ifm-color-content: #ededed !important;
--ifm-color-content-secondary: #a3a3a3 !important;
--ifm-toc-border-color: #343434 !important;
--ifm-hr-background-color: #343434 !important;
--ifm-code-background: #343434 !important;
}
/* Search/Ask-AI plugin accents should follow OpenReader accent colors. */
:root:not(#\#):not(#\#):not(#\#) {
--search-local-highlight-color: var(--ifm-color-primary) !important;
--search-local-hit-active-color: var(--ifm-color-primary) !important;
--search-local-input-active-border-color: var(--ifm-color-primary) !important;
}
.ask-ai:not(#\#):not(#\#) {
--ask-ai-primary: var(--ifm-color-primary) !important;
--ask-ai-primary-hover: var(--ifm-color-primary-dark) !important;
}
html[data-theme='dark'] .ask-ai:not(#\#):not(#\#) {
--ask-ai-primary: var(--ifm-color-primary) !important;
--ask-ai-primary-hover: var(--ifm-color-primary-dark) !important;
}

View file

@ -0,0 +1,16 @@
---
title: Acknowledgements
---
This project is built with support from the following open-source projects and tools:
- [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M)
- [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI)
- [Better Auth](https://www.better-auth.com/)
- [SQLite](https://www.sqlite.org/)
- [PostgreSQL](https://www.postgresql.org/)
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs)
- [whisper.cpp](https://github.com/ggerganov/whisper.cpp)
- [ffmpeg](https://ffmpeg.org)
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
- [react-reader](https://github.com/happyr/react-reader)

View file

@ -0,0 +1,7 @@
---
title: License
---
OpenReader is licensed under the MIT License.
- Repository license file: [LICENSE](https://github.com/richardr1126/openreader/blob/main/LICENSE)

View file

@ -0,0 +1,19 @@
---
title: Support and Contributing
---
## Feature requests
Use [GitHub Discussions](https://github.com/richardr1126/openreader/discussions) for feature requests and ideas.
## Issues and support
If you encounter a bug, open an issue in [GitHub Issues](https://github.com/richardr1126/openreader/issues).
## Contributing
Contributions are welcome.
- Fork the repository
- Create your branch
- Open a pull request with your changes

View file

@ -0,0 +1,46 @@
---
title: Auth
---
This page covers application-level configuration for provider access and authentication.
## Auth behavior
- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set.
- Remove either value to disable auth.
- Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`.
- Anonymous auth sessions are disabled by default.
- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows.
## Route behavior
- `/` is a public landing/onboarding page and remains indexable.
- `/app` is the protected app home (document list and uploader UI).
- If auth is enabled and a valid session exists (including anonymous), visiting `/` redirects to `/app`.
- Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`.
## Related docs
- For auth environment variables: [Environment Variables](../reference/environment-variables#auth-and-identity)
- For TTS character limits and quota behavior: [TTS Rate Limiting](./tts-rate-limiting)
- For provider-specific guidance: [TTS Providers](./tts-providers)
- For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage)
- For database mode: [Database](./database)
- For migration behavior and commands: [Migrations](./migrations)
## Sync notes
### Auth enabled
- Settings and reading progress are saved to the server.
- Updates are not instant push-based sync; they use normal client polling/refresh behavior.
- If two devices change the same item around the same time, the newest update wins.
### Auth disabled
- Settings and reading progress stay local in the browser (Dexie/IndexedDB).
- This avoids no-auth cross-browser conflicts, but there is no cross-device sync.
## Claim modal note
- You may still see old anonymous settings/progress available to claim from older deployments.

View file

@ -0,0 +1,39 @@
---
title: Database
---
This page covers database mode selection for OpenReader.
## Database mode
- SQLite (default): embedded DB at `docstore/sqlite3.db`; good for local/self-host single-instance setups.
- Postgres: enabled when `POSTGRES_URL` is set; recommended for production/distributed deployments.
## What the database stores
- Document and audiobook metadata/state used by server routes.
- Auth/session tables (`user`, `session`, `account`, `verification`) when auth is enabled — schema is auto-generated by Better Auth.
- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled).
- User settings preferences (`user_preferences`) when auth is enabled.
- User reading progress (`user_document_progress`) when auth is enabled.
- Document preview job/asset metadata (`document_previews`) for server-side PDF/EPUB thumbnails.
App-specific tables are manually maintained in Drizzle schema files, while auth tables are generated by the Better Auth CLI. Both are migrated together via Drizzle. See [Migrations](./migrations) for details.
## Related variables
- `POSTGRES_URL`
For database variable behavior, see [Environment Variables](../reference/environment-variables#database-and-object-blob-storage).
## Related docs
- [Migrations](./migrations)
- [Object / Blob Storage](./object-blob-storage)
- [Auth](./auth)
## State sync summary
- With auth enabled, settings and reading progress are stored in SQL and synced from the app.
- With auth disabled, settings and reading progress remain local in the browser.
- Sync is currently request-based (not realtime push invalidation).

View file

@ -0,0 +1,132 @@
---
title: Migrations
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
This page covers migration behavior for both database schema and storage data in OpenReader.
## Startup migration behavior
By default, the shared entrypoint runs migrations automatically before app startup in:
- Docker container startup
- `pnpm dev`
- `pnpm start`
Startup migration phases:
- DB schema migrations (`pnpm migrate`)
- Storage/data migration (`pnpm migrate-fs`) for legacy filesystem content into S3 + DB rows
:::info
In most setups, you do not need to run migration commands manually because startup handles this automatically.
:::
To skip automatic startup migrations:
- Set `RUN_DRIZZLE_MIGRATIONS=false`
- Set `RUN_FS_MIGRATIONS=false`
:::warning
If you disable startup migrations, ensure your deployment process runs migrations before serving traffic.
:::
## Apply migrations
In most cases, you do not need manual migration commands because startup runs migrations automatically.
`pnpm migrate` applies migrations for one database target:
- Postgres when `POSTGRES_URL` is set
- SQLite when `POSTGRES_URL` is unset
You can always override the target explicitly with `--config`.
<Tabs groupId="apply-migration-commands">
<TabItem value="project-scripts" label="Project Scripts" default>
```bash
# Run pending migrations for one target:
# - Postgres if POSTGRES_URL is set
# - SQLite if POSTGRES_URL is unset
pnpm migrate
# Run storage migration (filesystem -> S3 + DB)
pnpm migrate-fs
# Dry-run storage migration without uploading/deleting
pnpm migrate-fs:dry-run
```
</TabItem>
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
```bash
# Migrate SQLite
pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts
# Migrate Postgres
pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
```
</TabItem>
</Tabs>
## Generate migrations
`pnpm generate` is a two-phase script for contributors and schema changes:
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
:::note
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
:::
### Schema ownership
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
- `src/db/schema_auth_sqlite.ts`
- `src/db/schema_auth_postgres.ts`
App-specific tables are manually maintained in the standard Drizzle schema files:
- `src/db/schema_sqlite.ts`
- `src/db/schema_postgres.ts`
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
<Tabs groupId="generate-migration-commands">
<TabItem value="project-script" label="Project Script" default>
```bash
# Full pipeline: Better Auth CLI + Drizzle generate (both dialects)
pnpm generate
```
</TabItem>
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
```bash
# Generate SQLite migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
# Generate Postgres migrations only (skips Better Auth CLI)
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
```
:::warning
Running `drizzle-kit generate` directly skips the Better Auth CLI step. If auth schema has changed upstream (e.g. after a Better Auth version bump), run `pnpm generate` instead to regenerate the auth schema files first.
:::
</TabItem>
</Tabs>
## Related docs
- [Database](./database)
- [Object / Blob Storage](./object-blob-storage)
- [Migration Environment Variables](../reference/environment-variables#migration-controls)

View file

@ -0,0 +1,103 @@
---
title: Object / Blob Storage
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
This page documents storage backends, blob upload routing, and core Docker mount behavior.
## Storage backends
- Embedded (default): SQLite metadata + embedded SeaweedFS (`weed mini`) blobs.
- External: Postgres + external S3-compatible object storage.
Storage variables are documented in [Environment Variables](../reference/environment-variables#database-and-object-blob-storage).
## Ports
- `3003`: OpenReader app and API routes
- `8333`: Embedded SeaweedFS S3 endpoint for direct browser blob access
:::info
`8333` is only needed for direct browser presigned access to embedded SeaweedFS.
:::
## Upload behavior
- Primary path: browser uploads to presigned URL from `/api/documents/blob/upload/presign`.
- Fallback path: `/api/documents/blob/upload/fallback` when direct upload fails/unreachable.
- Read/download path: blob/content serving route `/api/documents/blob` (not the upload fallback route).
- Preview path: `/api/documents/blob/preview` (returns `202` while a preview is generating; serves/redirects when ready).
## Document previews
- PDF/EPUB previews are generated server-side and stored in object storage under `document_previews_v1`.
- Preview generation is triggered on upload registration and also backfills on first preview request for older docs.
- Preview artifacts are temporary-cache friendly and can be regenerated from the source document blob.
## FS / Volume Mounts
### App data mount
- Target: `/app/docstore`
- Recommended: yes, for persistence
- Purpose: persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state
- Mount string: `-v openreader_docstore:/app/docstore`
### Library source mount (optional)
- Target: `/app/docstore/library`
- Recommended: optional, use read-only (`:ro`)
- Purpose: exposes host files as a source for server library import
- Mount string: `-v /path/to/your/library:/app/docstore/library:ro`
- Details: [Server Library Import](./server-library-import)
## Private blob endpoint mode
If `8333` is not published externally:
- Document uploads still work through upload fallback proxy
- Reads/snippets continue through app API routes
- Direct presigned browser upload/download to embedded endpoint is unavailable
:::warning
Without `8333`, expect higher app-server traffic because uploads/downloads go through API routes instead of direct object endpoint access.
:::
## Audiobook Storage Debug Commands
Audiobook assets are stored in object storage under the `audiobooks_v1` keyspace. Use these commands to inspect and download objects for debugging.
<Tabs groupId="audiobook-storage-access-cli">
<TabItem value="aws-s3" label="AWS S3" default>
```bash
# List all audiobook objects
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive
# Filter to one book id (replace <book-id>)
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive | grep "<book-id>-audiobook/"
# Download one object by full key
aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/<path>/<file>.m4b" "./audiobook.m4b"
```
</TabItem>
<TabItem value="s3-compatible" label="Embedded / MinIO / R2 / etc">
```bash
# List all audiobook objects
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT"
# Filter to one book id (replace <book-id>)
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT" | grep "<book-id>-audiobook/"
# Download one object by full key
aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/<path>/<file>.m4b" "./audiobook.m4b" --endpoint-url "$S3_ENDPOINT"
```
Embedded default example: `S3_ENDPOINT=http://127.0.0.1:8333` (or your mapped host/port).
</TabItem>
</Tabs>

View file

@ -0,0 +1,68 @@
---
title: Server Library Import
---
This page documents how server library import works and how to configure it.
## What it does
Server library import lets you browse files from one or more server directories and import selected files into OpenReader.
- Import is user-driven via a selection modal
- Only selected files are imported
- Imported files become normal OpenReader documents
## FS / Volume Mounts
### App data mount
- Target: `/app/docstore`
- Recommended: yes, for persistence
- Purpose: stores app runtime data, metadata DB, and embedded storage state
- Mount string: `-v openreader_docstore:/app/docstore`
### Library source mount
- Target: `/app/docstore/library`
- Recommended: yes for this feature, use read-only (`:ro`)
- Purpose: exposes host files as import candidates in Server Library Import
- Mount string: `-v /path/to/your/library:/app/docstore/library:ro`
## Import flow
1. Open **Settings -> Documents -> Server Library Import**.
2. Select files in the modal.
3. Click **Import**.
Selected files are fetched from the server library endpoint and imported into OpenReader storage.
:::warning Shared Library Roots
Library roots are configured at the server level (not per-user). Any user who can access Server Library Import can browse/import from the same configured roots.
Imported documents are still saved to the importing user's document scope.
:::
## Supported file types
- `.pdf`
- `.epub`
- `.html`, `.htm`
- `.txt`
- `.md`, `.mdown`, `.markdown`
## Optional: Configure Library Roots
You only need this when the default mounted path is not what you want.
By default, OpenReader uses `docstore/library` as the import root. You can override that with environment variables:
- `IMPORT_LIBRARY_DIRS` (takes precedence): multiple roots separated by comma, colon, or semicolon
- `IMPORT_LIBRARY_DIR`: single root
See [Environment Variables](../reference/environment-variables#library-import) for variable details.
## Notes
- Library listing is capped per request (up to 10,000 files).
- When auth is enabled, library import endpoints require a valid session.
- The mounted library is a source; removing it does not delete already imported documents.

View file

@ -0,0 +1,40 @@
---
title: Custom OpenAI
---
Use any custom OpenAI-compatible TTS service with OpenReader.
Use this integration when your endpoint is not directly covered by built-in dropdown defaults.
## Provider
- Provider: `Custom OpenAI-Like`
- `API_BASE`: required (your service base URL)
- `API_KEY`: set if required by your service
Custom providers should expose:
- `GET /v1/audio/voices`
- `POST /v1/audio/speech`
## OpenReader setup
1. In OpenReader Settings, choose provider `Custom OpenAI-Like`.
2. Set `API_BASE` to your service base URL (typically ending with `/v1`).
3. Set `API_KEY` if your service requires authentication.
4. Choose a model and voice supported by your backend.
## Notes
:::warning Compatibility required
Custom providers must implement OpenAI-compatible TTS endpoints, including `GET /v1/audio/voices` and `POST /v1/audio/speech`.
:::
:::info Voice troubleshooting
If voices do not load, verify the `/v1/audio/voices` response shape and that the endpoint is reachable from the OpenReader server.
:::
## References
- [TTS Providers](../tts-providers)
- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)

View file

@ -0,0 +1,33 @@
---
title: Deepinfra
---
Use Deepinfra as a hosted OpenAI-compatible TTS provider.
## Provider
- Provider: `Deepinfra`
- Default endpoint: `https://api.deepinfra.com/v1/openai` (auto-filled)
- `API_KEY`: required for authenticated DeepInfra usage
## OpenReader setup
1. In OpenReader Settings, choose provider `Deepinfra`.
2. Keep the default `API_BASE`.
3. Set `API_KEY`.
4. Choose your model and voice.
## Notes
:::tip Built-in endpoint
`Deepinfra` is a built-in provider, so OpenReader auto-fills the default `API_BASE`.
:::
:::info Model support
DeepInfra exposes multiple TTS models, including Kokoro-family options.
:::
## References
- [TTS Providers](../tts-providers)
- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)

View file

@ -0,0 +1,68 @@
---
title: Kokoro-FastAPI
---
You can run the Kokoro TTS API server directly with Docker.
:::warning
For Kokoro issues and support, use the upstream repository: [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).
:::
## Provider
- Provider: `Custom OpenAI-Like`
- Typical model: `Kokoro`
- `API_BASE`: required (typically your Kokoro URL ending with `/v1`)
- `API_KEY`: set only if your deployment requires one
## Run Kokoro (CPU)
```bash
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
```
## Run Kokoro (GPU)
```bash
docker run -d \
--name kokoro-tts \
--gpus all \
--user 1001:1001 \
--restart unless-stopped \
-p 8880:8880 \
-e USE_GPU=true \
-e PYTHONUNBUFFERED=1 \
-e API_LOG_LEVEL=DEBUG \
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4
```
## OpenReader setup
1. Start Kokoro using either the CPU or GPU image.
2. In OpenReader Settings, choose provider `Custom OpenAI-Like`.
3. Set `API_BASE` to your Kokoro endpoint (for Docker Compose, commonly `http://kokoro-tts:8880/v1`).
4. Set `API_KEY` only if your deployment requires one.
5. Choose model `Kokoro`.
## Notes
:::tip Runtime guidance
GPU mode requires NVIDIA Docker support and is best on NVIDIA hardware. CPU mode is a good default on Apple Silicon and modern x86 CPUs.
:::
## References
- [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI)
- [TTS Providers](../tts-providers)
- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)

View file

@ -0,0 +1,33 @@
---
title: OpenAI
---
Use OpenAI directly as an OpenAI-compatible TTS provider.
## Provider
- Provider: `OpenAI`
- Default endpoint: `https://api.openai.com/v1` (auto-filled)
- `API_KEY`: required for OpenAI access
## OpenReader setup
1. In OpenReader Settings, choose provider `OpenAI`.
2. Keep the default `API_BASE`.
3. Set `API_KEY`.
4. Choose your model and voice.
## Notes
:::tip Built-in endpoint
`OpenAI` is a built-in provider, so OpenReader auto-fills the default `API_BASE`.
:::
:::info Server-side requests
OpenReader sends TTS requests from the server runtime, not directly from the browser.
:::
## References
- [TTS Providers](../tts-providers)
- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)

View file

@ -0,0 +1,36 @@
---
title: Orpheus-FastAPI
---
Use Orpheus-FastAPI as an OpenAI-compatible TTS backend for OpenReader.
## Provider
- Provider: `Custom OpenAI-Like`
- Typical model: `Orpheus`
- `API_BASE`: required (usually your Orpheus URL ending with `/v1`)
- `API_KEY`: set only if your deployment requires one
## OpenReader setup
1. Start your Orpheus-FastAPI server.
2. In OpenReader Settings, choose provider `Custom OpenAI-Like`.
3. Set `API_BASE` to your Orpheus base URL (typically ending with `/v1`).
4. Set `API_KEY` only if your Orpheus deployment requires one.
5. Choose model `Orpheus` (or another model exposed by your deployment).
## Notes
:::info OpenAI-compatible API
OpenReader expects OpenAI-compatible audio endpoints when using Orpheus through `Custom OpenAI-Like`.
:::
:::tip Endpoint shape
Use an `API_BASE` that points at the Orpheus API root (typically ending with `/v1`).
:::
## References
- [Lex-au/Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
- [TTS Providers](../tts-providers)
- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)

View file

@ -0,0 +1,79 @@
---
title: TTS Providers
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
OpenReader supports OpenAI-compatible TTS providers through a common API shape.
:::tip
If you are running a self-hosted TTS server (Kokoro/Orpheus/etc.), use **Custom OpenAI-Like** in Settings.
:::
## Quick Setup by Provider
<Tabs groupId="tts-provider-setup">
<TabItem value="openai" label="OpenAI" default>
1. In Settings, choose provider: `OpenAI`.
2. Keep the default `API_BASE` (auto-filled).
3. Set `API_KEY` to your OpenAI key.
4. Choose model/voice.
</TabItem>
<TabItem value="deepinfra" label="DeepInfra">
1. In Settings, choose provider: `Deepinfra`.
2. Keep the default `API_BASE` (auto-filled).
3. Set `API_KEY` to your DeepInfra key.
4. Choose model/voice.
</TabItem>
<TabItem value="custom" label="Custom OpenAI-Like">
1. In Settings, choose provider: `Custom OpenAI-Like`.
2. Set `API_BASE` to your endpoint (example: `http://host.docker.internal:8880/v1`).
3. Set `API_KEY` if your provider requires one.
4. Choose model/voice.
</TabItem>
</Tabs>
## Provider Dropdown Behavior
In Settings, provider options include:
- `OpenAI`
- `Deepinfra`
- `Custom OpenAI-Like` (Kokoro, Orpheus, and other OpenAI-compatible endpoints)
`API_BASE` guidance:
- `OpenAI` and `Deepinfra` auto-fill default endpoints.
- `Custom OpenAI-Like` requires setting `API_BASE` manually.
:::info OpenAI-Compatible API Shape
Custom providers should expose:
- `GET /v1/audio/voices`
- `POST /v1/audio/speech`
:::
:::warning Server-Reachable API Base
TTS requests are sent from the Next.js server, not directly from the browser. `API_BASE` must be reachable from the server runtime.
:::
## Provider Guides
- [Kokoro-FastAPI](./tts-provider-guides/kokoro-fastapi)
- [Orpheus-FastAPI](./tts-provider-guides/orpheus-fastapi)
- [DeepInfra](./tts-provider-guides/deepinfra)
- [OpenAI](./tts-provider-guides/openai)
- [Custom OpenAI-Like](./tts-provider-guides/custom-openai)
## Related Configuration
- [TTS Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
- [TTS Rate Limiting](./tts-rate-limiting)
- [Auth](./auth)

View file

@ -0,0 +1,51 @@
---
title: TTS Rate Limiting
---
This page explains OpenReader's TTS character rate limiting controls.
## Overview
- TTS rate limiting is disabled by default.
- To enable it, set `TTS_ENABLE_RATE_LIMIT=true`.
- Limits are enforced per day in UTC.
- Enforcement applies only when auth is enabled.
## How enforcement works
When enabled, OpenReader enforces:
- Per-user daily character limits.
- IP backstop daily character limits.
- Anonymous device backstop tracking (cookie-based) to reduce limit resets.
If a request exceeds the active limit, the TTS API returns `429` with reset metadata for the next UTC day.
## Required auth behavior
- Auth must be enabled (`BASE_URL` + `AUTH_SECRET`) for TTS char limits to apply.
- If auth is disabled, TTS character limits are effectively unlimited.
- `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling.
- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits.
## Environment variables
Enable/disable:
- `TTS_ENABLE_RATE_LIMIT` (default: `false`)
Per-user daily limits:
- `TTS_DAILY_LIMIT_ANONYMOUS` (default: `50000`)
- `TTS_DAILY_LIMIT_AUTHENTICATED` (default: `500000`)
IP backstop daily limits:
- `TTS_IP_DAILY_LIMIT_ANONYMOUS` (default: `100000`)
- `TTS_IP_DAILY_LIMIT_AUTHENTICATED` (default: `1000000`)
## Related docs
- TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
- Auth configuration: [Auth](./auth)
- Provider setup: [TTS Providers](./tts-providers)

View file

@ -0,0 +1,138 @@
---
title: Local Development
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## Prerequisites
- Node.js (recommended with [nvm](https://github.com/nvm-sh/nvm))
- `pnpm` (recommended) or `npm`
```bash
npm install -g pnpm
```
- A reachable TTS API server
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required unless using external S3 storage)
<Tabs groupId="seaweedfs-install">
<TabItem value="macos" label="macOS" default>
```bash
brew install seaweedfs
```
</TabItem>
<TabItem value="linux" label="Linux">
Install the `weed` binary from the [SeaweedFS releases](https://github.com/seaweedfs/seaweedfs/releases) and ensure it is available on `PATH`.
</TabItem>
</Tabs>
Optional, depending on features:
- [libreoffice](https://www.libreoffice.org) (required for DOCX conversion)
```bash
brew install libreoffice
```
- [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, for word-by-word highlighting)
```bash
# clone and build whisper.cpp (no model download needed OpenReader handles that)
git clone https://github.com/ggml-org/whisper.cpp.git
cd whisper.cpp
cmake -B build
cmake --build build -j --config Release
# point OpenReader to the compiled whisper-cli binary
echo WHISPER_CPP_BIN="$(pwd)/build/bin/whisper-cli"
```
:::tip
Set `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting.
:::
## Steps
1. Clone the repository.
```bash
git clone https://github.com/richardr1126/openreader.git
cd openreader
```
2. Install dependencies.
```bash
pnpm i
```
3. Configure the environment.
```bash
cp .env.example .env
```
Then edit `.env`.
- No auth mode: leave `BASE_URL` or `AUTH_SECRET` unset.
- Auth enabled mode: set both `BASE_URL` (typically `http://localhost:3003`) and `AUTH_SECRET` (generate with `openssl rand -hex 32`).
Optional:
- `AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://192.168.0.116:3003`
- Stable S3 credentials via `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`
- External S3 storage by setting `USE_EMBEDDED_WEED_MINI=false` and related S3 vars
:::info
For all environment variables, see [Environment Variables](../reference/environment-variables).
:::
See [Auth](../configure/auth) for app/auth behavior.
Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage).
Refer to [Database](../configure/database) for database modes.
Learn about migration behavior and commands in [Migrations](../configure/migrations).
4. Run DB migrations.
- Migrations run automatically on startup through the shared entrypoint for both `pnpm dev` and `pnpm start`.
- You only need manual migration commands for one-off troubleshooting or explicit migration workflows:
```bash
pnpm migrate
```
:::info
If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DRIZZLE_MIGRATIONS=false` and/or `RUN_FS_MIGRATIONS=false`. You can run storage migration manually with `pnpm migrate-fs`.
:::
5. Start the app.
<Tabs groupId="local-run-mode">
<TabItem value="dev" label="Dev" default>
```bash
pnpm dev
```
</TabItem>
<TabItem value="prod" label="Build + Start">
```bash
pnpm build
pnpm start
```
</TabItem>
</Tabs>
:::warning API Base Reachability
`API_BASE` must be reachable from the Next.js server process, not just your browser.
:::
Visit [http://localhost:3003](http://localhost:3003).

View file

@ -0,0 +1,108 @@
---
title: Vercel Deployment
---
This guide covers deploying OpenReader to Vercel with external Postgres and S3-compatible object storage.
## What works on Vercel
- Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage.
- Audiobook routes work on Node.js serverless functions using `ffmpeg-static`.
:::warning DOCX Conversion Limitation
`docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime.
:::
## 1. Environment Variables
Recommended production setup (auth enabled):
```bash
API_BASE=https://api.deepinfra.com/v1/openai
API_KEY=your_deepinfra_key
POSTGRES_URL=postgres://...
USE_EMBEDDED_WEED_MINI=false
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BUCKET=...
S3_REGION=us-east-1
S3_PREFIX=openreader
BASE_URL=https://your-app.vercel.app
AUTH_SECRET=...
# Optional client/runtime feature defaults:
NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false
NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false
NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra
NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M
NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false
NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true
NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false
# Optional (non-AWS S3-compatible providers):
# S3_ENDPOINT=https://...
# S3_FORCE_PATH_STYLE=true
```
:::info Production Configuration & Feature Flags
We recommend setting these defaults for a production-like environment:
- `NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false`: Disables DOCX upload (requires external tools anyway)
- `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false`: Hides destructive "Delete All" actions
- `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra`: Points default TTS to a scalable provider
- `NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M`: Uses a high-quality default model
- `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false`: Restricts usage to free models if no key is provided
- `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true`: (Optional) Controls audiobook export UI
- `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false`: (Optional) Controls word highlighting UI (requires timestamp backend)
:::
:::warning Auth recommendation
For internet-exposed Vercel deployments, set both `BASE_URL` and `AUTH_SECRET`. Running without auth is possible, but not recommended for public environments.
:::
:::tip
For all variables and defaults, see [Environment Variables](../reference/environment-variables).
:::
## 2. FFmpeg packaging in Vercel functions
`ffmpeg-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for:
- `/api/audiobook`
- `/api/audiobook/chapter`
- `/api/audiobook/status`
- `/api/whisper`
:::info
`serverExternalPackages` should include `ffmpeg-static` so package paths resolve at runtime instead of being bundled into route output.
:::
If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly.
## 3. Function memory sizing
FFmpeg workloads benefit from more memory/CPU. This repo includes:
```json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"app/api/audiobook/route.ts": { "memory": 3009 },
"app/api/whisper/route.ts": { "memory": 3009 }
}
}
```
Adjust memory per route if your files are larger or your plan differs.
## 4. Runtime expectations and caveats
- Audiobook APIs require S3 configuration; otherwise they return `503`.
- For production Vercel deploys, use `POSTGRES_URL` instead of SQLite.
- Filesystem-to-object-store migrations run via server scripts/entrypoint (`scripts/migrate-fs-v2.mjs`), not API routes.
- Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so run `pnpm migrate-fs` in a controlled environment when migrating legacy filesystem data.
## 5. Smoke test after deploy
1. Upload and read a PDF/EPUB document.
2. Confirm sync/blob fetch works across refreshes/devices.
3. Generate at least one audiobook chapter and play/download it.
4. If using word highlighting, verify timestamps are produced and rendered.

View file

@ -0,0 +1,106 @@
---
title: Docker Quick Start
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## Prerequisites
- A recent Docker version installed
- A TTS API server that OpenReader can reach (Kokoro-FastAPI, Orpheus-FastAPI, DeepInfra, OpenAI, or equivalent)
:::note
If you have suitable hardware, you can run Kokoro locally with Docker. See [Kokoro-FastAPI](./configure/tts-provider-guides/kokoro-fastapi).
:::
## 1. Start the Docker container
<Tabs groupId="docker-start-mode">
<TabItem value="minimal" label="Minimal" default>
Auth disabled, embedded storage ephemeral, no library import:
```bash
docker run --name openreader \
--restart unless-stopped \
-p 3003:3003 \
-p 8333:8333 \
ghcr.io/richardr1126/openreader:latest
```
</TabItem>
<TabItem value="full" label="Full Setup">
Persistent storage, embedded SeaweedFS `weed mini`, optional auth, optional library mount:
```bash
docker run --name openreader \
--restart unless-stopped \
-p 3003:3003 \
-p 8333:8333 \
-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 BASE_URL=http://localhost:3003 \
-e AUTH_SECRET=$(openssl rand -hex 32) \
ghcr.io/richardr1126/openreader:latest
```
</TabItem>
</Tabs>
:::tip
Remove `/app/docstore/library` if you do not need server library import.
:::
:::tip
Remove either `BASE_URL` or `AUTH_SECRET` to keep auth disabled.
:::
:::tip TTS API Base
Set `API_BASE` to your reachable TTS server base URL.
:::
:::warning Port `8333` Exposure
Expose `8333` for direct browser presigned upload/download with embedded SeaweedFS.
If `8333` is not reachable from the browser, direct presigned access is unavailable. Uploads can still fall back to `/api/documents/blob/upload/fallback`, and document reads/downloads continue through `/api/documents/blob`.
:::
:::info Auth and Migrations
- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set.
- DB/storage migrations run automatically at container startup via the shared entrypoint.
:::
:::info Related Docs
- [Environment Variables](./reference/environment-variables)
- [Auth](./configure/auth)
- [Database](./configure/database)
- [Object / Blob Storage](./configure/object-blob-storage)
- [Migrations](./configure/migrations)
:::
## 2. Configure settings in the app UI
- Set TTS provider and model in Settings
- Set TTS API base URL and API key if needed
- Select the model voice from the voice dropdown
## 3. Update Docker image
Legacy image compatibility: `ghcr.io/richardr1126/openreader-webui:latest` remains available as an alias.
```bash
docker stop openreader || true && \
docker rm openreader || true && \
docker image rm ghcr.io/richardr1126/openreader:latest || true && \
docker pull ghcr.io/richardr1126/openreader:latest
```
:::tip
If you use a mounted volume for `/app/docstore`, your persisted data remains after image updates.
:::
Visit [http://localhost:3003](http://localhost:3003) after startup.

View file

@ -0,0 +1,51 @@
---
id: intro
title: Introduction
slug: /
---
OpenReader is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
> Previously named **OpenReader-WebUI**.
It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenAI-compatible endpoints such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI).
## ✨ Highlights
- 🎯 **Multi-Provider TTS Support**
- [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): supports multi-voice combinations (for example `af_heart+af_bella`)
- [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI)
- **Custom OpenAI-compatible**: any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints
- **Cloud TTS providers**:
- [**DeepInfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M and other hosted models
- [**OpenAI API**](https://platform.openai.com/docs/pricing#transcription-and-speech): `tts-1`, `tts-1-hd`, and `gpt-4o-mini-tts`
- 🛜 **Server-side Document Storage**
- Documents are persisted in server blob/object storage for consistent access
- 📚 **External Library Import**
- Import documents from server-mounted folders
- 🎧 **Server-side Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
- 📖 **Read Along Experience**
- Real-time highlighting for PDF/EPUB, with optional word-level [whisper.cpp](https://github.com/ggml-org/whisper.cpp) timestamps
- 🔐 **Auth Optional by Design**
- Run no-auth for local use, or enable auth with user isolation and claim flow
- 🗂️ **Flexible Storage and Database Modes** with embedded defaults or external S3/Postgres
- 🚀 **Production-ready Server Behavior** with TTS caching/retries/rate limits and startup migrations
- 🎨 **Customizable Experience**
- Theme, TTS, and document handling controls
## 🧭 Key Docs
- [Docker Quick Start](./docker-quick-start)
- [Local Development](./deploy/local-development)
- [Vercel Deployment](./deploy/vercel-deployment)
- [Environment Variables](./reference/environment-variables)
- [Auth](./configure/auth)
- [Database](./configure/database)
- [Object / Blob Storage](./configure/object-blob-storage)
- [Migrations](./configure/migrations)
- [Server Library Import](./configure/server-library-import)
- [TTS Providers](./configure/tts-providers)
## Source Repository
- GitHub: [richardr1126/openreader](https://github.com/richardr1126/openreader)

View file

@ -0,0 +1,395 @@
---
title: Environment Variables
toc_max_heading_level: 3
---
This is the single reference page for OpenReader environment variables.
## Quick Reference Table
| Variable | Area | Default | When to set |
| --- | --- | --- | --- |
| `NEXT_PUBLIC_ENABLE_DOCX_CONVERSION` | Client feature flags | `true` unless set to `false` | Set `false` to hide DOCX support |
| `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS` | Client feature flags | `true` unless set to `false` | Set `false` to hide destructive actions |
| `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER` | Client feature flags | `custom-openai` | Override default TTS provider |
| `NEXT_PUBLIC_DEFAULT_TTS_MODEL` | Client feature flags | `kokoro` | Override default TTS model |
| `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS` | Client feature flags | `true` unless set to `false` | Set `false` to restrict DeepInfra models |
| `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT` | Client feature flags | `true` unless set to `false` | Set `false` to hide audiobook export UI |
| `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT` | Client feature flags | `true` unless set to `false` | Set `false` to disable word highlight + alignment |
| `API_BASE` | TTS provider | none | Point to your OpenAI-compatible TTS base URL |
| `API_KEY` | TTS provider | `none` fallback in TTS route | Set when provider requires auth |
| `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size |
| `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL |
| `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx |
| `TTS_RETRY_INITIAL_MS` | TTS retry | `250` | Tune initial retry delay |
| `TTS_RETRY_MAX_MS` | TTS retry | `2000` | Tune max retry delay |
| `TTS_RETRY_BACKOFF` | TTS retry | `2` | Tune exponential backoff factor |
| `TTS_ENABLE_RATE_LIMIT` | Rate limiting | `false` | Set `true` to enable TTS per-user/IP daily character limits |
| `TTS_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `50000` | Override anonymous per-user daily character limit |
| `TTS_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `500000` | Override authenticated per-user daily character limit |
| `TTS_IP_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `100000` | Override anonymous IP backstop daily limit |
| `TTS_IP_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `1000000` | Override authenticated IP backstop daily limit |
| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth |
| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth |
| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins |
| `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to enable anonymous auth sessions |
| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in |
| `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in |
| `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting |
| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres |
| `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only |
| `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory |
| `WEED_MINI_WAIT_SEC` | Storage | `20` | Tune SeaweedFS startup wait timeout |
| `S3_ACCESS_KEY_ID` | Storage | auto-generated in embedded mode | Set explicitly for stable/external credentials |
| `S3_SECRET_ACCESS_KEY` | Storage | auto-generated in embedded mode | Set explicitly for stable/external credentials |
| `S3_BUCKET` | Storage | `openreader-documents` in embedded mode | Required for external S3-compatible storage |
| `S3_REGION` | Storage | `us-east-1` in embedded mode | Required for external S3-compatible storage |
| `S3_ENDPOINT` | Storage | derived in embedded mode | Set for S3-compatible providers (MinIO/SeaweedFS/R2/etc.) |
| `S3_FORCE_PATH_STYLE` | Storage | `true` in embedded mode | Set per provider requirement |
| `S3_PREFIX` | Storage | `openreader` | Customize object key prefix |
| `RUN_DRIZZLE_MIGRATIONS` | Database migrations | `true` | Set `false` to skip startup Drizzle schema migrations |
| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass |
| `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root |
| `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) |
| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps |
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
## TTS Provider and Request Behavior
### API_BASE
Base URL for OpenAI-compatible TTS API requests.
- Example: `http://host.docker.internal:8880/v1`
- Can be overridden per request from UI settings
- Related docs: [TTS Providers](../configure/tts-providers)
### API_KEY
Default API key for TTS provider requests.
- Example: `none` or your provider token
- Can be overridden by request headers from app settings
- Related docs: [TTS Providers](../configure/tts-providers)
### TTS_CACHE_MAX_SIZE_BYTES
Maximum in-memory TTS audio cache size in bytes.
- Default: `268435456` (256 MB)
### TTS_CACHE_TTL_MS
In-memory TTS audio cache TTL in milliseconds.
- Default: `1800000` (30 minutes)
### TTS_MAX_RETRIES
Maximum retries for upstream TTS failures (429/5xx).
- Default: `2`
### TTS_RETRY_INITIAL_MS
Initial retry delay in milliseconds for TTS upstream requests.
- Default: `250`
### TTS_RETRY_MAX_MS
Maximum retry delay in milliseconds.
- Default: `2000`
### TTS_RETRY_BACKOFF
Exponential backoff multiplier between retries.
- Default: `2`
### TTS_ENABLE_RATE_LIMIT
Controls TTS character rate limiting in the TTS API.
- Default: `false` (TTS char limits disabled)
- Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*`
- For behavior details and examples, see [TTS Rate Limiting](../configure/tts-rate-limiting)
### TTS_DAILY_LIMIT_ANONYMOUS
Anonymous per-user daily character limit.
- Default: `50000`
### TTS_DAILY_LIMIT_AUTHENTICATED
Authenticated per-user daily character limit.
- Default: `500000`
### TTS_IP_DAILY_LIMIT_ANONYMOUS
Anonymous IP backstop daily character limit.
- Default: `100000`
### TTS_IP_DAILY_LIMIT_AUTHENTICATED
Authenticated IP backstop daily character limit.
- Default: `1000000`
## Auth and Identity
### BASE_URL
External base URL for this OpenReader instance.
- Required with `AUTH_SECRET` to enable auth
- Example: `http://localhost:3003` or `https://reader.example.com`
- Related docs: [Auth](../configure/auth)
### AUTH_SECRET
Secret key used by auth/session handling.
- Required with `BASE_URL` to enable auth
- Generate with `openssl rand -hex 32`
- Related docs: [Auth](../configure/auth)
### AUTH_TRUSTED_ORIGINS
Additional allowed origins for auth requests.
- Comma-separated list
- `BASE_URL` origin is always trusted automatically
- Related docs: [Auth](../configure/auth)
### USE_ANONYMOUS_AUTH_SESSIONS
Controls whether auth-enabled deployments can create/use anonymous sessions.
- Default: `false` (anonymous sessions disabled)
- Set `true` to allow anonymous sessions and guest-style flows
- When `false`, users must sign in or sign up with an account
- Related docs: [Auth](../configure/auth)
### GITHUB_CLIENT_ID
GitHub OAuth client ID.
- Enable only with `GITHUB_CLIENT_SECRET`
### GITHUB_CLIENT_SECRET
GitHub OAuth client secret.
- Enable only with `GITHUB_CLIENT_ID`
### DISABLE_AUTH_RATE_LIMIT
Controls Better Auth rate limiting.
- Default behavior: auth-layer rate limiting enabled
- Set to `true` to disable auth-layer rate limiting
- This does not affect TTS character rate limiting
- Related docs: [Auth](../configure/auth)
## Database and Object Blob Storage
### POSTGRES_URL
Switches metadata/auth storage from SQLite to Postgres.
- Unset: SQLite at `docstore/sqlite3.db`
- Set: Postgres mode
- Related docs: [Database](../configure/database)
### USE_EMBEDDED_WEED_MINI
Controls embedded SeaweedFS startup.
- Default behavior: treated as enabled when unset
- Set `false` to rely on external S3-compatible storage
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### WEED_MINI_DIR
Data directory for embedded SeaweedFS (`weed mini`).
- Default: `docstore/seaweedfs`
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### WEED_MINI_WAIT_SEC
Maximum seconds to wait for embedded SeaweedFS startup.
- Default: `20`
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3_ACCESS_KEY_ID
Access key for S3-compatible storage.
- Auto-generated in embedded mode if unset
- Set explicitly for stable credentials or external providers
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3_SECRET_ACCESS_KEY
Secret key for S3-compatible storage.
- Auto-generated in embedded mode if unset
- Set explicitly for stable credentials or external providers
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3_BUCKET
Bucket name used for document blobs.
- Default in embedded mode: `openreader-documents`
- Required for external S3-compatible storage
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3_REGION
Region used by the S3 client.
- Default in embedded mode: `us-east-1`
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3_ENDPOINT
Endpoint URL for S3-compatible storage.
- In embedded mode, defaults to `http://<BASE_URL host>:8333` (or detected host)
- For AWS S3, usually leave unset
- For MinIO/SeaweedFS/R2/B2-style APIs, typically set explicitly
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3_FORCE_PATH_STYLE
Path-style S3 addressing toggle.
- Default in embedded mode: `true`
- Set according to provider requirements
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
### S3_PREFIX
Prefix prepended to stored object keys.
- Default: `openreader`
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
## Migration Controls
### RUN_DRIZZLE_MIGRATIONS
Controls startup migration execution in shared entrypoint.
- Default: `true`
- Set `false` to skip automatic startup Drizzle schema migrations
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database)
### RUN_FS_MIGRATIONS
Controls startup filesystem-to-object-store migration execution in shared entrypoint.
- Default: `true`
- Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations
- Set `false` to skip automatic storage migration pass
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database), [Object / Blob Storage](../configure/object-blob-storage)
## Library Import
### IMPORT_LIBRARY_DIR
Single directory root for server library import.
- Used when `IMPORT_LIBRARY_DIRS` is unset
- Default fallback root: `docstore/library`
- Related docs: [Server Library Import](../configure/server-library-import)
### IMPORT_LIBRARY_DIRS
Multiple library roots for server library import.
- Separator: comma, colon, or semicolon
- Takes precedence over `IMPORT_LIBRARY_DIR`
- Related docs: [Server Library Import](../configure/server-library-import)
## Audio Tooling and Alignment
### WHISPER_CPP_BIN
Absolute path to compiled `whisper.cpp` binary for word-level timestamps.
- Example: `/whisper.cpp/build/bin/whisper-cli`
- Required only for optional word-by-word highlighting
### FFMPEG_BIN
Absolute path or executable name for the ffmpeg binary used by audiobook/processing routes.
- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static`
- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg`
## Client Runtime and Feature Flags
### NEXT_PUBLIC_ENABLE_DOCX_CONVERSION
Controls whether the experimental DOCX-to-PDF conversion and upload feature is enabled.
- Default: `true` (enabled)
- Set `false` to hide DOCX support in the upload UI
### NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS
Controls whether the "Delete all user docs" and other bulk-delete buttons are shown in Settings.
- Default: `true` (enabled)
- Set `false` to hide destructive actions (recommended for production)
### NEXT_PUBLIC_DEFAULT_TTS_PROVIDER
Sets the default TTS provider for new users.
- Default: `custom-openai`
- Example values: `deepinfra`, `openai`, `custom-openai`
### NEXT_PUBLIC_DEFAULT_TTS_MODEL
Sets the default TTS model for new users.
- Default: `kokoro`
- Example values: `hexgrad/Kokoro-82M`, `tts-1`
### NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS
Controls whether the DeepInfra model list shows all models or just the free tier when no API key is set.
- Default: `true` (show all)
- Set `false` to restrict to free tier models when no API key is provided
### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT
Controls whether audiobook export UI/actions are shown in the client.
- Default behavior: enabled unless explicitly set to `false`
- Applies in both development and production
- Affects export entry points in PDF/EPUB pages and document settings UI
### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT
Controls word-by-word highlighting UI and timestamp-alignment behavior.
- Default behavior: enabled unless explicitly set to `false`
- Applies in both development and production
- Requires working timestamp generation (for example `WHISPER_CPP_BIN`)
- Affects:
- Word-highlight toggles in document settings
- Alignment requests during TTS playback

View file

@ -0,0 +1,44 @@
---
title: Stack
---
## Framework
- [Next.js](https://nextjs.org/) 15 (App Router)
- [React](https://react.dev/) 19
- [TypeScript](https://www.typescriptlang.org/)
## Containerization and runtime
- [Docker](https://www.docker.com/) (linux/amd64 and linux/arm64)
- Shared entrypoint that runs DB migrations by default and can bootstrap embedded SeaweedFS before app startup
## Next.js client
- UI: [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin)
- Interactions: `react-dnd`, `react-dropzone`
- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
- Document rendering:
- PDF: [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/)
- EPUB: [react-reader](https://github.com/gerhardsletten/react-reader), [epubjs](https://github.com/futurepress/epub.js/)
- Markdown/Text: [react-markdown](https://github.com/remarkjs/react-markdown), [remark-gfm](https://github.com/remarkjs/remark-gfm)
- Text preprocessing/matching: [compromise](https://github.com/spencermountain/compromise), [cmpstr](https://github.com/remsky/cmpstr)
## Next.js server
- APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying
- State sync: request-based today (not realtime push updates)
- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters
- Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`)
- App tables are manually maintained in Drizzle schema files
- Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle
- Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3
- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word timestamps
## Tooling and testing
- ESLint
- TypeScript
- [Playwright](https://playwright.dev/) end-to-end tests
- Drizzle migration/generation scripts

View file

@ -0,0 +1,123 @@
import { themes as prismThemes } from 'prism-react-renderer';
import type { Config } from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
const config: Config = {
title: 'OpenReader Docs',
tagline: 'Docs for OpenReader',
favicon: 'favicon.ico',
future: {
v4: true,
},
url: 'https://docs.openreader.richardr.dev',
baseUrl: '/',
organizationName: 'richardr1126',
projectName: 'OpenReader',
onBrokenLinks: 'throw',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'throw',
},
},
i18n: {
defaultLocale: 'en',
locales: ['en'],
},
presets: [
[
'classic',
{
docs: {
routeBasePath: '/',
sidebarPath: './sidebars.ts',
editUrl: 'https://github.com/richardr1126/openreader/tree/main/docs-site/',
// lastVersion: 'current',
// versions: {
// current: {
// label: 'Current',
// },
// },
},
blog: false,
theme: {
customCss: './custom.css',
},
} satisfies Preset.Options,
],
],
plugins: [
[
'@easyops-cn/docusaurus-search-local',
{
indexDocs: true,
indexBlog: false,
docsRouteBasePath: '/',
language: ['en'],
hashed: true,
explicitSearchResultPath: true,
highlightSearchTermsOnTargetPage: true,
},
],
],
themeConfig: {
colorMode: {
defaultMode: 'light',
disableSwitch: false,
respectPrefersColorScheme: true,
},
navbar: {
title: 'OpenReader',
items: [
{
type: 'docSidebar',
sidebarId: 'tutorialSidebar',
position: 'left',
label: 'Docs',
},
{
type: 'docsVersionDropdown',
position: 'left',
},
{
href: 'https://github.com/richardr1126/openreader',
label: 'GitHub',
position: 'right',
},
],
},
footer: {
links: [
{
title: 'Community',
items: [
{ label: 'Support', to: '/about/support-and-contributing' },
{ label: 'GitHub Discussions', href: 'https://github.com/richardr1126/openreader/discussions' },
{ label: 'Issues', href: 'https://github.com/richardr1126/openreader/issues' },
],
},
{
title: 'Project',
items: [
{ label: 'GitHub', href: 'https://github.com/richardr1126/openreader' },
{ label: 'Releases', href: 'https://github.com/richardr1126/openreader/releases' },
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} OpenReader contributors.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
} satisfies Preset.ThemeConfig,
};
export default config;

32
docs-site/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "openreader-docs",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "docusaurus start --host 0.0.0.0 --port 3004",
"build": "docusaurus build",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"docusaurus": "docusaurus",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy"
},
"dependencies": {
"@docusaurus/core": "^3.9.2",
"@docusaurus/preset-classic": "^3.9.2",
"@easyops-cn/docusaurus-search-local": "^0.54.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.2",
"@docusaurus/tsconfig": "^3.9.2",
"@docusaurus/types": "^3.9.2",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20"
}
}

12621
docs-site/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

67
docs-site/sidebars.ts Normal file
View file

@ -0,0 +1,67 @@
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
const sidebars: SidebarsConfig = {
tutorialSidebar: [
'intro',
{
type: 'doc',
id: 'docker-quick-start',
label: '🐳 Docker Quick Start',
},
{
type: 'category',
label: '⚙️ Configure',
items: [
{
type: 'category',
label: '🔊 TTS Providers',
link: {
type: 'doc',
id: 'configure/tts-providers',
},
items: [
'configure/tts-provider-guides/kokoro-fastapi',
'configure/tts-provider-guides/orpheus-fastapi',
'configure/tts-provider-guides/deepinfra',
'configure/tts-provider-guides/openai',
'configure/tts-provider-guides/custom-openai',
],
},
{
type: 'doc',
id: 'configure/auth',
label: '🔐 Auth',
},
{
type: 'doc',
id: 'configure/server-library-import',
label: '📥 Server Library Import',
},
'configure/tts-rate-limiting',
'configure/database',
'configure/object-blob-storage',
'configure/migrations',
],
},
{
type: 'category',
label: '🚀 Deploy',
items: ['deploy/local-development', 'deploy/vercel-deployment'],
},
{
type: 'category',
label: 'Reference',
items: [
'reference/environment-variables',
'reference/stack',
],
},
{
type: 'category',
label: 'About',
items: ['about/support-and-contributing', 'about/acknowledgements', 'about/license'],
},
],
};
export default sidebars;

1
docs-site/static/CNAME Normal file
View file

@ -0,0 +1 @@
docs.openreader.richardr.dev

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

6
docs-site/tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
}
}

19
drizzle.config.pg.ts Normal file
View file

@ -0,0 +1,19 @@
import type { Config } from 'drizzle-kit';
import * as dotenv from 'dotenv';
dotenv.config();
let url = process.env.POSTGRES_URL;
if (!url) {
console.warn('[drizzle.config.pg.ts] POSTGRES_URL is not set; using a placeholder URL.');
url = 'postgresql://placeholder:placeholder@localhost:5432/placeholder';
}
export default {
schema: ['./src/db/schema_postgres.ts', './src/db/schema_auth_postgres.ts'],
out: './drizzle/postgres',
dialect: 'postgresql',
dbCredentials: {
url,
},
} satisfies Config;

13
drizzle.config.sqlite.ts Normal file
View file

@ -0,0 +1,13 @@
import type { Config } from 'drizzle-kit';
import * as dotenv from 'dotenv';
dotenv.config();
export default {
schema: ['./src/db/schema_sqlite.ts', './src/db/schema_auth_sqlite.ts'],
out: './drizzle/sqlite',
dialect: 'sqlite',
dbCredentials: {
url: 'docstore/sqlite3.db',
},
} satisfies Config;

View file

@ -0,0 +1,151 @@
CREATE TABLE "audiobook_chapters" (
"id" text NOT NULL,
"book_id" text NOT NULL,
"user_id" text NOT NULL,
"chapter_index" integer NOT NULL,
"title" text NOT NULL,
"duration" real DEFAULT 0,
"file_path" text NOT NULL,
"format" text NOT NULL,
CONSTRAINT "audiobook_chapters_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
CREATE TABLE "audiobooks" (
"id" text NOT NULL,
"user_id" text NOT NULL,
"title" text NOT NULL,
"author" text,
"description" text,
"cover_path" text,
"duration" real DEFAULT 0,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
CREATE TABLE "document_previews" (
"document_id" text NOT NULL,
"namespace" text DEFAULT '' NOT NULL,
"variant" text DEFAULT 'card-240-jpeg' NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"source_last_modified_ms" bigint NOT NULL,
"object_key" text NOT NULL,
"content_type" text DEFAULT 'image/jpeg' NOT NULL,
"width" integer DEFAULT 240 NOT NULL,
"height" integer,
"byte_size" bigint,
"etag" text,
"lease_owner" text,
"lease_until_ms" bigint DEFAULT 0 NOT NULL,
"attempt_count" integer DEFAULT 0 NOT NULL,
"last_error" text,
"created_at_ms" bigint DEFAULT 0 NOT NULL,
"updated_at_ms" bigint DEFAULT 0 NOT NULL,
CONSTRAINT "document_previews_document_id_namespace_variant_pk" PRIMARY KEY("document_id","namespace","variant")
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" text NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"size" bigint NOT NULL,
"last_modified" bigint NOT NULL,
"file_path" text NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "documents_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
CREATE TABLE "user_document_progress" (
"user_id" text NOT NULL,
"document_id" text NOT NULL,
"reader_type" text NOT NULL,
"location" text NOT NULL,
"progress" real,
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id")
);
--> statement-breakpoint
CREATE TABLE "user_preferences" (
"user_id" text PRIMARY KEY NOT NULL,
"data_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint
);
--> statement-breakpoint
CREATE TABLE "user_tts_chars" (
"user_id" text NOT NULL,
"date" date NOT NULL,
"char_count" bigint DEFAULT 0,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "user_tts_chars_user_id_date_pk" PRIMARY KEY("user_id","date")
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"is_anonymous" boolean DEFAULT false,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk" FOREIGN KEY ("book_id","user_id") REFERENCES "public"."audiobooks"("id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audiobooks" ADD CONSTRAINT "audiobooks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_document_progress" ADD CONSTRAINT "user_document_progress_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_document_previews_status_lease" ON "document_previews" USING btree ("status","lease_until_ms");--> statement-breakpoint
CREATE INDEX "idx_documents_user_id" ON "documents" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_documents_user_id_last_modified" ON "documents" USING btree ("user_id","last_modified");--> statement-breakpoint
CREATE INDEX "idx_user_document_progress_user_id_updated_at" ON "user_document_progress" USING btree ("user_id","updated_at");--> statement-breakpoint
CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date");--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1771386480954,
"tag": "0000_black_lucky_pierre",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,82 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import * as dotenv from 'dotenv';
function loadEnvFiles() {
// Approximate Next.js behavior enough for server-side scripts.
// Load .env first, then .env.local (local overrides).
const cwd = process.cwd();
const envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
if (fs.existsSync(envLocalPath)) {
dotenv.config({ path: envLocalPath, override: true });
}
}
loadEnvFiles();
// Ensure AUTH_SECRET and BASE_URL are set so Better Auth CLI can evaluate the config.
const env = { ...process.env };
if (!env.AUTH_SECRET) env.AUTH_SECRET = 'generate-placeholder-secret-value-32chars!!';
if (!env.BASE_URL) env.BASE_URL = 'http://localhost:3003';
const extraArgs = process.argv.slice(2);
// ---------------------------------------------------------------------------
// Step 1: Generate Better Auth schema files (one per dialect).
//
// The Better Auth CLI reads our auth config and produces a Drizzle schema file
// matching the adapter in use. We run it twice:
// - Without POSTGRES_URL → SQLite schema
// - With POSTGRES_URL → Postgres schema
//
// These files are checked in and should NOT be hand-edited.
// ---------------------------------------------------------------------------
console.log('\n--- Generating Better Auth schema (SQLite) ---');
{
const envSqlite = { ...env };
delete envSqlite.POSTGRES_URL; // force SQLite adapter
const result = spawnSync('npx', [
'@better-auth/cli', 'generate',
'--output', 'src/db/schema_auth_sqlite.ts',
'--yes',
], { stdio: 'inherit', env: envSqlite });
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
}
console.log('\n--- Generating Better Auth schema (Postgres) ---');
{
const envPg = { ...env };
// A placeholder URL is enough the CLI doesn't connect, it just reads the config.
if (!envPg.POSTGRES_URL) {
envPg.POSTGRES_URL = 'postgresql://placeholder:placeholder@localhost:5432/placeholder';
}
const result = spawnSync('npx', [
'@better-auth/cli', 'generate',
'--output', 'src/db/schema_auth_postgres.ts',
'--yes',
], { stdio: 'inherit', env: envPg });
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
}
// ---------------------------------------------------------------------------
// Step 2: Generate Drizzle migrations for both dialects.
// ---------------------------------------------------------------------------
for (const configFile of ['drizzle.config.sqlite.ts', 'drizzle.config.pg.ts']) {
console.log(`\n--- Drizzle generate (${configFile}) ---`);
const result = spawnSync('drizzle-kit', ['generate', '--config', configFile, ...extraArgs], {
stdio: 'inherit',
env,
});
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);

View file

@ -0,0 +1,56 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import * as dotenv from 'dotenv';
function loadEnvFiles() {
// Approximate Next.js behavior enough for server-side scripts.
// Load .env first, then .env.local (local overrides).
const cwd = process.cwd();
const envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
if (fs.existsSync(envLocalPath)) {
dotenv.config({ path: envLocalPath, override: true });
}
}
loadEnvFiles();
const extraArgs = process.argv.slice(2);
const hasConfigArg = extraArgs.includes('--config');
const configFile = process.env.POSTGRES_URL ? 'drizzle.config.pg.ts' : 'drizzle.config.sqlite.ts';
const configArgs = hasConfigArg ? [] : ['--config', configFile];
// Ensure the docstore directory exists for SQLite migrations.
// drizzle-kit opens the database file directly and will fail if the parent
// directory is missing (e.g. in a fresh CI checkout).
if (!process.env.POSTGRES_URL) {
const dbDir = path.join(process.cwd(), 'docstore');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
}
function resolveDrizzleKitBin() {
const binName = process.platform === 'win32' ? 'drizzle-kit.cmd' : 'drizzle-kit';
const localBin = path.join(process.cwd(), 'node_modules', '.bin', binName);
if (fs.existsSync(localBin)) return localBin;
return 'drizzle-kit';
}
const result = spawnSync(resolveDrizzleKitBin(), ['migrate', ...configArgs, ...extraArgs], {
stdio: 'inherit',
env: process.env,
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -0,0 +1,151 @@
CREATE TABLE `audiobook_chapters` (
`id` text NOT NULL,
`book_id` text NOT NULL,
`user_id` text NOT NULL,
`chapter_index` integer NOT NULL,
`title` text NOT NULL,
`duration` real DEFAULT 0,
`file_path` text NOT NULL,
`format` text NOT NULL,
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`book_id`,`user_id`) REFERENCES `audiobooks`(`id`,`user_id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `audiobooks` (
`id` text NOT NULL,
`user_id` text NOT NULL,
`title` text NOT NULL,
`author` text,
`description` text,
`cover_path` text,
`duration` real DEFAULT 0,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `document_previews` (
`document_id` text NOT NULL,
`namespace` text DEFAULT '' NOT NULL,
`variant` text DEFAULT 'card-240-jpeg' NOT NULL,
`status` text DEFAULT 'queued' NOT NULL,
`source_last_modified_ms` integer NOT NULL,
`object_key` text NOT NULL,
`content_type` text DEFAULT 'image/jpeg' NOT NULL,
`width` integer DEFAULT 240 NOT NULL,
`height` integer,
`byte_size` integer,
`etag` text,
`lease_owner` text,
`lease_until_ms` integer DEFAULT 0 NOT NULL,
`attempt_count` integer DEFAULT 0 NOT NULL,
`last_error` text,
`created_at_ms` integer DEFAULT 0 NOT NULL,
`updated_at_ms` integer DEFAULT 0 NOT NULL,
PRIMARY KEY(`document_id`, `namespace`, `variant`)
);
--> statement-breakpoint
CREATE INDEX `idx_document_previews_status_lease` ON `document_previews` (`status`,`lease_until_ms`);--> statement-breakpoint
CREATE TABLE `documents` (
`id` text NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer NOT NULL,
`last_modified` integer NOT NULL,
`file_path` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_documents_user_id` ON `documents` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_documents_user_id_last_modified` ON `documents` (`user_id`,`last_modified`);--> statement-breakpoint
CREATE TABLE `user_document_progress` (
`user_id` text NOT NULL,
`document_id` text NOT NULL,
`reader_type` text NOT NULL,
`location` text NOT NULL,
`progress` real,
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`user_id`, `document_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`);--> statement-breakpoint
CREATE TABLE `user_preferences` (
`user_id` text PRIMARY KEY NOT NULL,
`data_json` text DEFAULT '{}' NOT NULL,
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `user_tts_chars` (
`user_id` text NOT NULL,
`date` text NOT NULL,
`char_count` integer DEFAULT 0,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`user_id`, `date`)
);
--> statement-breakpoint
CREATE INDEX `idx_user_tts_chars_date` ON `user_tts_chars` (`date`);--> statement-breakpoint
CREATE TABLE `account` (
`id` text PRIMARY KEY NOT NULL,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`token` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`is_anonymous` integer DEFAULT false
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1771386480690,
"tag": "0000_familiar_caretaker",
"breakpoints": true
}
]
}

View file

@ -1,30 +0,0 @@
services:
kokoro-tts:
container_name: kokoro-tts
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
ports:
- "8880:8880"
environment:
# ONNX Optimization Settings for vectorized operations
- ONNX_NUM_THREADS=8 # Maximize core usage for vectorized ops
- ONNX_INTER_OP_THREADS=4 # Higher inter-op for parallel matrix operations
- ONNX_EXECUTION_MODE=parallel
- ONNX_OPTIMIZATION_LEVEL=all
- ONNX_MEMORY_PATTERN=true
- ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo
- API_LOG_LEVEL=DEBUG
restart: unless-stopped
openreader-webui:
container_name: openreader-webui
image: ghcr.io/richardr1126/openreader-webui:latest
environment:
- API_BASE=http://host.docker.internal:8880/v1
ports:
- "3003:3003"
volumes:
- docstore:/app/docstore
restart: unless-stopped
volumes:
docstore:

View file

@ -1,11 +1,56 @@
import type { NextConfig } from "next";
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://*.huggingface.co https://huggingface.co",
},
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
];
const nextConfig: NextConfig = {
async headers() {
return [
{
// Apply security headers to all routes
source: '/(.*)',
headers: securityHeaders,
},
];
},
turbopack: {
resolveAlias: {
canvas: './empty-module.ts',
canvas: '@napi-rs/canvas',
},
},
serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"],
outputFileTracingIncludes: {
'/api/audiobook': [
'./node_modules/ffmpeg-static/ffmpeg',
],
'/api/audiobook/chapter': [
'./node_modules/ffmpeg-static/ffmpeg',
],
'/api/whisper': [
'./node_modules/ffmpeg-static/ffmpeg',
],
'/api/documents/blob/preview/ensure': [
'./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs',
],
'/api/documents/blob/preview/presign': [
'./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs',
],
'/api/documents/blob/preview/fallback': [
'./node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs',
],
},
};
export default nextConfig;

View file

@ -1,35 +1,59 @@
{
"name": "openreader-webui",
"version": "v1.2.1",
"name": "openreader",
"version": "v1.3.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 3003",
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
"dev:raw": "next dev --turbopack -p 3003",
"build": "next build",
"start": "next start -p 3003",
"start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw",
"start:raw": "next start -p 3003",
"lint": "next lint",
"test": "playwright test"
"test": "playwright test",
"migrate": "node drizzle/scripts/migrate.mjs",
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",
"generate": "node drizzle/scripts/generate.mjs",
"docs:init": "pnpm --dir docs-site install",
"docs:dev": "pnpm --dir docs-site start",
"docs:build": "pnpm --dir docs-site build",
"docs:serve": "pnpm --dir docs-site serve",
"docs:version": "pnpm --dir docs-site docusaurus docs:version",
"docs:clear": "pnpm --dir docs-site clear"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.987.0",
"@aws-sdk/s3-request-presigner": "^3.987.0",
"@headlessui/react": "^2.2.9",
"@types/howler": "^2.2.12",
"@types/uuid": "^10.0.0",
"@napi-rs/canvas": "^0.1.91",
"@types/archiver": "^7.0.0",
"@vercel/analytics": "^1.6.1",
"cmpstr": "^3.2.0",
"archiver": "^7.0.1",
"better-auth": "^1.4.18",
"better-sqlite3": "^12.6.2",
"cmpstr": "^3.2.1",
"compromise": "^14.14.5",
"core-js": "^3.48.0",
"dexie": "^4.2.1",
"dexie": "^4.3.0",
"dexie-react-hooks": "^4.2.0",
"dotenv": "^17.2.4",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "^0.45.1",
"epubjs": "^0.3.93",
"fast-xml-parser": "^5.3.5",
"ffmpeg-static": "^5.3.0",
"howler": "^2.2.4",
"lru-cache": "^11.2.4",
"next": "^15.5.9",
"openai": "^6.16.0",
"jszip": "^3.10.1",
"lru-cache": "^11.2.6",
"next": "^15.5.12",
"openai": "^6.21.0",
"pdfjs-dist": "4.8.69",
"react": "^19.2.3",
"pg": "^8.18.0",
"react": "^19.2.4",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.2.3",
"react-dropzone": "^14.3.8",
"react-dom": "^19.2.4",
"react-dropzone": "^14.4.1",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0",
"react-pdf": "^9.2.1",
@ -39,22 +63,31 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@playwright/test": "^1.58.0",
"@playwright/test": "^1.58.2",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.19.30",
"@types/react": "^19.2.9",
"@types/better-sqlite3": "^7.6.13",
"@types/howler": "^2.2.12",
"@types/node": "^20.19.33",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
"eslint": "^9.39.2",
"eslint-config-next": "^15.5.9",
"eslint-config-next": "^15.5.12",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"@napi-rs/canvas",
"better-sqlite3",
"ffmpeg-static"
],
"overrides": {
"lodash": "^4.17.23",
"@xmldom/xmldom": "^0.9.8",
"@types/localforage": "npm:empty-module@0.0.2"
}
}
}
}

View file

@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import 'dotenv/config';
/**
* See https://playwright.dev/docs/test-configuration.
@ -7,6 +8,7 @@ export default defineConfig({
testDir: './tests',
timeout: 30 * 1000,
outputDir: './tests/results',
globalTeardown: './tests/global-teardown.ts',
// fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
@ -26,7 +28,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run build && npm run start',
// Disable auth rate limiting for tests to support parallel workers creating sessions
command: `npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start`,
url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,

File diff suppressed because it is too large Load diff

View file

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

Before

Width:  |  Height:  |  Size: 391 B

View file

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

Before

Width:  |  Height:  |  Size: 1 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

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

Before

Width:  |  Height:  |  Size: 128 B

View file

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

Before

Width:  |  Height:  |  Size: 385 B

930
scripts/migrate-fs-v2.mjs Normal file
View file

@ -0,0 +1,930 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import * as dotenv from 'dotenv';
import {
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
const require = createRequire(import.meta.url);
const { Pool } = require('pg');
const BetterSqlite3 = require('better-sqlite3');
const ffmpegStatic = require('ffmpeg-static');
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1');
const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1');
const UNCLAIMED_USER_ID = 'unclaimed';
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
const SAFE_AUDIOBOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
function loadEnvFiles() {
const envPath = path.join(process.cwd(), '.env');
const envLocalPath = path.join(process.cwd(), '.env.local');
if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
if (fs.existsSync(envLocalPath)) dotenv.config({ path: envLocalPath, override: true });
}
function parseBool(value, fallback = false) {
if (value == null || String(value).trim() === '') return fallback;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function parseArgs(argv) {
const args = {
dryRun: false,
deleteLocal: false,
namespace: null,
};
for (let i = 0; i < argv.length; i += 1) {
const raw = argv[i];
if (raw === '--dry-run' && argv[i + 1]) {
args.dryRun = parseBool(argv[i + 1], true);
i += 1;
continue;
}
if (raw.startsWith('--dry-run=')) {
args.dryRun = parseBool(raw.slice('--dry-run='.length), true);
continue;
}
if (raw === '--delete-local' && argv[i + 1]) {
args.deleteLocal = parseBool(argv[i + 1], true);
i += 1;
continue;
}
if (raw.startsWith('--delete-local=')) {
args.deleteLocal = parseBool(raw.slice('--delete-local='.length), true);
continue;
}
if (raw === '--namespace' && argv[i + 1]) {
args.namespace = sanitizeNamespace(argv[i + 1]);
i += 1;
continue;
}
if (raw.startsWith('--namespace=')) {
args.namespace = sanitizeNamespace(raw.slice('--namespace='.length));
}
}
return args;
}
function sanitizeNamespace(namespace) {
if (!namespace) return null;
const safe = String(namespace).trim();
if (!safe || !SAFE_NAMESPACE_REGEX.test(safe)) return null;
return safe;
}
function applyNamespacePath(baseDir, namespace) {
if (!namespace) return baseDir;
const resolved = path.resolve(baseDir, namespace);
if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir;
return resolved;
}
function getUnclaimedUserIdForNamespace(namespace) {
if (!namespace) return UNCLAIMED_USER_ID;
return `${UNCLAIMED_USER_ID}::${namespace}`;
}
function normalizePrefix(prefix) {
const base = String(prefix || 'openreader').trim();
if (!base) return 'openreader';
return base.replace(/^\/+|\/+$/g, '');
}
function parseS3ConfigFromEnv() {
const bucket = process.env.S3_BUCKET?.trim();
const region = process.env.S3_REGION?.trim();
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim();
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim();
const endpoint = process.env.S3_ENDPOINT?.trim();
if (!bucket || !region || !accessKeyId || !secretAccessKey) {
throw new Error('S3 is not configured. Required env vars: S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.');
}
return {
bucket,
region,
endpoint: endpoint || undefined,
accessKeyId,
secretAccessKey,
forcePathStyle: parseBool(process.env.S3_FORCE_PATH_STYLE, false),
prefix: normalizePrefix(process.env.S3_PREFIX),
};
}
function createS3Client(config) {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
}
function isPreconditionFailed(error) {
if (!error || typeof error !== 'object') return false;
return error?.$metadata?.httpStatusCode === 412 || error?.name === 'PreconditionFailed';
}
function documentKey(s3Config, id, namespace) {
if (!DOCUMENT_ID_REGEX.test(id)) {
throw new Error(`Invalid document id: ${id}`);
}
const nsSegment = namespace ? `ns/${namespace}/` : '';
return `${s3Config.prefix}/documents_v1/${nsSegment}${id}`;
}
function audiobookKey(s3Config, bookId, userId, fileName, namespace) {
if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) throw new Error(`Invalid audiobook id: ${bookId}`);
if (!userId) throw new Error('Missing user id for audiobook key');
if (!fileName || fileName.includes('/') || fileName.includes('\\')) throw new Error(`Invalid audiobook file name: ${fileName}`);
const nsSegment = namespace ? `ns/${namespace}/` : '';
return `${s3Config.prefix}/audiobooks_v1/${nsSegment}users/${encodeURIComponent(String(userId))}/${bookId}-audiobook/${fileName}`;
}
async function putObjectIfMissing(s3Client, s3Config, key, body, contentType) {
await s3Client.send(new PutObjectCommand({
Bucket: s3Config.bucket,
Key: key,
Body: body,
ContentType: contentType,
IfNoneMatch: '*',
}));
}
function isLegacyDocumentMetadata(value) {
if (!value || typeof value !== 'object') return false;
const v = value;
return typeof v.id === 'string'
&& typeof v.name === 'string'
&& typeof v.size === 'number'
&& typeof v.lastModified === 'number'
&& typeof v.type === 'string';
}
function isValidDocumentId(id) {
return DOCUMENT_ID_REGEX.test(id);
}
function extractIdFromFileName(fileName) {
const match = /^([a-f0-9]{64})__/i.exec(fileName);
if (!match) return null;
const id = match[1].toLowerCase();
return isValidDocumentId(id) ? id : null;
}
function decodeNameFromFileName(fileName, id) {
const prefix = `${id}__`;
if (!fileName.startsWith(prefix)) return fileName;
const encoded = fileName.slice(prefix.length);
try {
return decodeURIComponent(encoded);
} catch {
return fileName;
}
}
function sniffBinaryDocumentType(bytes) {
if (bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-') {
return 'pdf';
}
const isZip = bytes.length >= 4
&& bytes[0] === 0x50
&& bytes[1] === 0x4b
&& (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07)
&& (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08);
if (!isZip) return null;
const probe = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString('latin1');
if (probe.includes('application/epub+zip') || probe.includes('META-INF/container.xml')) return 'epub';
if (probe.includes('[Content_Types].xml') && probe.includes('word/')) return 'docx';
return null;
}
function toDocumentTypeFromName(name) {
const ext = path.extname(name).toLowerCase();
if (ext === '.pdf') return 'pdf';
if (ext === '.epub') return 'epub';
if (ext === '.docx') return 'docx';
return 'html';
}
function safeDocumentName(rawName, fallback) {
const baseName = path.basename(rawName || fallback);
return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback;
}
function normalizeNameForType(name, id, type) {
if (type === 'html') return name;
const expectedExt = type === 'pdf' ? '.pdf' : type === 'epub' ? '.epub' : '.docx';
if (name.toLowerCase().endsWith(expectedExt)) return name;
const base = name.replace(/\.bin$/i, '');
return `${base || id}${expectedExt}`;
}
function contentTypeForName(name) {
const ext = path.extname(name).toLowerCase();
if (ext === '.pdf') return 'application/pdf';
if (ext === '.epub') return 'application/epub+zip';
if (ext === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (ext === '.md' || ext === '.mdown' || ext === '.markdown') return 'text/markdown; charset=utf-8';
if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8';
return 'text/plain; charset=utf-8';
}
function contentTypeForDocument(type, name) {
if (type === 'pdf') return 'application/pdf';
if (type === 'epub') return 'application/epub+zip';
if (type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
return contentTypeForName(name);
}
function sanitizeTagValue(value) {
return String(value || '').replaceAll('\u0000', '').replaceAll(/\r?\n/g, ' ').trim();
}
function sanitizeFileStem(value) {
return sanitizeTagValue(value)
.replaceAll(/[\\/]/g, ' ')
.replaceAll(/[<>:"|?*\u0000]/g, '')
.replaceAll(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
function encodeChapterFileName(index, title, format) {
const oneBased = String(index + 1).padStart(4, '0');
const safeTitle = sanitizeFileStem(title) || `Chapter ${index + 1}`;
return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`;
}
function decodeChapterFileName(fileName) {
const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName);
if (!match) return null;
const oneBased = Number(match[1]);
if (!Number.isInteger(oneBased) || oneBased <= 0) return null;
const format = match[3].toLowerCase();
try {
const title = decodeURIComponent(match[2]);
return { index: oneBased - 1, title: title || `Chapter ${oneBased}`, format };
} catch {
return { index: oneBased - 1, title: match[2], format };
}
}
function decodeChapterTitleTag(tag) {
const raw = sanitizeTagValue(tag);
if (!raw) return null;
const match = /^(\d{1,6})\s*[-.:]\s*(.+)$/.exec(raw);
if (!match) return null;
const oneBased = Number(match[1]);
if (!Number.isFinite(oneBased) || !Number.isInteger(oneBased) || oneBased <= 0) return null;
return { index: oneBased - 1, title: match[2].trim() || `Chapter ${oneBased}` };
}
function chooseBinary(preferred, bundled, envVarName, packageName) {
const envValue = preferred ? String(preferred).trim() : '';
if (envValue) {
if ((envValue.includes('/') || envValue.includes('\\')) && !fs.existsSync(envValue)) {
throw new Error(`${envVarName} points to a missing binary: ${envValue}`);
}
return envValue;
}
const bundledValue = bundled ? String(bundled).trim() : '';
if (!bundledValue) {
throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`);
}
if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !fs.existsSync(bundledValue)) {
throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`);
}
return bundledValue;
}
function getFFmpegPath() {
return chooseBinary(process.env.FFMPEG_BIN || null, ffmpegStatic || null, 'FFMPEG_BIN', 'ffmpeg-static');
}
async function ffprobeTitleTag(filePath) {
return new Promise((resolve) => {
const child = spawn(getFFmpegPath(), [
'-i', filePath,
'-f', 'ffmetadata',
'-',
]);
let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('error', () => resolve(null));
child.on('close', () => {
const line = stdout.split(/\r?\n/).find((l) => l.startsWith('title='));
if (!line) {
resolve(null);
return;
}
const raw = line.slice('title='.length).trim();
resolve(raw.length > 0 ? raw : null);
});
});
}
function isPersistedAudiobookFileName(fileName) {
if (fileName === 'audiobook.meta.json') return true;
if (fileName === 'complete.mp3' || fileName === 'complete.m4b') return true;
if (/^complete\.(mp3|m4b)\.manifest\.json$/i.test(fileName)) return true;
return decodeChapterFileName(fileName) !== null;
}
function isTransientAudiobookFileName(fileName) {
if (/^-?\d+-input\.mp3$/i.test(fileName)) return true;
if (/\.tmp\./i.test(fileName)) return true;
return false;
}
function contentTypeForAudiobookFileName(fileName) {
if (fileName.endsWith('.mp3')) return 'audio/mpeg';
if (fileName.endsWith('.m4b')) return 'audio/mp4';
if (fileName.endsWith('.json')) return 'application/json; charset=utf-8';
return 'application/octet-stream';
}
async function collectDocumentCandidates(docsDir, docstoreDir) {
const byId = new Map();
let filesScanned = 0;
let skippedInvalid = 0;
if (fs.existsSync(docsDir)) {
const entries = await fsp.readdir(docsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
filesScanned += 1;
const fullPath = path.join(docsDir, entry.name);
const bytes = await fsp.readFile(fullPath);
const st = await fsp.stat(fullPath);
const extractedId = extractIdFromFileName(entry.name);
const id = extractedId ?? createHash('sha256').update(bytes).digest('hex');
if (!isValidDocumentId(id)) {
skippedInvalid += 1;
continue;
}
const inferredName = decodeNameFromFileName(entry.name, id);
const inferredType = toDocumentTypeFromName(inferredName);
const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType;
const normalizedName = normalizeNameForType(inferredName, id, type);
const contentType = contentTypeForDocument(type, normalizedName);
const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now();
if (!byId.has(id)) {
byId.set(id, {
id,
name: normalizedName,
type,
size: bytes.length,
lastModified,
contentType,
bytes,
localPaths: new Set([fullPath]),
});
} else {
byId.get(id).localPaths.add(fullPath);
}
}
}
if (fs.existsSync(docstoreDir)) {
const entries = await fsp.readdir(docstoreDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const metadataPath = path.join(docstoreDir, entry.name);
let parsed;
try {
parsed = JSON.parse(await fsp.readFile(metadataPath, 'utf8'));
} catch {
continue;
}
if (!isLegacyDocumentMetadata(parsed)) continue;
const contentPath = path.join(docstoreDir, `${parsed.id}.${parsed.type}`);
if (!fs.existsSync(contentPath)) continue;
filesScanned += 1;
const bytes = await fsp.readFile(contentPath);
const st = await fsp.stat(contentPath);
const id = createHash('sha256').update(bytes).digest('hex');
if (!isValidDocumentId(id)) {
skippedInvalid += 1;
continue;
}
const fallbackName = `${id}.${parsed.type}`;
const normalizedInputName = safeDocumentName(parsed.name, fallbackName);
const inferredType = toDocumentTypeFromName(normalizedInputName);
const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType;
const normalizedName = normalizeNameForType(normalizedInputName, id, type);
const contentType = contentTypeForDocument(type, normalizedName);
const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now();
if (!byId.has(id)) {
byId.set(id, {
id,
name: normalizedName,
type,
size: bytes.length,
lastModified,
contentType,
bytes,
localPaths: new Set([contentPath, metadataPath]),
});
} else {
byId.get(id).localPaths.add(contentPath);
byId.get(id).localPaths.add(metadataPath);
}
}
}
return {
candidates: Array.from(byId.values()).map((item) => ({
...item,
localPaths: Array.from(item.localPaths),
})),
filesScanned,
skippedInvalid,
};
}
async function collectAudiobookCandidates(audiobooksDir, docstoreDir) {
const stats = {
booksScanned: 0,
filesScanned: 0,
skippedTransient: 0,
};
const sourceDirs = new Map();
if (fs.existsSync(audiobooksDir)) {
const entries = await fsp.readdir(audiobooksDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue;
const bookId = entry.name.slice(0, -'-audiobook'.length);
if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue;
sourceDirs.set(`${bookId}::${path.join(audiobooksDir, entry.name)}`, {
bookId,
dirPath: path.join(audiobooksDir, entry.name),
});
}
}
if (fs.existsSync(docstoreDir)) {
const entries = await fsp.readdir(docstoreDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue;
const bookId = entry.name.slice(0, -'-audiobook'.length);
if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue;
sourceDirs.set(`${bookId}::${path.join(docstoreDir, entry.name)}`, {
bookId,
dirPath: path.join(docstoreDir, entry.name),
});
}
}
const grouped = new Map();
for (const source of sourceDirs.values()) {
if (!grouped.has(source.bookId)) grouped.set(source.bookId, []);
grouped.get(source.bookId).push(source.dirPath);
}
const books = [];
for (const [bookId, dirPaths] of grouped.entries()) {
stats.booksScanned += 1;
const uploads = [];
const dedupedChapterByIndex = new Map();
let title = 'Unknown Title';
for (const dirPath of dirPaths) {
const entries = await fsp.readdir(dirPath, { withFileTypes: true }).catch(() => []);
const chapterMetaByIndex = new Map();
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === 'audiobook.meta.json') continue;
if (!entry.name.endsWith('.meta.json')) continue;
const metaPath = path.join(dirPath, entry.name);
try {
const raw = JSON.parse(await fsp.readFile(metaPath, 'utf8'));
const idx = Number(raw?.index ?? entry.name.replace(/\.meta\.json$/i, ''));
const chapterTitle = typeof raw?.title === 'string' ? raw.title : null;
if (Number.isInteger(idx) && idx >= 0 && chapterTitle) {
chapterMetaByIndex.set(idx, chapterTitle);
}
} catch { }
}
const unresolvedSha = [];
const usedIndices = new Set();
const chapterUploads = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const fileName = entry.name;
stats.filesScanned += 1;
if (isTransientAudiobookFileName(fileName)) {
stats.skippedTransient += 1;
continue;
}
const fullPath = path.join(dirPath, fileName);
const st = await fsp.stat(fullPath).catch(() => null);
const mtimeMs = Number(st?.mtimeMs ?? 0);
const canonical = decodeChapterFileName(fileName);
if (canonical) {
const targetFileName = encodeChapterFileName(canonical.index, canonical.title, canonical.format);
chapterUploads.push({
index: canonical.index,
title: canonical.title,
format: canonical.format,
targetFileName,
sourcePath: fullPath,
mtimeMs,
});
usedIndices.add(canonical.index);
continue;
}
if (isPersistedAudiobookFileName(fileName)) {
uploads.push({
sourcePath: fullPath,
targetFileName: fileName,
contentType: contentTypeForAudiobookFileName(fileName),
});
continue;
}
const legacy = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(fileName);
if (legacy) {
const index = Number(legacy[1]);
const format = legacy[2].toLowerCase();
const chapterTitle = chapterMetaByIndex.get(index) ?? `Chapter ${index + 1}`;
const targetFileName = encodeChapterFileName(index, chapterTitle, format);
chapterUploads.push({
index,
title: chapterTitle,
format,
targetFileName,
sourcePath: fullPath,
mtimeMs,
});
usedIndices.add(index);
continue;
}
const sha = /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(fileName);
if (sha) {
unresolvedSha.push({
sourcePath: fullPath,
format: fileName.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b',
mtimeMs,
});
continue;
}
}
unresolvedSha.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
const nextIndex = () => {
let idx = 0;
while (usedIndices.has(idx)) idx += 1;
usedIndices.add(idx);
return idx;
};
for (const item of unresolvedSha) {
let decoded = null;
const titleTag = await ffprobeTitleTag(item.sourcePath);
if (titleTag) decoded = decodeChapterTitleTag(titleTag);
const index = decoded?.index ?? nextIndex();
const chapterTitle = decoded?.title ?? `Chapter ${index + 1}`;
const targetFileName = encodeChapterFileName(index, chapterTitle, item.format);
chapterUploads.push({
index,
title: chapterTitle,
format: item.format,
targetFileName,
sourcePath: item.sourcePath,
mtimeMs: item.mtimeMs,
});
}
for (const chapter of chapterUploads) {
uploads.push({
sourcePath: chapter.sourcePath,
targetFileName: chapter.targetFileName,
contentType: contentTypeForAudiobookFileName(chapter.targetFileName),
});
const current = dedupedChapterByIndex.get(chapter.index);
if (!current || chapter.targetFileName > current.fileName || chapter.mtimeMs > current.mtimeMs) {
dedupedChapterByIndex.set(chapter.index, {
index: chapter.index,
title: chapter.title,
format: chapter.format,
fileName: chapter.targetFileName,
mtimeMs: chapter.mtimeMs,
});
}
}
}
const chapters = Array.from(dedupedChapterByIndex.values())
.sort((a, b) => a.index - b.index)
.map((chapter) => ({
index: chapter.index,
title: chapter.title,
format: chapter.format,
fileName: chapter.fileName,
}));
if (chapters.length > 0) title = chapters[0].title || title;
books.push({
id: bookId,
title,
chapters,
uploads,
sourceDirs: dirPaths,
});
}
return { books, stats };
}
function openDatabase() {
if (process.env.POSTGRES_URL) {
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
return {
mode: 'pg',
async query(sql, values = []) {
return pool.query(sql, values);
},
async close() {
await pool.end();
},
};
}
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const sqlite = new BetterSqlite3(dbPath);
return {
mode: 'sqlite',
async query(sql, values = []) {
if (/^\s*select/i.test(sql)) {
const rows = sqlite.prepare(sql).all(...values);
return { rows, rowCount: rows.length };
}
const info = sqlite.prepare(sql).run(...values);
return { rows: [], rowCount: Number(info.changes ?? 0) };
},
async close() {
sqlite.close();
},
};
}
async function migrateDatabaseRows(database, userId, docCandidates, audiobookBooks, dryRun) {
const mismatched = await database.query('SELECT COUNT(*) AS count FROM documents WHERE file_path <> id');
const rowsUpdated = Number(mismatched.rows[0]?.count ?? mismatched.rows[0]?.COUNT ?? 0);
if (!dryRun && rowsUpdated > 0) {
await database.query('UPDATE documents SET file_path = id WHERE file_path <> id');
}
// Ensure the user exists to satisfy foreign key constraints (cascade delete)
if (!dryRun) {
const now = Date.now();
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES (?, ?, ?, ?, ?, ?, ?)',
[userId, 'System User', `${userId}@local`, 0, now, now, 0]
);
} else {
await database.query(
"INSERT INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES ($1, $2, $3, $4, to_timestamp($5 / 1000.0), to_timestamp($6 / 1000.0), $7) ON CONFLICT (id) DO NOTHING",
[userId, 'System User', `${userId}@local`, false, now, now, false]
);
}
}
const existingDocs = database.mode === 'sqlite'
? await database.query('SELECT id FROM documents WHERE user_id = ?', [userId])
: await database.query('SELECT id FROM documents WHERE user_id = $1', [userId]);
const existingDocIds = new Set(existingDocs.rows.map((row) => row.id));
const toInsertDocs = docCandidates.filter((candidate) => !existingDocIds.has(candidate.id));
if (!dryRun) {
for (const candidate of toInsertDocs) {
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES (?, ?, ?, ?, ?, ?, ?)',
[candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id],
);
} else {
await database.query(
'INSERT INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING',
[candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id],
);
}
}
for (const book of audiobookBooks) {
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO audiobooks (id, user_id, title, duration) VALUES (?, ?, ?, ?)',
[book.id, userId, book.title || 'Unknown Title', 0],
);
} else {
await database.query(
'INSERT INTO audiobooks (id, user_id, title, duration) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
[book.id, userId, book.title || 'Unknown Title', 0],
);
}
for (const chapter of book.chapters) {
const chapterId = `${book.id}-${chapter.index}`;
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format],
);
} else {
await database.query(
'INSERT INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT DO NOTHING',
[chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format],
);
}
}
}
}
return {
dbRowsUpdated: rowsUpdated,
dbRowsSeeded: toInsertDocs.length,
audiobookDbBooksSeeded: audiobookBooks.length,
audiobookDbChaptersSeeded: audiobookBooks.reduce((sum, book) => sum + book.chapters.length, 0),
};
}
async function main() {
loadEnvFiles();
const { dryRun, deleteLocal, namespace } = parseArgs(process.argv.slice(2));
const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace);
const docsDir = applyNamespacePath(DOCUMENTS_V1_DIR, namespace);
const audiobooksDir = applyNamespacePath(AUDIOBOOKS_V1_DIR, namespace);
const docstoreDir = applyNamespacePath(DOCSTORE_DIR, namespace);
const s3Config = parseS3ConfigFromEnv();
const s3Client = createS3Client(s3Config);
const { candidates: documentCandidates, filesScanned, skippedInvalid } = await collectDocumentCandidates(docsDir, docstoreDir);
const { books: audiobookBooks, stats: audiobookStats } = await collectAudiobookCandidates(audiobooksDir, docstoreDir);
let uploaded = 0;
let alreadyPresent = 0;
let deletedLocal = 0;
for (const candidate of documentCandidates) {
if (!dryRun) {
try {
await putObjectIfMissing(
s3Client,
s3Config,
documentKey(s3Config, candidate.id, namespace),
candidate.bytes,
candidate.contentType,
);
uploaded += 1;
} catch (error) {
if (isPreconditionFailed(error)) {
alreadyPresent += 1;
} else {
throw error;
}
}
}
if (deleteLocal && !dryRun) {
for (const localPath of candidate.localPaths) {
const removed = await fsp.unlink(localPath).then(() => true).catch(() => false);
if (removed) deletedLocal += 1;
}
}
}
let audiobookUploaded = 0;
let audiobookAlreadyPresent = 0;
let audiobookDeletedLocal = 0;
for (const book of audiobookBooks) {
for (const upload of book.uploads) {
const bytes = await fsp.readFile(upload.sourcePath);
if (!dryRun) {
try {
await putObjectIfMissing(
s3Client,
s3Config,
audiobookKey(s3Config, book.id, unclaimedUserId, upload.targetFileName, namespace),
bytes,
upload.contentType,
);
audiobookUploaded += 1;
} catch (error) {
if (isPreconditionFailed(error)) {
audiobookAlreadyPresent += 1;
} else {
throw error;
}
}
}
if (deleteLocal && !dryRun) {
const removed = await fsp.unlink(upload.sourcePath).then(() => true).catch(() => false);
if (removed) audiobookDeletedLocal += 1;
}
}
if (deleteLocal && !dryRun) {
for (const dirPath of book.sourceDirs) {
await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => { });
}
}
}
const database = openDatabase();
try {
const dbStats = await migrateDatabaseRows(
database,
unclaimedUserId,
documentCandidates,
audiobookBooks,
dryRun,
);
const result = {
success: true,
dryRun,
deleteLocal,
docsDir,
audiobooksDir,
namespace,
filesScanned,
uploaded,
alreadyPresent,
skippedInvalid,
deletedLocal,
dbRowsUpdated: dbStats.dbRowsUpdated,
dbRowsSeeded: dbStats.dbRowsSeeded,
audiobookBooksScanned: audiobookStats.booksScanned,
audiobookFilesScanned: audiobookStats.filesScanned,
audiobookUploaded,
audiobookAlreadyPresent,
audiobookDeletedLocal,
audiobookSkippedTransient: audiobookStats.skippedTransient,
audiobookDbBooksSeeded: dbStats.audiobookDbBooksSeeded,
audiobookDbChaptersSeeded: dbStats.audiobookDbChaptersSeeded,
};
console.log(JSON.stringify(result, null, 2));
} finally {
await database.close();
}
}
main().catch((error) => {
console.error('Error running v2 migration script:', error);
process.exit(1);
});

View file

@ -0,0 +1,425 @@
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import * as dotenv from 'dotenv';
function loadEnvFiles() {
const cwd = process.cwd();
const envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local');
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
}
if (fs.existsSync(envLocalPath)) {
dotenv.config({ path: envLocalPath, override: true });
}
}
function isTrue(value, defaultValue) {
if (value == null || value.trim() === '') return defaultValue;
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
}
function resolveBooleanEnv(env, key, defaultValue) {
const value = env[key];
return isTrue(value, defaultValue);
}
function withDefault(value, fallback) {
return value && value.trim() ? value.trim() : fallback;
}
function isPrivateIPv4(address) {
if (!address) return false;
if (address.startsWith('10.')) return true;
if (address.startsWith('192.168.')) return true;
const m = /^172\.(\d+)\./.exec(address);
if (m) {
const second = Number.parseInt(m[1], 10);
if (second >= 16 && second <= 31) return true;
}
return false;
}
function detectHostForDefaultEndpoint() {
const interfaces = os.networkInterfaces();
const ipv4 = [];
for (const entries of Object.values(interfaces)) {
for (const entry of entries || []) {
if (!entry) continue;
const family = typeof entry.family === 'string' ? entry.family : String(entry.family);
if (family !== 'IPv4') continue;
if (entry.internal) continue;
ipv4.push(entry.address);
}
}
const privateAddr = ipv4.find(isPrivateIPv4);
if (privateAddr) return privateAddr;
if (ipv4[0]) return ipv4[0];
return '127.0.0.1';
}
function parseS3Endpoint(endpoint) {
let url;
try {
url = new URL(endpoint);
} catch {
throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`);
}
if (!url.hostname) {
throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`);
}
const port = Number.parseInt(url.port || '8333', 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`);
}
return {
hostname: url.hostname,
port,
normalized: `${url.protocol}//${url.hostname}:${port}`,
};
}
function parseUrlHost(urlValue, fieldName) {
let url;
try {
url = new URL(urlValue);
} catch {
throw new Error(`Invalid ${fieldName}: ${urlValue}`);
}
if (!url.hostname) {
throw new Error(`Invalid ${fieldName} host: ${urlValue}`);
}
return url.hostname;
}
function parseCommandFromArgs(argv) {
const marker = argv.indexOf('--');
if (marker >= 0) return argv.slice(marker + 1);
return argv;
}
function forwardChildStream(stream, target) {
if (!stream) return () => { };
const onData = (chunk) => {
target.write(chunk);
};
stream.on('data', onData);
return () => {
stream.off('data', onData);
};
}
function hasWeedBinary() {
const probe = spawnSync('weed', ['version'], { stdio: 'ignore' });
if (probe.error) return false;
return true;
}
async function waitForEndpoint(url, timeoutSeconds) {
const waitMs = Math.max(1, timeoutSeconds) * 1000;
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(url, { method: 'GET', signal: controller.signal });
if (res) return;
} catch {
// retry
} finally {
clearTimeout(timeoutId);
}
await delay(1000);
}
throw new Error(`Embedded weed mini did not become ready at ${url} within ${timeoutSeconds}s.`);
}
function isRunningInDocker() {
if (process.platform !== 'linux') return false;
if (fs.existsSync('/.dockerenv')) return true;
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
return /(docker|containerd|kubepods|podman)/i.test(cgroup);
} catch {
return false;
}
}
function spawnMainCommand(command, env) {
const [cmd, ...args] = command;
const child = spawn(cmd, args, {
env,
stdio: 'inherit',
shell: process.platform === 'win32',
});
const exitPromise = new Promise((resolve) => {
child.on('error', (error) => {
console.error('Failed to launch command:', error);
resolve(1);
});
child.on('exit', (code, signal) => {
if (typeof code === 'number') {
resolve(code);
return;
}
if (signal) {
resolve(1);
return;
}
resolve(0);
});
});
return { child, exitPromise };
}
function runDbMigrations(env) {
const migrateScript = path.join(process.cwd(), 'drizzle', 'scripts', 'migrate.mjs');
if (!fs.existsSync(migrateScript)) {
throw new Error(`Could not find migration script at ${migrateScript}`);
}
console.log('Running database migrations...');
const migration = spawnSync(process.execPath, [migrateScript], {
env,
stdio: 'inherit',
});
if (migration.error) {
throw migration.error;
}
if (typeof migration.status === 'number' && migration.status !== 0) {
throw new Error(`Database migrations failed with exit code ${migration.status}.`);
}
}
function runStorageMigrations(env) {
const migrateScript = path.join(process.cwd(), 'scripts', 'migrate-fs-v2.mjs');
if (!fs.existsSync(migrateScript)) {
throw new Error(`Could not find storage migration script at ${migrateScript}`);
}
console.log('Running storage migrations (v2)...');
const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], {
env,
stdio: 'inherit',
});
if (migration.error) {
throw migration.error;
}
if (typeof migration.status === 'number' && migration.status !== 0) {
throw new Error(`Storage migrations failed with exit code ${migration.status}.`);
}
}
function hasS3Config(env) {
return Boolean(
env.S3_BUCKET?.trim()
&& env.S3_REGION?.trim()
&& env.S3_ACCESS_KEY_ID?.trim()
&& env.S3_SECRET_ACCESS_KEY?.trim()
);
}
function sendSignal(child, signal, useProcessGroup) {
if (!child) return false;
if (useProcessGroup && process.platform !== 'win32' && typeof child.pid === 'number' && child.pid > 0) {
try {
process.kill(-child.pid, signal);
return true;
} catch {
return false;
}
}
try {
child.kill(signal);
return true;
} catch {
return false;
}
}
async function terminateChild(child, signal = 'SIGTERM', graceMs = 3000, useProcessGroup = false) {
if (!child) return;
if (child.exitCode != null) return;
if (!sendSignal(child, signal, useProcessGroup)) return;
const exited = await Promise.race([
once(child, 'exit').then(() => true).catch(() => true),
delay(graceMs).then(() => false),
]);
if (exited) return;
if (!sendSignal(child, 'SIGKILL', useProcessGroup)) return;
await Promise.race([
once(child, 'exit').then(() => true).catch(() => true),
delay(1000).then(() => false),
]);
}
async function main() {
loadEnvFiles();
const command = parseCommandFromArgs(process.argv.slice(2));
if (command.length === 0) {
console.error('Usage: node scripts/openreader-entrypoint.mjs -- <command> [args]');
process.exit(2);
}
const embeddedEnvRaw = process.env.USE_EMBEDDED_WEED_MINI;
let useEmbeddedWeed = isTrue(embeddedEnvRaw, true);
if (useEmbeddedWeed && !hasWeedBinary()) {
if (embeddedEnvRaw && isTrue(embeddedEnvRaw, true)) {
console.error('USE_EMBEDDED_WEED_MINI=true but `weed` binary is not available in PATH.');
process.exit(1);
}
useEmbeddedWeed = false;
console.warn('`weed` binary not found; skipping embedded SeaweedFS startup.');
}
const runtimeEnv = { ...process.env };
let weedProc = null;
let weedExitPromise = Promise.resolve();
let appProc = null;
let shutdownPromise = null;
let stopWeedStdoutForward = () => { };
let stopWeedStderrForward = () => { };
let didExit = false;
const exitOnce = (code) => {
if (didExit) return;
didExit = true;
process.exit(code);
};
const shutdown = async (signal = 'SIGTERM') => {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = (async () => {
await Promise.all([
terminateChild(appProc, signal, 4000),
terminateChild(weedProc, 'SIGTERM', 4000),
]);
await weedExitPromise;
stopWeedStdoutForward();
stopWeedStderrForward();
})();
return shutdownPromise;
};
process.once('SIGINT', () => {
void shutdown('SIGINT').finally(() => exitOnce(130));
});
process.once('SIGTERM', () => {
void shutdown('SIGTERM').finally(() => exitOnce(143));
});
try {
const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true);
if (shouldRunDbMigrations) {
runDbMigrations(runtimeEnv);
}
if (useEmbeddedWeed) {
runtimeEnv.WEED_MINI_DIR = withDefault(runtimeEnv.WEED_MINI_DIR, 'docstore/seaweedfs');
runtimeEnv.WEED_MINI_WAIT_SEC = withDefault(runtimeEnv.WEED_MINI_WAIT_SEC, '20');
runtimeEnv.S3_BUCKET = withDefault(runtimeEnv.S3_BUCKET, 'openreader-documents');
runtimeEnv.S3_REGION = withDefault(runtimeEnv.S3_REGION, 'us-east-1');
const configuredBaseUrl = runtimeEnv.BASE_URL?.trim() || '';
const baseUrlHost = configuredBaseUrl ? parseUrlHost(configuredBaseUrl, 'BASE_URL') : '';
const configuredS3Endpoint = runtimeEnv.S3_ENDPOINT?.trim() || '';
const defaultS3Host = baseUrlHost || detectHostForDefaultEndpoint();
runtimeEnv.S3_ENDPOINT = configuredS3Endpoint || `http://${defaultS3Host}:8333`;
runtimeEnv.S3_FORCE_PATH_STYLE = withDefault(runtimeEnv.S3_FORCE_PATH_STYLE, 'true');
runtimeEnv.S3_PREFIX = withDefault(runtimeEnv.S3_PREFIX, 'openreader');
runtimeEnv.S3_ACCESS_KEY_ID = withDefault(runtimeEnv.S3_ACCESS_KEY_ID, randomBytes(16).toString('hex'));
runtimeEnv.S3_SECRET_ACCESS_KEY = withDefault(runtimeEnv.S3_SECRET_ACCESS_KEY, randomBytes(32).toString('hex'));
runtimeEnv.AWS_ACCESS_KEY_ID = runtimeEnv.S3_ACCESS_KEY_ID;
runtimeEnv.AWS_SECRET_ACCESS_KEY = runtimeEnv.S3_SECRET_ACCESS_KEY;
fs.mkdirSync(runtimeEnv.WEED_MINI_DIR, { recursive: true });
const runningInDocker = isRunningInDocker();
const waitSec = Number.parseInt(runtimeEnv.WEED_MINI_WAIT_SEC || '20', 10);
const waitTimeout = Number.isFinite(waitSec) ? waitSec : 20;
const launchWeed = (endpointUrl) => {
const parsedEndpoint = parseS3Endpoint(endpointUrl);
const weedArgs = ['mini', `-dir=${runtimeEnv.WEED_MINI_DIR}`];
weedArgs.push(`-s3.port=${parsedEndpoint.port}`);
if (runningInDocker) {
weedArgs.push('-ip.bind=0.0.0.0');
}
weedProc = spawn('weed', weedArgs, {
env: runtimeEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
stopWeedStdoutForward = forwardChildStream(weedProc.stdout, process.stdout);
stopWeedStderrForward = forwardChildStream(weedProc.stderr, process.stderr);
weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined);
weedProc.on('exit', (code, signal) => {
if (typeof code === 'number' && code !== 0) {
console.error(`Embedded weed mini exited with code ${code}.`);
return;
}
if (signal) {
console.error(`Embedded weed mini exited due to signal ${signal}.`);
}
});
};
console.log('Starting embedded SeaweedFS weed mini...');
launchWeed(runtimeEnv.S3_ENDPOINT);
const startupEndpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT);
await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout);
console.log(`Embedded SeaweedFS is ready at ${runtimeEnv.S3_ENDPOINT}`);
}
const shouldRunStorageMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_FS_MIGRATIONS', true);
if (shouldRunStorageMigrations) {
if (hasS3Config(runtimeEnv)) {
runStorageMigrations(runtimeEnv);
} else {
console.warn('Skipping storage migrations: S3 configuration is incomplete.');
}
}
const { child, exitPromise } = spawnMainCommand(command, runtimeEnv);
appProc = child;
const exitCode = await exitPromise;
await shutdown('SIGTERM');
exitOnce(exitCode);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
await shutdown('SIGTERM');
exitOnce(1);
}
}
await main();

View file

@ -0,0 +1,33 @@
import { Header } from '@/components/Header';
import { HomeContent } from '@/components/HomeContent';
import { SettingsModal } from '@/components/SettingsModal';
import { UserMenu } from '@/components/auth/UserMenu';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
export default function Home() {
return (
<div className="flex flex-col h-full w-full">
<Header
title={
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="w-5 h-5" aria-hidden="true" />
<h1 className="text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">OpenReader</h1>
</div>
}
right={
<div className="flex items-center gap-2">
<SettingsModal />
<UserMenu />
</div>
}
/>
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
<div className="max-w-7xl mx-auto">
<RateLimitBanner className="mb-6" />
<HomeContent />
</div>
</section>
</div>
);
}

View file

@ -2,29 +2,31 @@
import { useParams, useRouter } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEPUB } from '@/contexts/EPUBContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { EPUBViewer } from '@/components/EPUBViewer';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { EPUBViewer } from '@/components/views/EPUBViewer';
import { DocumentSettings } from '@/components/documents/DocumentSettings';
import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { DownloadIcon } from '@/components/icons/Icons';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { resolveDocumentId } from '@/lib/dexie';
import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false';
export default function EPUBPage() {
const { id } = useParams();
const router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { stop } = useTTS();
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@ -32,11 +34,20 @@ export default function EPUBPage() {
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
const [maxPadPx, setMaxPadPx] = useState<number>(0);
const inFlightDocIdRef = useRef<string | null>(null);
const loadedDocIdRef = useRef<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
inFlightDocIdRef.current = null;
loadedDocIdRef.current = null;
}, [id]);
const loadDocument = useCallback(async () => {
console.log('Loading new epub (from page.tsx)');
stop(); // Reset TTS when loading new document
let didRedirect = false;
let startedLoad = false;
try {
if (!id) {
setError('Document not found');
@ -48,12 +59,27 @@ export default function EPUBPage() {
router.replace(`/epub/${resolved}`);
return;
}
if (loadedDocIdRef.current === resolved) {
return;
}
if (inFlightDocIdRef.current === resolved) {
return;
}
startedLoad = true;
inFlightDocIdRef.current = resolved;
stop(); // Reset TTS when loading new document
await setCurrentDocument(resolved);
loadedDocIdRef.current = resolved;
} catch (err) {
console.error('Error loading document:', err);
setError('Failed to load document');
} finally {
if (!didRedirect) {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
if (!didRedirect && startedLoad) {
setIsLoading(false);
}
}
@ -118,7 +144,7 @@ export default function EPUBPage() {
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
href="/app"
onClick={() => clearCurrDoc()}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
@ -136,7 +162,7 @@ export default function EPUBPage() {
<Header
left={
<Link
href="/"
href="/app"
onClick={() => clearCurrDoc()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
@ -150,45 +176,32 @@ export default function EPUBPage() {
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-3">
<ZoomControl
value={padPct}
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
min={0}
max={100}
<DocumentHeaderMenu
zoomLevel={padPct}
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
onOpenSettings={() => setIsSettingsOpen(true)}
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
showAudiobookExport={canExportAudiobook}
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 className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px` }}>
<EPUBViewer className="h-full" />
</div>
)}
</div>
{isDev && (
{canExportAudiobook && (
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
@ -198,7 +211,16 @@ export default function EPUBPage() {
onRegenerateChapter={handleRegenerateChapter}
/>
)}
<TTSPlayer />
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>
</div>
) : (
<TTSPlayer />
)}
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -2,35 +2,47 @@
import { useParams, useRouter } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useHTML } from '@/contexts/HTMLContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { HTMLViewer } from '@/components/HTMLViewer';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { HTMLViewer } from '@/components/views/HTMLViewer';
import { DocumentSettings } from '@/components/documents/DocumentSettings';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl';
import { resolveDocumentId } from '@/lib/dexie';
import { resolveDocumentId } from '@/lib/client/dexie';
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
export default function HTMLPage() {
const { id } = useParams();
const router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML();
const { stop } = useTTS();
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
const [maxPadPx, setMaxPadPx] = useState<number>(0);
const inFlightDocIdRef = useRef<string | null>(null);
const loadedDocIdRef = useRef<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
inFlightDocIdRef.current = null;
loadedDocIdRef.current = null;
}, [id]);
const loadDocument = useCallback(async () => {
if (!isLoading) return;
console.log('Loading new HTML document (from page.tsx)');
stop();
let didRedirect = false;
let startedLoad = false;
try {
if (!id) {
setError('Document not found');
@ -42,20 +54,36 @@ export default function HTMLPage() {
router.replace(`/html/${resolved}`);
return;
}
if (loadedDocIdRef.current === resolved) {
return;
}
if (inFlightDocIdRef.current === resolved) {
return;
}
startedLoad = true;
inFlightDocIdRef.current = resolved;
stop();
await setCurrentDocument(resolved);
loadedDocIdRef.current = resolved;
} catch (err) {
console.error('Error loading document:', err);
setError('Failed to load document');
} finally {
if (!didRedirect) {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
if (!didRedirect && startedLoad) {
setIsLoading(false);
}
}
}, [isLoading, id, router, setCurrentDocument, stop]);
useEffect(() => {
if (!isLoading) return;
loadDocument();
}, [loadDocument]);
}, [loadDocument, isLoading]);
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
@ -85,8 +113,8 @@ export default function HTMLPage() {
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
onClick={() => {clearCurrDoc();}}
href="/app"
onClick={() => { clearCurrDoc(); }}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -103,7 +131,7 @@ export default function HTMLPage() {
<Header
left={
<Link
href="/"
href="/app"
onClick={() => clearCurrDoc()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
@ -117,20 +145,14 @@ export default function HTMLPage() {
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-3">
<ZoomControl
value={padPct}
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
min={0}
max={100}
<DocumentHeaderMenu
zoomLevel={padPct}
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
onOpenSettings={() => setIsSettingsOpen(true)}
minZoom={0}
maxZoom={100}
/>
<button
onClick={() => setIsSettingsOpen(true)}
className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
</div>
}
/>
@ -140,12 +162,21 @@ export default function HTMLPage() {
<DocumentSkeleton />
</div>
) : (
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px` }}>
<HTMLViewer className="h-full" />
</div>
)}
</div>
<TTSPlayer />
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>
</div>
) : (
<TTSPlayer />
)}
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

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

@ -0,0 +1,64 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Toaster } from 'react-hot-toast';
import { Providers } from '@/app/providers';
import ClaimDataPopup from '@/components/auth/ClaimDataModal';
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
noimageindex: true,
'max-snippet': 0,
'max-video-preview': 0,
},
},
};
export default function AppLayout({ children }: { children: ReactNode }) {
const authEnabled = isAuthEnabled();
const authBaseUrl = getAuthBaseUrl();
const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled();
const githubAuthEnabled = isGithubAuthEnabled();
return (
<Providers
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<div className="app-shell min-h-screen flex flex-col bg-background">
{authEnabled && <ClaimDataPopup />}
<main className="flex-1 flex flex-col">{children}</main>
</div>
<Toaster
toastOptions={{
style: {
background: 'var(--offbase)',
color: 'var(--foreground)',
},
success: {
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
},
error: {
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
},
}}
/>
</Providers>
);
}

View file

@ -4,25 +4,27 @@ import dynamic from 'next/dynamic';
import { usePDF } from '@/contexts/PDFContext';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
import { DocumentSettings } from '@/components/documents/DocumentSettings';
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
import { Header } from '@/components/Header';
import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import TTSPlayer from '@/components/player/TTSPlayer';
import { resolveDocumentId } from '@/lib/dexie';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false';
// Dynamic import for client-side rendering only
const PDFViewer = dynamic(
() => import('@/components/PDFViewer').then((module) => module.PDFViewer),
{
() => import('@/components/views/PDFViewer').then((module) => module.PDFViewer),
{
ssr: false,
loading: () => <DocumentSkeleton />
}
@ -33,18 +35,28 @@ export default function PDFViewerPage() {
const router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
const { stop } = useTTS();
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const inFlightDocIdRef = useRef<string | null>(null);
const loadedDocIdRef = useRef<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
inFlightDocIdRef.current = null;
loadedDocIdRef.current = null;
}, [id]);
const loadDocument = useCallback(async () => {
if (!isLoading) return; // Prevent calls when not loading new doc
console.log('Loading new document (from page.tsx)');
stop(); // Reset TTS when loading new document
let didRedirect = false;
let startedLoad = false;
try {
if (!id) {
setError('Document not found');
@ -56,12 +68,27 @@ export default function PDFViewerPage() {
router.replace(`/pdf/${resolved}`);
return;
}
setCurrentDocument(resolved);
if (loadedDocIdRef.current === resolved) {
return;
}
if (inFlightDocIdRef.current === resolved) {
return;
}
startedLoad = true;
inFlightDocIdRef.current = resolved;
stop(); // Reset TTS when loading new document
await setCurrentDocument(resolved);
loadedDocIdRef.current = resolved;
} catch (err) {
console.error('Error loading document:', err);
setError('Failed to load document');
} finally {
if (!didRedirect) {
if (startedLoad) {
inFlightDocIdRef.current = null;
}
if (!didRedirect && startedLoad) {
setIsLoading(false);
}
}
@ -113,8 +140,8 @@ export default function PDFViewerPage() {
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
onClick={() => {clearCurrDoc();}}
href="/app"
onClick={() => { clearCurrDoc(); }}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -131,7 +158,7 @@ export default function PDFViewerPage() {
<Header
left={
<Link
href="/"
href="/app"
onClick={() => clearCurrDoc()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
@ -145,28 +172,21 @@ export default function PDFViewerPage() {
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-2">
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
{isDev && (
<button
onClick={() => setIsAudiobookModalOpen(true)}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open audiobook export"
title="Export Audiobook"
>
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
)}
<button
onClick={() => setIsSettingsOpen(true)}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
<DocumentHeaderMenu
zoomLevel={zoomLevel}
onZoomIncrease={handleZoomIn}
onZoomDecrease={handleZoomOut}
onOpenSettings={() => setIsSettingsOpen(true)}
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
showAudiobookExport={canExportAudiobook}
minZoom={50}
maxZoom={300}
/>
</div>
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
@ -175,7 +195,7 @@ export default function PDFViewerPage() {
<PDFViewer zoomLevel={zoomLevel} />
)}
</div>
{isDev && (
{canExportAudiobook && (
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
@ -185,7 +205,16 @@ export default function PDFViewerPage() {
onRegenerateChapter={handleRegenerateChapter}
/>
)}
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>
</div>
) : (
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
)}
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -0,0 +1,279 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams();
useEffect(() => {
const reason = searchParams.get('reason');
setSessionExpired(reason === 'expired');
}, [searchParams, setSessionExpired]);
return null;
}
function SignInContent() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGithub, setLoadingGithub] = useState(false);
const [loadingAnonymous, setLoadingAnonymous] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [sessionExpired, setSessionExpired] = useState(false);
const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit();
const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous;
// Check if auth is enabled, redirect home if not
useEffect(() => {
if (!authEnabled) {
router.push('/app');
}
}, [router, authEnabled]);
const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const handleSignIn = async () => {
setError(null);
if (!email.trim() || !validateEmail(email)) {
setError('Please enter a valid email address');
return;
}
if (!password.trim()) {
setError('Password is required');
return;
}
setLoadingEmail(true);
try {
const client = getAuthClient(baseUrl);
const result = await client.signIn.email({
email: email.trim(),
password,
rememberMe
});
if (result.error) {
const errorMessage = result.error.message || 'An unknown error occurred';
if (errorMessage.toLowerCase().includes('invalid') ||
errorMessage.toLowerCase().includes('credentials')) {
setError('Invalid email or password');
} else {
setError(errorMessage);
}
} else {
// Immediately refresh rate-limit status so the banner clears without a full reload.
// This is especially important when an anonymous user upgrades to an account.
await refreshRateLimit();
router.push('/app');
}
} catch (err) {
console.error('Sign in error:', err);
setError('Unable to connect. Please try again.');
} finally {
setLoadingEmail(false);
}
};
const handleGithubSignIn = async () => {
setLoadingGithub(true);
try {
const client = getAuthClient(baseUrl);
await client.signIn.social({
provider: 'github',
callbackURL: '/app'
});
} finally {
setLoadingGithub(false);
}
};
const handleAnonymousContinue = async () => {
setLoadingAnonymous(true);
setError(null);
try {
const client = getAuthClient(baseUrl);
await client.signIn.anonymous();
await refreshRateLimit();
router.push('/app');
} catch (e) {
console.error('Anonymous sign-in failed:', e);
setError('Unable to continue anonymously. Please try again.');
} finally {
setLoadingAnonymous(false);
}
};
if (!authEnabled) {
return null;
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<Suspense fallback={null}>
<SessionExpiredLoader setSessionExpired={setSessionExpired} />
</Suspense>
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
<h1 className="text-xl font-semibold text-foreground">
{sessionExpired ? 'Session Expired' : 'Connect Account'}
</h1>
<p className="text-sm text-muted mt-1">
{sessionExpired
? 'Please sign in again to continue'
: 'Connect an email account to sync your data across devices'}
</p>
{/* Alerts */}
{sessionExpired && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<p className="text-sm text-amber-700 dark:text-amber-400">
Your session has expired. Please sign in again.
</p>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="mt-6 space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<Input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Remember Me */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="rounded border-muted text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">Remember me</span>
</label>
{/* Connect Button */}
<Button
type="submit"
disabled={isAnyLoading}
onClick={handleSignIn}
className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background
hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent
focus:ring-offset-2 disabled:opacity-50 transform transition-transform
duration-200 hover:scale-[1.02]"
>
{loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
</Button>
{/* GitHub */}
{githubAuthEnabled && (
<Button
type="button"
disabled={isAnyLoading}
onClick={handleGithubSignIn}
className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground
hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent
focus:ring-offset-2 disabled:opacity-50 border border-offbase
transform transition-transform duration-200 hover:scale-[1.02]
flex items-center justify-center gap-2"
>
{loadingGithub ? (
<LoadingSpinner className="w-4 h-4" />
) : (
<>
<GithubIcon className="w-4 h-4" />
Sign in with GitHub
</>
)}
</Button>
)}
{/* Anonymous */}
{allowAnonymousAuthSessions && (
<Button
type="button"
disabled={isAnyLoading}
onClick={handleAnonymousContinue}
className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground
hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent
focus:ring-offset-2 disabled:opacity-50 border border-offbase
transform transition-transform duration-200 hover:scale-[1.02]"
>
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
</Button>
)}
</div>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
<p className="text-xs text-muted">
Don&apos;t have an account?{' '}
<Link href="/signup" className="underline hover:text-foreground">
Sign up
</Link>
</p>
<p className="text-xs text-muted">
By signing in, you agree to our{' '}
<button
onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy
</button>
</p>
</div>
</div>
</div>
);
}
export default function SignInPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-background">
<LoadingSpinner className="w-8 h-8" />
</div>
}>
<SignInContent />
</Suspense>
);
}

View file

@ -0,0 +1,246 @@
'use client';
import { useState, useEffect } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner';
import toast from 'react-hot-toast';
export default function SignUpPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirmation, setPasswordConfirmation] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit();
// Check if auth is enabled, redirect home if not
useEffect(() => {
if (!authEnabled) {
router.push('/app');
}
}, [router, authEnabled]);
const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const validatePassword = (password: string) => {
const checks = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /\d/.test(password),
special: /[!@#$%^&*(),.?":{}|<>]/.test(password)
};
const strength = Object.values(checks).filter(Boolean).length;
return { checks, strength };
};
const handleSignUp = async () => {
setError(null);
if (!email.trim() || !validateEmail(email)) {
setError('Please enter a valid email address');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
const { strength } = validatePassword(password);
if (strength < 3) {
setError('Password is too weak');
return;
}
if (password !== passwordConfirmation) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
const client = getAuthClient(baseUrl);
const result = await client.signUp.email({
email: email.trim(),
password,
name: email.trim().split('@')[0], // Use part of email as name
});
if (result.error) {
const errorMessage = result.error.message || 'An unknown error occurred';
if (errorMessage.toLowerCase().includes('already exists')) {
setError('An account with this email already exists');
} else {
setError(errorMessage);
}
} else {
// Auto sign in
const signInResult = await client.signIn.email({ email: email.trim(), password });
if (signInResult.error) {
toast.success('Account created! Please sign in.');
router.push('/signin');
} else {
await refreshRateLimit();
toast.success('Account created successfully!');
router.push('/app');
}
}
} catch (err) {
console.error('Signup error:', err);
setError('Unable to connect. Please try again.');
} finally {
setLoading(false);
}
};
if (!authEnabled) {
return null;
}
const { checks, strength } = validatePassword(password);
const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
<p className="text-sm text-muted mt-1">Create your account to get started</p>
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="mt-6 space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground"
>
{showPassword ? '👁️' : '👁️‍🗨️'}
</button>
</div>
{/* Password Strength */}
{password && (
<div className="mt-2 space-y-1">
<div className="flex gap-1">
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className={`h-1 flex-1 rounded ${i < strength ? strengthColors[strength - 1] : 'bg-offbase'
}`}
/>
))}
</div>
<p className={`text-xs ${strength >= 3 ? 'text-green-600' : 'text-red-600'}`}>
{strengthLabels[strength - 1] || 'Very Weak'}
</p>
<div className="text-xs space-y-0.5 text-muted">
{Object.entries(checks).map(([key, passed]) => (
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-green-600' : ''}`}>
<span>{passed ? '✓' : '○'}</span>
<span>
{key === 'length' && 'At least 8 characters'}
{key === 'uppercase' && 'Uppercase letter'}
{key === 'lowercase' && 'Lowercase letter'}
{key === 'number' && 'Number'}
{key === 'special' && 'Special character'}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
<Input
type={showPassword ? 'text' : 'password'}
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
placeholder="Confirm Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
{passwordConfirmation && password && (
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-green-600' : 'text-red-600'}`}>
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
</p>
)}
</div>
{/* Sign Up Button */}
<Button
type="submit"
disabled={loading}
onClick={handleSignUp}
className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background
hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent
focus:ring-offset-2 disabled:opacity-50 transform transition-transform
duration-200 hover:scale-[1.02]"
>
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
</Button>
</div>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
<p className="text-xs text-muted">
Already have an account?{' '}
<Link href="/signin" className="underline hover:text-foreground">
Sign in
</Link>
</p>
<p className="text-xs text-muted">
By creating an account, you agree to our{' '}
<button
onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy
</button>
</p>
</div>
</div>
</div>
);
}

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

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

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

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

View file

@ -0,0 +1,303 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers';
export const metadata: Metadata = {
title: 'Privacy & Data Usage | OpenReader',
description:
'Learn how OpenReader handles your data, what is stored in your browser, and what is sent to the server.',
alternates: {
canonical: '/privacy',
},
robots: {
index: true,
follow: true,
},
};
export default async function PrivacyPage() {
const effectiveDate = 'February 17, 2026';
const hdrs = await headers();
const host = hdrs.get('host') ?? 'this server';
const proto = hdrs.get('x-forwarded-proto') ?? 'https';
const origin = `${proto}://${host}`;
return (
<>
<style>{`
/* ── Privacy body ───────────────── */
.privacy-body {
max-width: 42rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
animation: landing-fade-up 0.7s ease-out 0.15s both;
}
.privacy-body h1 {
font-family: var(--g-display);
font-weight: 800;
font-size: clamp(1.5rem, 4vw, 2.25rem);
letter-spacing: -0.03em;
margin: 0 0 0.5rem;
}
.privacy-body h1 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.privacy-subtitle {
font-size: 0.95rem;
color: var(--g-muted);
margin: 0 0 2.5rem;
line-height: 1.6;
}
.privacy-card {
padding: 2rem;
margin-bottom: 1.25rem;
}
.privacy-card-label {
font-family: var(--g-display);
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--g-accent);
margin: 0 0 0.75rem;
}
.privacy-card p,
.privacy-card li {
font-size: 0.92rem;
line-height: 1.65;
color: var(--g-fg);
}
.privacy-card ul {
list-style: none;
margin: 0;
padding: 0;
}
.privacy-card li {
position: relative;
padding-left: 1.1rem;
margin-bottom: 0.4rem;
}
.privacy-card li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--g-accent);
opacity: 0.5;
}
.privacy-highlight {
background: color-mix(in srgb, var(--g-accent), transparent 88%);
border: 1px solid color-mix(in srgb, var(--g-accent), transparent 70%);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
font-size: 0.88rem;
line-height: 1.6;
color: var(--g-fg);
}
.privacy-highlight strong {
color: var(--g-accent);
font-weight: 600;
}
.privacy-note {
font-size: 0.8rem;
color: var(--g-muted);
line-height: 1.6;
margin-top: 2rem;
}
.privacy-note a {
color: var(--g-accent);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.privacy-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--g-system);
font-size: 0.875rem;
font-weight: 500;
color: var(--g-accent);
text-decoration: none;
margin-top: 2rem;
transition: opacity 0.2s;
}
.privacy-back:hover {
opacity: 0.75;
}
`}</style>
<div className="privacy-body">
<h1>Privacy &amp; <span>Data Usage</span></h1>
<p className="privacy-subtitle">
Effective Date: {effectiveDate}
</p>
<div className="privacy-highlight">
This OpenReader instance is hosted at <strong>{origin}</strong>.
The operator of this service is responsible for handling your information.
</div>
<div className="privacy-highlight">
<strong>OpenReader does not sell your personal information.</strong> We do not sell data to data brokers or third parties.
We use data solely to provide and improve the reading experience.
</div>
<div className="space-y-8">
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
1. Information We Collect (CCPA Categories)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We collect information that identifies, relates to, describes, references, or is reasonably capable of being associated with you (&quot;<strong>Personal Information</strong>&quot;).
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Categories Collected</div>
<ul className="space-y-3">
<li>
<strong className="text-foreground">Identifiers:</strong> Email address, IP address, unique personal identifier (session token), and account name.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Authentication, security, providing service.</div>
</li>
<li>
<strong className="text-foreground">Customer Records:</strong> Uploaded documents (PDF, EPUB), reading progress, bookmarks, and preferences.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Providing core reading functionality.</div>
</li>
<li>
<strong className="text-foreground">Internet Activity:</strong> Browsing history within the app, interaction with features (Analytics).
<div className="text-xs text-muted-foreground mt-1">Source: Automatic collection. Purpose: Debugging, performance optimization.</div>
</li>
</ul>
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
2. How We Use Your Information
</h2>
<ul className="list-disc pl-5 space-y-2 text-sm text-foreground/90">
<li>To provide, support, and personalize the OpenReader application.</li>
<li>To process your uploaded documents for display and text-to-speech conversion.</li>
<li>To maintain the safety, security, and integrity of our service.</li>
<li>To debug and repair errors that impair existing intended functionality.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
3. Sharing &amp; Selling
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
<strong>We do not sell your personal information.</strong>
</p>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We may &quot;share&quot; (as defined by CPRA for cross-context behavioral advertising) anonymous usage data with analytics providers solely to improve our app. You can opt-out of this sharing via the Cookie Banner, and Global Privacy Control (GPC) signals are automatically honored.
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Service Providers (Sub-processors)</div>
{process.env.RICHARDRDEV_PRODUCTION === 'true' ? (
<ul className="grid gap-2 sm:grid-cols-2 mt-2">
<li className="text-sm"><strong>Vercel:</strong> Hosting, Edge Functions &amp; Analytics</li>
<li className="text-sm"><strong>Neon (PostgreSQL):</strong> Database Storage</li>
<li className="text-sm"><strong>Railway (S3):</strong> Encrypted Object Storage (Documents)</li>
<li className="text-sm"><strong>DeepInfra:</strong> Text-to-Speech Processing (User-Initiated)</li>
</ul>
) : (
<ul className="grid gap-2 sm:grid-cols-2 mt-2">
<li className="text-sm"><strong>Hosting Provider:</strong> Application Hosting &amp; Logs</li>
<li className="text-sm"><strong>Database Service:</strong> Relational Database Storage</li>
<li className="text-sm"><strong>Object Storage:</strong> Encrypted File Storage (Documents)</li>
<li className="text-sm"><strong>TTS Provider:</strong> Text-to-Speech Processing (Optional)</li>
</ul>
)}
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
4. Your Rights (CCPA/CPRA)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
You have the following rights regarding your personal information:
</p>
<div className="privacy-card landing-panel">
<ul className="space-y-2">
<li><strong>Right to Know:</strong> You may request details about the categories and specific pieces of personal information we have collected.</li>
<li><strong>Right to Delete:</strong> You may request deletion of your personal information (via &quot;Delete Account&quot; in Settings).</li>
<li><strong>Right to Correct:</strong> You may update your account information in Settings.</li>
<li><strong>Right to Opt-Out:</strong> We do not sell data. You may opt-out of analytics &quot;sharing&quot; via our Cookie Banner.</li>
<li><strong>Right to Non-Discrimination:</strong> We will not discriminate against you for exercising your privacy rights.</li>
</ul>
</div>
<div className="mt-4">
<p className="text-sm text-foreground/90 mb-2">
<strong>How to Exercise Your Rights:</strong>
</p>
<ul className="list-disc pl-5 text-sm text-foreground/90">
<li><strong>Export Data:</strong> Use the &quot;Export My Data&quot; button in Settings to download your account metadata plus object-storage-backed document and audiobook files.</li>
<li><strong>Delete Data:</strong> Use the &quot;Delete Account&quot; button in Settings.</li>
</ul>
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
5. Data Retention
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We retain your account data and uploaded files only for as long as you maintain an account.
Uploaded documents are stored <strong>encrypted at rest (AES-256)</strong>.
Upon account deletion, all data is permanently removed from our active databases and storage buckets immediately.
</p>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
6. Contact Us
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
If you have questions or concerns about this Privacy Policy, please contact the instance administrator via the repository:
</p>
<a
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline font-medium"
>
OpenReader Issues
</a>
</section>
</div>
<p className="privacy-note mt-12 pt-8 border-t border-border">
For maximum privacy, you can self-host OpenReader using the{' '}
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
>
open-source repository
</a>.
</p>
<Link href="/?redirect=false" className="privacy-back">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 8H3M7 4l-4 4 4 4" /></svg>
Back to home
</Link>
</div>
</>
);
}

View file

@ -0,0 +1,36 @@
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config';
export async function DELETE() {
if (!isAuthEnabled() || !auth) {
return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 });
}
const reqHeaders = await headers();
const session = await auth.api.getSession({
headers: reqHeaders
});
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Use Better Auth's built-in deleteUser to handle cascading cleanup
await auth.api.deleteUser({
headers: reqHeaders,
body: {},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete account:', error);
return NextResponse.json(
{ error: 'Failed to delete account' },
{ status: 500 }
);
}
}

View file

@ -1,141 +1,541 @@
import { NextRequest, NextResponse } from 'next/server';
import { createReadStream, existsSync } from 'fs';
import { readdir, unlink } from 'fs/promises';
import { spawn } from 'child_process';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { findStoredChapterByIndex } from '@/lib/server/audiobook';
import { randomUUID } from 'crypto';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import {
deleteAudiobookObject,
getAudiobookObjectBuffer,
isMissingBlobError,
listAudiobookObjects,
putAudiobookObject,
} from '@/lib/server/audiobooks/blobstore';
import {
decodeChapterFileName,
encodeChapterFileName,
encodeChapterTitleTag,
ffprobeAudio,
} from '@/lib/server/audiobooks/chapters';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
export const dynamic = 'force-dynamic';
function getAudiobooksRootDir(request: NextRequest): string {
const raw = request.headers.get('x-openreader-test-namespace')?.trim();
if (!raw) return AUDIOBOOKS_V1_DIR;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR;
interface ConversionRequest {
chapterTitle: string;
buffer: TTSAudioBytes;
bookId?: string;
format?: TTSAudiobookFormat;
chapterIndex?: number;
settings?: AudiobookGenerationSettings;
}
type ChapterObject = {
index: number;
title: string;
format: TTSAudiobookFormat;
fileName: string;
};
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
function isSafeId(value: string): boolean {
return SAFE_ID_REGEX.test(value);
}
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Audiobooks storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
function chapterFileMimeType(format: TTSAudiobookFormat): string {
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
}
function buildAtempoFilter(speed: number): string {
const clamped = Math.max(0.5, Math.min(speed, 3));
if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`;
const second = clamped / 2;
return `atempo=2.0,atempo=${second.toFixed(3)}`;
}
function listChapterObjects(objectNames: string[]): ChapterObject[] {
const chapters = objectNames
.filter((name) => !name.startsWith('complete.'))
.map((fileName) => {
const decoded = decodeChapterFileName(fileName);
if (!decoded) return null;
return {
index: decoded.index,
title: decoded.title,
format: decoded.format,
fileName,
} satisfies ChapterObject;
})
.filter((value): value is ChapterObject => Boolean(value))
.sort((a, b) => a.index - b.index);
const deduped = new Map<number, ChapterObject>();
for (const chapter of chapters) {
const existing = deduped.get(chapter.index);
if (!existing || chapter.fileName > existing.fileName) {
deduped.set(chapter.index, chapter);
}
}
return Array.from(deduped.values()).sort((a, b) => a.index - b.index);
}
function streamBuffer(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const ffmpeg = spawn(getFFmpegPath(), args);
let finished = false;
const onAbort = () => {
if (finished) return;
finished = true;
try {
ffmpeg.kill('SIGKILL');
} catch {}
reject(new Error('ABORTED'));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
ffmpeg.stderr.on('data', (data) => {
console.error(`ffmpeg stderr: ${data}`);
});
ffmpeg.on('close', (code) => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
if (code === 0) {
resolve();
} else {
reject(new Error(`FFmpeg process exited with code ${code}`));
}
});
ffmpeg.on('error', (err) => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
reject(err);
});
});
}
function findChapterFileNameByIndex(fileNames: string[], index: number): { fileName: string; title: string; format: 'mp3' | 'm4b' } | null {
const matches = fileNames
.map((fileName) => {
const decoded = decodeChapterFileName(fileName);
if (!decoded) return null;
if (decoded.index !== index) return null;
return { fileName, title: decoded.title, format: decoded.format };
})
.filter((value): value is { fileName: string; title: string; format: 'mp3' | 'm4b' } => Boolean(value))
.sort((a, b) => a.fileName.localeCompare(b.fileName));
return matches.at(-1) ?? null;
}
export async function POST(request: NextRequest) {
let workDir: string | null = null;
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const data: ConversionRequest = await request.json();
const requestedFormat = data.format || 'm4b';
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const bookId = data.bookId || randomUUID();
if (!isSafeId(bookId)) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const storageUserId =
pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
) ?? preferredUserId;
await db
.insert(audiobooks)
.values({
id: bookId,
userId: storageUserId,
title: data.chapterTitle || 'Untitled Audiobook',
})
.onConflictDoNothing();
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
const objectNames = objects.map((item) => item.fileName);
const existingChapters = listChapterObjects(objectNames);
const hasChapters = existingChapters.length > 0;
let existingSettings: AudiobookGenerationSettings | null = null;
try {
existingSettings = JSON.parse(
(await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'),
) as AudiobookGenerationSettings;
} catch (error) {
if (!isMissingBlobError(error)) throw error;
existingSettings = null;
}
const incomingSettings = data.settings;
if (existingSettings && hasChapters && incomingSettings) {
const mismatch =
existingSettings.ttsProvider !== incomingSettings.ttsProvider ||
existingSettings.ttsModel !== incomingSettings.ttsModel ||
existingSettings.voice !== incomingSettings.voice ||
existingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
existingSettings.postSpeed !== incomingSettings.postSpeed ||
existingSettings.format !== incomingSettings.format;
if (mismatch) {
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 });
}
}
const existingFormats = new Set(existingChapters.map((chapter) => chapter.format));
if (existingFormats.size > 1) {
return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 });
}
const format: TTSAudiobookFormat =
(existingFormats.values().next().value as TTSAudiobookFormat | undefined) ??
existingSettings?.format ??
incomingSettings?.format ??
requestedFormat;
const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1;
const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1;
let chapterIndex: number;
if (data.chapterIndex !== undefined) {
const normalized = Number(data.chapterIndex);
if (!Number.isInteger(normalized) || normalized < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 });
}
chapterIndex = normalized;
} else {
const indices = existingChapters.map((c) => c.index);
let next = 0;
for (const idx of indices) {
if (idx === next) {
next++;
} else if (idx > next) {
break;
}
}
chapterIndex = next;
}
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-'));
const inputPath = join(workDir, `${chapterIndex}-input.mp3`);
const chapterOutputTempPath = join(workDir, `${chapterIndex}-chapter.tmp.${format}`);
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
if (format === 'mp3') {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'libmp3lame',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
chapterOutputTempPath,
],
request.signal,
);
} else {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'aac',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
'-f',
'mp4',
chapterOutputTempPath,
],
request.signal,
);
}
const probe = await ffprobeAudio(chapterOutputTempPath, request.signal);
const duration = probe.durationSec ?? 0;
const finalChapterName = encodeChapterFileName(chapterIndex, data.chapterTitle, format);
const finalChapterBytes = await readFile(chapterOutputTempPath);
await putAudiobookObject(bookId, storageUserId, finalChapterName, finalChapterBytes, chapterFileMimeType(format), testNamespace);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
for (const fileName of objectNames) {
if (!fileName.startsWith(chapterPrefix)) continue;
if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue;
if (fileName === finalChapterName) continue;
await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {});
}
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
if (!existingSettings && incomingSettings) {
await putAudiobookObject(
bookId,
storageUserId,
'audiobook.meta.json',
Buffer.from(JSON.stringify(incomingSettings, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);
}
await db
.insert(audiobookChapters)
.values({
id: `${bookId}-${chapterIndex}`,
bookId,
userId: storageUserId,
chapterIndex,
title: data.chapterTitle,
duration,
format,
filePath: finalChapterName,
})
.onConflictDoUpdate({
target: [audiobookChapters.id, audiobookChapters.userId],
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName },
});
return NextResponse.json({
index: chapterIndex,
title: data.chapterTitle,
duration,
status: 'completed' as const,
bookId,
format,
});
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
}
console.error('Error processing audio chapter:', error);
return NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 });
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
export async function GET(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const bookId = request.nextUrl.searchParams.get('bookId');
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
if (!bookId || !chapterIndexStr) {
return NextResponse.json(
{ error: 'Missing bookId or chapterIndex parameter' },
{ status: 400 }
);
return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 });
}
const chapterIndex = parseInt(chapterIndexStr);
if (isNaN(chapterIndex)) {
return NextResponse.json(
{ error: 'Invalid chapterIndex parameter' },
{ status: 400 }
);
const chapterIndex = Number.parseInt(chapterIndexStr, 10);
if (!Number.isInteger(chapterIndex) || chapterIndex < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 });
}
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const existingBookUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBookUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal);
if (!chapter || !existsSync(chapter.filePath)) {
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
const chapter = findChapterFileNameByIndex(
objects.map((object) => object.fileName),
chapterIndex,
);
if (!chapter) {
await db
.delete(audiobookChapters)
.where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, existingBookUserId),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
}
// Stream the chapter file
const stream = createReadStream(chapter.filePath);
const readableWebStream = new ReadableStream({
start(controller) {
stream.on('data', (chunk) => {
controller.enqueue(chunk);
});
stream.on('end', () => {
controller.close();
});
stream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
stream.destroy();
let buffer: Buffer;
try {
buffer = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
} catch (error) {
if (isMissingBlobError(error)) {
await db
.delete(audiobookChapters)
.where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, existingBookUserId),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
}
});
throw error;
}
const mimeType = chapter.format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
const sanitizedTitle = chapter.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
return new NextResponse(readableWebStream, {
return new NextResponse(streamBuffer(buffer), {
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${sanitizedTitle}.${chapter.format}"`,
'Cache-Control': 'no-cache',
},
});
} catch (error) {
console.error('Error downloading chapter:', error);
return NextResponse.json(
{ error: 'Failed to download chapter' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const bookId = request.nextUrl.searchParams.get('bookId');
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
if (!bookId || !chapterIndexStr) {
return NextResponse.json(
{ error: 'Missing bookId or chapterIndex parameter' },
{ status: 400 }
);
return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 });
}
const chapterIndex = parseInt(chapterIndexStr, 10);
if (isNaN(chapterIndex)) {
return NextResponse.json(
{ error: 'Invalid chapterIndex parameter' },
{ status: 400 }
);
const chapterIndex = Number.parseInt(chapterIndexStr, 10);
if (!Number.isInteger(chapterIndex) || chapterIndex < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 });
}
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const storageUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!storageUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
await db
.delete(audiobookChapters)
.where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, storageUserId),
eq(audiobookChapters.chapterIndex, chapterIndex),
),
);
const objectNames = (await listAudiobookObjects(bookId, storageUserId, testNamespace)).map((object) => object.fileName);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
const files = await readdir(intermediateDir).catch(() => []);
for (const file of files) {
if (!file.startsWith(chapterPrefix)) continue;
if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue;
await unlink(join(intermediateDir, file)).catch(() => {});
for (const fileName of objectNames) {
if (!fileName.startsWith(chapterPrefix)) continue;
if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue;
await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {});
}
// Invalidate any combined "complete" files
const completeM4b = join(intermediateDir, `complete.m4b`);
const completeMp3 = join(intermediateDir, `complete.mp3`);
if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {});
if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {});
await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {});
await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting chapter:', error);
return NextResponse.json(
{ error: 'Failed to delete chapter' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 });
}
}

View file

@ -1,101 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises';
import { existsSync, createReadStream } from 'fs';
import { basename, join, resolve } from 'path';
import { randomUUID } from 'crypto';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import {
audiobookPrefix,
deleteAudiobookObject,
deleteAudiobookPrefix,
getAudiobookObjectBuffer,
listAudiobookObjects,
putAudiobookObject,
} from '@/lib/server/audiobooks/blobstore';
import {
decodeChapterFileName,
escapeFFMetadata,
ffprobeAudio,
} from '@/lib/server/audiobooks/chapters';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import type { TTSAudiobookFormat } from '@/types/tts';
export const dynamic = 'force-dynamic';
function getAudiobooksRootDir(request: NextRequest): string {
const raw = request.headers.get('x-openreader-test-namespace')?.trim();
if (!raw) return AUDIOBOOKS_V1_DIR;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe || safe === '.' || safe === '..' || safe.includes('..')) {
return AUDIOBOOKS_V1_DIR;
}
const resolved = resolve(AUDIOBOOKS_V1_DIR, safe);
if (!resolved.startsWith(resolve(AUDIOBOOKS_V1_DIR) + '/')) {
return AUDIOBOOKS_V1_DIR;
}
return resolved;
type ChapterObject = {
index: number;
title: string;
format: TTSAudiobookFormat;
fileName: string;
};
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
function isSafeId(value: string): boolean {
return SAFE_ID_REGEX.test(value);
}
interface ConversionRequest {
chapterTitle: string;
buffer: TTSAudioBytes;
bookId?: string;
format?: TTSAudiobookFormat;
chapterIndex?: number;
settings?: AudiobookGenerationSettings;
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Audiobooks storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> {
return new Promise((resolve, reject) => {
const ffprobe = spawn('ffprobe', [
'-i', filePath,
'-show_entries', 'format=duration',
'-v', 'quiet',
'-of', 'csv=p=0'
]);
function chapterFileMimeType(format: TTSAudiobookFormat): string {
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
}
let output = '';
let finished = false;
function listChapterObjects(objectNames: string[]): ChapterObject[] {
const chapters = objectNames
.filter((name) => !name.startsWith('complete.'))
.map((fileName) => {
const decoded = decodeChapterFileName(fileName);
if (!decoded) return null;
return {
index: decoded.index,
title: decoded.title,
format: decoded.format,
fileName,
} satisfies ChapterObject;
})
.filter((value): value is ChapterObject => Boolean(value))
.sort((a, b) => a.index - b.index);
const onAbort = () => {
if (finished) return;
finished = true;
try {
ffprobe.kill('SIGKILL');
} catch {}
reject(new Error('ABORTED'));
};
const cleanup = () => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
const deduped = new Map<number, ChapterObject>();
for (const chapter of chapters) {
const existing = deduped.get(chapter.index);
if (!existing) {
deduped.set(chapter.index, chapter);
continue;
}
if (chapter.fileName > existing.fileName) {
deduped.set(chapter.index, chapter);
}
}
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
return Array.from(deduped.values()).sort((a, b) => a.index - b.index);
}
ffprobe.on('close', (code) => {
if (finished) return;
cleanup();
if (code === 0) {
const duration = parseFloat(output.trim());
resolve(duration);
} else {
reject(new Error(`ffprobe process exited with code ${code}`));
}
});
ffprobe.on('error', (err) => {
if (finished) return;
cleanup();
reject(err);
});
function streamBuffer(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', args);
const ffmpeg = spawn(getFFmpegPath(), args);
let finished = false;
const onAbort = () => {
@ -139,212 +138,11 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
});
}
function buildAtempoFilter(speed: number): string {
const clamped = Math.max(0.5, Math.min(speed, 3));
// atempo supports 0.5..2.0 per filter; chain for >2.0
if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`;
const second = clamped / 2;
return `atempo=2.0,atempo=${second.toFixed(3)}`;
}
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
function isSafeId(value: string): boolean {
return SAFE_ID_REGEX.test(value);
}
export async function POST(request: NextRequest) {
try {
// Parse the request body
const data: ConversionRequest = await request.json();
const requestedFormat = data.format || 'm4b';
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
// Generate or use existing book ID
const bookId = data.bookId || randomUUID();
if (!isSafeId(bookId)) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
// Create intermediate directory
await mkdir(intermediateDir, { recursive: true });
const existingChapters = await listStoredChapters(intermediateDir, request.signal);
const hasChapters = existingChapters.length > 0;
const metaPath = join(intermediateDir, 'audiobook.meta.json');
const incomingSettings = data.settings;
let existingSettings: AudiobookGenerationSettings | null = null;
try {
existingSettings = JSON.parse(await readFile(metaPath, 'utf8')) as AudiobookGenerationSettings;
} catch {
existingSettings = null;
}
// Only enforce mismatch check if we already have generated chapters.
// If no chapters exist, we can overwrite/ignore "existing" settings (which might be stale or partial).
if (existingSettings && hasChapters) {
if (incomingSettings) {
const mismatch =
existingSettings.ttsProvider !== incomingSettings.ttsProvider ||
existingSettings.ttsModel !== incomingSettings.ttsModel ||
existingSettings.voice !== incomingSettings.voice ||
existingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
existingSettings.postSpeed !== incomingSettings.postSpeed ||
existingSettings.format !== incomingSettings.format;
if (mismatch) {
return NextResponse.json(
{ error: 'Audiobook settings mismatch', settings: existingSettings },
{ status: 409 },
);
}
}
}
// Note: We deliberately do NOT write the meta file here yet.
// We wait until a chapter is successfully generated/saved below.
const existingFormats = new Set(existingChapters.map((c) => c.format));
if (existingFormats.size > 1) {
return NextResponse.json(
{ error: 'Mixed chapter formats detected; reset the audiobook to continue' },
{ status: 400 },
);
}
const format: TTSAudiobookFormat =
(existingFormats.values().next().value as TTSAudiobookFormat | undefined) ??
existingSettings?.format ??
incomingSettings?.format ??
requestedFormat;
const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1;
const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1;
// Use provided chapter index or find the next available index robustly (handles gaps)
// Use provided chapter index or find the next available index robustly (handles gaps)
let chapterIndex: number;
if (data.chapterIndex !== undefined) {
const normalized = Number(data.chapterIndex);
if (!Number.isInteger(normalized) || normalized < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 });
}
chapterIndex = normalized;
} else {
const indices = existingChapters.map((c) => c.index);
// Find smallest non-negative integer not present
let next = 0;
for (const idx of indices) {
if (idx === next) {
next++;
} else if (idx > next) {
break;
}
}
chapterIndex = next;
}
// Write input file (MP3 from TTS)
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`);
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
// Write the chapter audio to a temp file
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
// We intentionally do not delete the existing chapter file up-front. This avoids a long
// window where the chapter is "missing" while ffmpeg is running (which can lead to
// partial/stale "complete.*" downloads). We clean up duplicates and invalidate the
// combined output only after the new chapter is written successfully.
if (format === 'mp3') {
// For MP3, re-encode to ensure proper headers and consistent format
await runFFmpeg([
'-y', // Overwrite output file without asking
'-i', inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a', 'libmp3lame',
'-b:a', '64k',
'-metadata', `title=${titleTag}`,
chapterOutputTempPath
], request.signal);
} else {
// Convert MP3 to M4B container with proper encoding and metadata
await runFFmpeg([
'-y', // Overwrite output file without asking
'-i', inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a', 'aac',
'-b:a', '64k',
'-metadata', `title=${titleTag}`,
'-f', 'mp4',
chapterOutputTempPath
], request.signal);
}
const probe = await ffprobeAudio(chapterOutputTempPath, request.signal);
const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal));
const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format));
await unlink(finalChapterPath).catch(() => {});
await rename(chapterOutputTempPath, finalChapterPath);
// Remove any existing chapter files for this index (e.g., if the title changed and the
// filename changed) and invalidate the combined output now that the chapter is updated.
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
const finalChapterName = basename(finalChapterPath);
const existingFiles = await readdir(intermediateDir).catch(() => []);
for (const file of existingFiles) {
if (!file.startsWith(chapterPrefix)) continue;
if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue;
if (file === finalChapterName) continue;
await unlink(join(intermediateDir, file)).catch(() => {});
}
await unlink(join(intermediateDir, 'complete.mp3')).catch(() => {});
await unlink(join(intermediateDir, 'complete.m4b')).catch(() => {});
await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {});
await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {});
// Ensure meta exists after first successful chapter.
if (!existingSettings && incomingSettings) {
await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => {});
}
// Clean up input file
await unlink(inputPath).catch(console.error);
return NextResponse.json({
index: chapterIndex,
title: data.chapterTitle,
duration,
status: 'completed' as const,
bookId,
format
});
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json(
{ error: 'cancelled' },
{ status: 499 }
);
}
console.error('Error processing audio chapter:', error);
return NextResponse.json(
{ error: 'Failed to process audio chapter' },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
let workDir: string | null = null;
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const bookId = request.nextUrl.searchParams.get('bookId');
const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null;
if (!bookId) {
@ -354,191 +152,181 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (!existsSync(intermediateDir)) {
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const existingBookUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBookUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
const stored = await listStoredChapters(intermediateDir, request.signal);
const chapters = stored.map((chapter) => ({
title: chapter.title,
duration: chapter.durationSec ?? 0,
index: chapter.index,
format: chapter.format,
filePath: chapter.filePath,
}));
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
const objectNames = objects.map((item) => item.fileName);
const chapters = listChapterObjects(objectNames);
if (chapters.length === 0) {
return NextResponse.json({ error: 'No chapters found' }, { status: 404 });
}
const chapterFormats = new Set(chapters.map((chapter) => chapter.format));
if (chapterFormats.size > 1) {
return NextResponse.json(
{ error: 'Mixed chapter formats detected; reset the audiobook to continue' },
{ status: 400 },
);
return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 });
}
// Sort chapters by index
chapters.sort((a, b) => a.index - b.index);
const format: TTSAudiobookFormat = requestedFormat ?? (chapters[0]?.format as TTSAudiobookFormat) ?? 'm4b';
const outputPath = join(intermediateDir, `complete.${format}`);
const manifestPath = join(intermediateDir, `complete.${format}.manifest.json`);
const metadataPath = join(intermediateDir, 'metadata.txt');
const listPath = join(intermediateDir, 'list.txt');
const format: TTSAudiobookFormat = requestedFormat ?? chapters[0].format;
const completeName = `complete.${format}`;
const manifestName = `${completeName}.manifest.json`;
const signature = chapters.map((chapter) => ({ index: chapter.index, fileName: chapter.fileName }));
const signature = chapters.map((chapter) => ({
index: chapter.index,
fileName: basename(chapter.filePath),
}));
if (existsSync(outputPath)) {
let cached: typeof signature | null = null;
if (objectNames.includes(completeName) && objectNames.includes(manifestName)) {
try {
cached = JSON.parse(await readFile(manifestPath, 'utf8')) as typeof signature;
} catch {
cached = null;
}
if (cached && JSON.stringify(cached) === JSON.stringify(signature)) {
return streamFile(outputPath, format);
}
await unlink(outputPath).catch(() => {});
await unlink(manifestPath).catch(() => {});
}
// Ensure we have chapter durations for chapter markers / ordering.
for (const chapter of chapters) {
if (chapter.duration && chapter.duration > 0) continue;
try {
const probe = await ffprobeAudio(chapter.filePath, request.signal);
if (probe.durationSec && probe.durationSec > 0) {
chapter.duration = probe.durationSec;
continue;
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, manifestName, testNamespace)).toString('utf8'));
if (JSON.stringify(manifest) === JSON.stringify(signature)) {
const cached = await getAudiobookObjectBuffer(bookId, existingBookUserId, completeName, testNamespace);
return new NextResponse(streamBuffer(cached), {
headers: {
'Content-Type': chapterFileMimeType(format),
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
'Cache-Control': 'no-cache',
},
});
}
} catch {}
try {
chapter.duration = await getAudioDuration(chapter.filePath, request.signal);
} catch {
chapter.duration = 0;
// Force regeneration below.
}
await deleteAudiobookObject(bookId, existingBookUserId, completeName, testNamespace).catch(() => {});
await deleteAudiobookObject(bookId, existingBookUserId, manifestName, testNamespace).catch(() => {});
}
const chapterRows = await db
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
.from(audiobookChapters)
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
const durationByIndex = new Map<number, number>();
for (const row of chapterRows) {
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
}
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-combine-'));
const metadataPath = join(workDir, 'metadata.txt');
const listPath = join(workDir, 'list.txt');
const outputPath = join(workDir, completeName);
const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = [];
for (const chapter of chapters) {
const localPath = join(workDir, chapter.fileName);
const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
await writeFile(localPath, bytes);
let duration = durationByIndex.get(chapter.index) ?? 0;
if (!duration || duration <= 0) {
try {
const probe = await ffprobeAudio(localPath, request.signal);
if (probe.durationSec && probe.durationSec > 0) {
duration = probe.durationSec;
}
} catch {
duration = 0;
}
}
localChapters.push({
index: chapter.index,
title: chapter.title,
localPath,
duration,
});
}
// Create chapter metadata file for M4B
const metadata: string[] = [];
let currentTime = 0;
for (const chapter of chapters) {
for (const chapter of localChapters) {
const startMs = Math.floor(currentTime * 1000);
currentTime += chapter.duration;
const endMs = Math.floor(currentTime * 1000);
metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${escapeFFMetadata(chapter.title)}`);
}
await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n'));
// Create list file for concat
await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n'));
await writeFile(
listPath,
chapters.map(c => `file '${c.filePath}'`).join('\n')
localChapters
.map((chapter) => `file '${chapter.localPath.replace(/'/g, "'\\''")}'`)
.join('\n'),
);
if (format === 'mp3') {
// For MP3, re-encode to properly rebuild headers and duration metadata
// Using libmp3lame to ensure proper MP3 structure
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', listPath,
'-c:a', 'libmp3lame',
'-b:a', '64k',
outputPath
], request.signal);
await runFFmpeg(['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath], request.signal);
} else {
// Combine all files into a single M4B with chapter metadata
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', listPath,
'-i', metadataPath,
'-map_metadata', '1',
'-c:a', 'aac',
'-b:a', '64k',
'-f', 'mp4',
outputPath
], request.signal);
}
// Clean up temporary files (but keep the chapters and complete file)
await Promise.all([
unlink(metadataPath).catch(console.error),
unlink(listPath).catch(console.error)
]);
await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => {});
// Stream the file back to the client
return streamFile(outputPath, format);
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json(
{ error: 'cancelled' },
{ status: 499 }
await runFFmpeg(
[
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-i',
metadataPath,
'-map_metadata',
'1',
'-c:a',
'aac',
'-b:a',
'64k',
'-f',
'mp4',
outputPath,
],
request.signal,
);
}
console.error('Error creating M4B:', error);
return NextResponse.json(
{ error: 'Failed to create M4B file' },
{ status: 500 }
const outputBytes = await readFile(outputPath);
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
await putAudiobookObject(
bookId,
existingBookUserId,
manifestName,
Buffer.from(JSON.stringify(signature, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);
return new NextResponse(streamBuffer(outputBytes), {
headers: {
'Content-Type': chapterFileMimeType(format),
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
'Cache-Control': 'no-cache',
},
});
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json({ error: 'cancelled' }, { status: 499 });
}
console.error('Error creating full audiobook:', error);
return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 });
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
// Helper function to stream file
function streamFile(filePath: string, format: string) {
const stream = createReadStream(filePath);
const readableWebStream = new ReadableStream({
start(controller) {
stream.on('data', (chunk) => {
controller.enqueue(chunk);
});
stream.on('end', () => {
controller.close();
});
stream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
return new NextResponse(readableWebStream, {
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
'Cache-Control': 'no-cache',
},
});
}
export async function DELETE(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const bookId = request.nextUrl.searchParams.get('bookId');
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
@ -547,28 +335,36 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const storageUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
// If directory doesn't exist, consider it already reset
if (!existsSync(intermediateDir)) {
return NextResponse.json({ success: true, existed: false });
if (!storageUserId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
// Recursively delete the entire audiobook directory
await rm(intermediateDir, { recursive: true, force: true });
await db
.delete(audiobookChapters)
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId)));
return NextResponse.json({ success: true, existed: true });
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0);
return NextResponse.json({ success: true, existed: deleted > 0 });
} catch (error) {
console.error('Error resetting audiobook:', error);
return NextResponse.json(
{ error: 'Failed to reset audiobook' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 });
}
}

View file

@ -1,43 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { existsSync } from 'fs';
import { join } from 'path';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { listStoredChapters } from '@/lib/server/audiobook';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore';
import { decodeChapterFileName } from '@/lib/server/audiobooks/chapters';
import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobooks/prune';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts';
import { readFile } from 'fs/promises';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
export const dynamic = 'force-dynamic';
function getAudiobooksRootDir(request: NextRequest): string {
const raw = request.headers.get('x-openreader-test-namespace')?.trim();
if (!raw) return AUDIOBOOKS_V1_DIR;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR;
}
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
function isSafeId(value: string): boolean {
return SAFE_ID_REGEX.test(value);
}
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Audiobooks storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
type ChapterObject = {
index: number;
title: string;
format: TTSAudiobookFormat;
fileName: string;
};
function listChapterObjects(fileNames: string[]): ChapterObject[] {
const chapters = fileNames
.map((fileName) => {
const decoded = decodeChapterFileName(fileName);
if (!decoded) return null;
return {
index: decoded.index,
title: decoded.title,
format: decoded.format,
fileName,
} satisfies ChapterObject;
})
.filter((value): value is ChapterObject => Boolean(value))
.sort((a, b) => a.index - b.index);
const deduped = new Map<number, ChapterObject>();
for (const chapter of chapters) {
const current = deduped.get(chapter.index);
if (!current || chapter.fileName > current.fileName) {
deduped.set(chapter.index, chapter);
}
}
return Array.from(deduped.values()).sort((a, b) => a.index - b.index);
}
export async function GET(request: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const bookId = request.nextUrl.searchParams.get('bookId');
if (!bookId || !isSafeId(bookId)) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (!existsSync(intermediateDir)) {
const { userId, authEnabled } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
const existingBookRows = await db
.select({ userId: audiobooks.userId })
.from(audiobooks)
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
const existingBookUserId = pickAudiobookOwner(
existingBookRows.map((book: { userId: string }) => book.userId),
preferredUserId,
unclaimedUserId,
);
if (!existingBookUserId) {
return NextResponse.json({
chapters: [],
exists: false,
@ -47,38 +96,66 @@ export async function GET(request: NextRequest) {
});
}
const stored = await listStoredChapters(intermediateDir, request.signal);
const chapters: TTSAudiobookChapter[] = stored.map((chapter) => ({
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
const objectNames = objects.map((object) => object.fileName);
const chapterObjects = listChapterObjects(objectNames);
await pruneAudiobookChaptersNotOnDisk(
bookId,
existingBookUserId,
chapterObjects.map((chapter) => chapter.index),
);
const chapterRows = await db
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
.from(audiobookChapters)
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
const durationByIndex = new Map<number, number>();
for (const row of chapterRows) {
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
}
const chapters: TTSAudiobookChapter[] = chapterObjects.map((chapter) => ({
index: chapter.index,
title: chapter.title,
duration: chapter.durationSec,
duration: durationByIndex.get(chapter.index),
status: 'completed',
bookId,
format: chapter.format as TTSAudiobookFormat,
format: chapter.format,
}));
let settings: AudiobookGenerationSettings | null = null;
try {
settings = JSON.parse(await readFile(join(intermediateDir, 'audiobook.meta.json'), 'utf8')) as AudiobookGenerationSettings;
} catch {
settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
} catch (error) {
if (!isMissingBlobError(error)) throw error;
settings = null;
}
const hasComplete = existsSync(join(intermediateDir, 'complete.mp3')) || existsSync(join(intermediateDir, 'complete.m4b'));
const hasComplete = objectNames.includes('complete.mp3') || objectNames.includes('complete.m4b');
const exists = chapters.length > 0 || hasComplete || settings !== null;
return NextResponse.json({
chapters,
if (!exists) {
// Deleting the audiobook row cascades to audiobookChapters via bookFk
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId)));
return NextResponse.json({
chapters: [],
exists: false,
hasComplete: false,
bookId: null,
settings: null,
});
}
return NextResponse.json({
chapters,
exists: true,
hasComplete,
bookId,
settings,
});
} catch (error) {
console.error('Error fetching chapters:', error);
return NextResponse.json(
{ error: 'Failed to fetch chapters' },
{ status: 500 }
);
return NextResponse.json({ error: 'Failed to fetch chapters' }, { status: 500 });
}
}

View file

@ -0,0 +1,11 @@
import { auth } from "@/lib/server/auth/auth"; // path to your auth file
import { toNextJsHandler } from "better-auth/next-js";
const handlers = auth
? toNextJsHandler(auth)
: {
POST: async () => new Response("Auth disabled", { status: 404 }),
GET: async () => new Response("Auth disabled", { status: 404 })
};
export const { POST, GET } = handlers;

View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { contentTypeForName } from '@/lib/server/storage/library-mount';
import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
export const dynamic = 'force-dynamic';
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
function streamBuffer(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
export async function GET(req: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
console.info('[blob-fallback] download proxy used', { id });
const rows = (await db
.select({ id: documents.id, userId: documents.userId, name: documents.name })
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
name: string;
}>;
const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0];
if (!doc) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const filename = doc.name || `${id}.bin`;
const responseType = contentTypeForName(filename);
try {
const content = await getDocumentBlob(id, testNamespace);
return new NextResponse(streamBuffer(content), {
headers: {
'Content-Type': responseType,
'Cache-Control': 'no-store',
},
});
} catch (error) {
if (isMissingBlobError(error)) {
await db
.delete(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)));
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
throw error;
}
} catch (error) {
console.error('Error loading document content fallback:', error);
return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 });
}
}

View file

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
export const dynamic = 'force-dynamic';
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
export async function GET(req: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const rows = (await db
.select({ id: documents.id, userId: documents.userId })
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
}>;
const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0];
if (!doc) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`;
const directUrl = await presignGet(doc.id, testNamespace).catch(() => null);
if (!directUrl) {
console.warn('[blob-fallback] presign download unavailable, redirecting to proxy fallback', { id: doc.id });
return NextResponse.redirect(fallbackUrl, {
status: 307,
headers: { 'Cache-Control': 'no-store' },
});
}
return NextResponse.redirect(directUrl, {
status: 307,
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
console.error('Error creating document download signature:', error);
return NextResponse.json({ error: 'Failed to prepare document download' }, { status: 500 });
}
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore';
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const validation = await validatePreviewRequest(req);
if (validation.errorResponse) return validation.errorResponse;
const { doc, testNamespace, id } = validation;
const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`;
const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`;
const preview = await ensureDocumentPreview(
{
id: doc.id,
type: doc.type,
lastModified: Number(doc.lastModified),
},
testNamespace,
);
if (preview.state !== 'ready') {
return NextResponse.json(
{
status: preview.status,
retryAfterMs: preview.retryAfterMs,
presignUrl,
fallbackUrl,
},
{
status: 202,
headers: { 'Cache-Control': 'no-store' },
},
);
}
const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null);
return NextResponse.json(
{
status: 'ready',
presignUrl,
fallbackUrl,
...(directUrl ? { directUrl } : {}),
},
{
headers: { 'Cache-Control': 'no-store' },
},
);
} catch (error) {
console.error('Error ensuring document preview:', error);
return NextResponse.json({ error: 'Failed to ensure document preview' }, { status: 500 });
}
}

View file

@ -0,0 +1,181 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import {
getDocumentRange,
isMissingBlobError as isMissingDocumentBlobError,
isValidDocumentId,
} from '@/lib/server/documents/blobstore';
import {
getDocumentPreviewBuffer,
isMissingBlobError as isMissingPreviewBlobError,
} from '@/lib/server/documents/previews-blobstore';
import {
ensureDocumentPreview,
enqueueDocumentPreview,
isPreviewableDocumentType,
} from '@/lib/server/documents/previews';
import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
export const dynamic = 'force-dynamic';
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
function streamBuffer(buffer: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, Math.trunc(value)));
}
export async function GET(req: NextRequest) {
try {
if (!isS3Configured()) return s3NotConfiguredResponse();
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
const snippetRequested = (url.searchParams.get('snippet') || '').trim() === '1';
const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`;
const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`;
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
console.info('[blob-fallback] preview proxy used', {
id,
snippetRequested,
});
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
type: documents.type,
lastModified: documents.lastModified,
})
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
type: string;
lastModified: number;
}>;
const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0];
if (!doc) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (snippetRequested) {
const maxChars = clampInt(Number.parseInt(url.searchParams.get('maxChars') || '1600', 10), 100, 8000);
const maxBytes = clampInt(Number.parseInt(url.searchParams.get('maxBytes') || '131072', 10), 4096, 1024 * 1024);
try {
const head = await getDocumentRange(id, 0, maxBytes - 1, testNamespace);
const decoded = new TextDecoder().decode(new Uint8Array(head));
const snippet = extractRawTextSnippet(decoded, maxChars);
return NextResponse.json(
{ snippet },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (error) {
if (isMissingDocumentBlobError(error)) {
await db
.delete(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)));
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
throw error;
}
}
if (!isPreviewableDocumentType(doc.type)) {
return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 });
}
const preview = await ensureDocumentPreview(
{
id: doc.id,
type: doc.type,
lastModified: Number(doc.lastModified),
},
testNamespace,
);
if (preview.state !== 'ready') {
return NextResponse.json(
{
status: preview.status,
retryAfterMs: preview.retryAfterMs,
presignUrl,
fallbackUrl,
},
{
status: 202,
headers: { 'Cache-Control': 'no-store' },
},
);
}
try {
const content = await getDocumentPreviewBuffer(doc.id, testNamespace);
return new NextResponse(streamBuffer(content), {
headers: {
'Content-Type': preview.contentType,
'Cache-Control': 'no-store',
'Content-Length': String(content.byteLength),
},
});
} catch (error) {
if (isMissingPreviewBlobError(error)) {
await enqueueDocumentPreview(
{
id: doc.id,
type: doc.type,
lastModified: Number(doc.lastModified),
},
testNamespace,
).catch(() => {});
return NextResponse.json(
{
status: 'queued',
retryAfterMs: 1500,
presignUrl,
fallbackUrl,
},
{
status: 202,
headers: { 'Cache-Control': 'no-store' },
},
);
}
throw error;
}
} catch (error) {
console.error('Error loading document preview fallback:', error);
return NextResponse.json({ error: 'Failed to load document preview' }, { status: 500 });
}
}

View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore';
import { ensureDocumentPreview } from '@/lib/server/documents/previews';
import { validatePreviewRequest } from '../utils';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const validation = await validatePreviewRequest(req);
if (validation.errorResponse) return validation.errorResponse;
const { doc, testNamespace, id } = validation;
const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`;
const preview = await ensureDocumentPreview(
{
id: doc.id,
type: doc.type,
lastModified: Number(doc.lastModified),
},
testNamespace,
);
if (preview.state !== 'ready') {
return NextResponse.json(
{
status: preview.status,
retryAfterMs: preview.retryAfterMs,
fallbackUrl,
},
{
status: 202,
headers: { 'Cache-Control': 'no-store' },
},
);
}
const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null);
if (!directUrl) {
console.warn('[blob-fallback] presign preview unavailable, redirecting to proxy fallback', { id: doc.id });
return NextResponse.redirect(fallbackUrl, {
status: 307,
headers: { 'Cache-Control': 'no-store' },
});
}
return NextResponse.redirect(directUrl, {
status: 307,
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
console.error('Error creating document preview signature:', error);
return NextResponse.json({ error: 'Failed to prepare document preview' }, { status: 500 });
}
}

View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
import { isPreviewableDocumentType } from '@/lib/server/documents/previews';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
export function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
export type ValidatedPreviewRequest = {
doc: {
id: string;
userId: string;
type: string;
lastModified: number;
};
testNamespace: string | null;
id: string;
errorResponse?: undefined;
} | {
doc?: undefined;
testNamespace?: undefined;
id?: undefined;
errorResponse: NextResponse | Response;
};
export async function validatePreviewRequest(req: NextRequest): Promise<ValidatedPreviewRequest> {
if (!isS3Configured()) return { errorResponse: s3NotConfiguredResponse() };
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return { errorResponse: ctxOrRes };
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
// Deduplicate allowedUserIds
const allowedUserIds = Array.from(new Set(
ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]
));
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return { errorResponse: NextResponse.json({ error: 'Invalid id' }, { status: 400 }) };
}
const rows = (await db
.select({
id: documents.id,
userId: documents.userId,
type: documents.type,
lastModified: documents.lastModified,
})
.from(documents)
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{
id: string;
userId: string;
type: string;
lastModified: number;
}>;
const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0];
if (!doc) {
return { errorResponse: NextResponse.json({ error: 'Not found' }, { status: 404 }) };
}
if (!isPreviewableDocumentType(doc.type)) {
return { errorResponse: NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }) };
}
return {
doc,
testNamespace,
id
};
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobstore';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
export const dynamic = 'force-dynamic';
function isPreconditionFailed(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } };
return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed';
}
export async function PUT(req: NextRequest) {
try {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
if (!isValidDocumentId(id)) {
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 });
}
const contentType = (req.headers.get('content-type') || 'application/octet-stream').trim() || 'application/octet-stream';
const body = Buffer.from(await req.arrayBuffer());
const namespace = getOpenReaderTestNamespace(req.headers);
try {
await putDocumentBlob(id, body, contentType, namespace);
} catch (error) {
if (!isPreconditionFailed(error)) {
throw error;
}
}
console.info('[blob-fallback] upload proxy used', {
id,
contentType,
bytes: body.byteLength,
});
return NextResponse.json({ success: true, id });
} catch (error) {
console.error('Error proxy-uploading document blob:', error);
return NextResponse.json({ error: 'Failed to upload document blob' }, { status: 500 });
}
}

View file

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
export const dynamic = 'force-dynamic';
type PresignUpload = {
id: string;
contentType: string;
size: number;
};
function parseUploads(body: unknown): PresignUpload[] {
if (!body || typeof body !== 'object') return [];
const rawUploads = (body as { uploads?: unknown }).uploads;
if (!Array.isArray(rawUploads)) return [];
const uploads: PresignUpload[] = [];
for (const raw of rawUploads) {
if (!raw || typeof raw !== 'object') continue;
const rec = raw as Record<string, unknown>;
const id = typeof rec.id === 'string' ? rec.id.trim().toLowerCase() : '';
if (!isValidDocumentId(id)) continue;
const contentType =
typeof rec.contentType === 'string' && rec.contentType.trim()
? rec.contentType.trim()
: 'application/octet-stream';
const size = Number.isFinite(rec.size) && Number(rec.size) >= 0 ? Number(rec.size) : 0;
uploads.push({ id, contentType, size });
}
return uploads;
}
export async function POST(req: NextRequest) {
try {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const body = await req.json().catch(() => null);
const uploads = parseUploads(body);
if (uploads.length === 0) {
return NextResponse.json({ error: 'No valid uploads provided' }, { status: 400 });
}
const namespace = getOpenReaderTestNamespace(req.headers);
const signed = await Promise.all(
uploads.map(async (upload) => {
const res = await presignPut(upload.id, upload.contentType, namespace);
return {
id: upload.id,
url: res.url,
headers: res.headers,
};
}),
);
return NextResponse.json({ uploads: signed });
} catch (error) {
console.error('Error creating document upload signatures:', error);
return NextResponse.json({ error: 'Failed to presign uploads' }, { status: 500 });
}
}

View file

@ -1,137 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises';
import { spawn } from 'child_process';
import path from 'path';
import { existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { pathToFileURL } from 'url';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
async function ensureTempDir() {
if (!existsSync(DOCSTORE_DIR)) {
await mkdir(DOCSTORE_DIR, { recursive: true });
}
if (!existsSync(TEMP_DIR)) {
await mkdir(TEMP_DIR, { recursive: true });
}
}
async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise<void> {
return new Promise((resolve, reject) => {
const args: string[] = [];
if (profileDir) {
// Ensure a per-job profile to isolate concurrent soffice instances
// Note: mkdir is async; we prepare the directory before calling this in POST, but safe to include here too
// (we avoid awaiting here; POST ensures creation)
args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`);
}
args.push(
'--headless',
'--nologo',
'--convert-to', 'pdf',
'--outdir', outputDir,
inputPath
);
const process = spawn('soffice', args);
process.on('error', (error) => {
reject(error);
});
process.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`LibreOffice conversion failed with code ${code}`));
}
});
});
}
async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise<string> {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
const files = await readdir(dir);
const pdf = files.find(f => f.toLowerCase().endsWith('.pdf'));
if (pdf) {
const pdfPath = path.join(dir, pdf);
try {
const first = await stat(pdfPath);
await new Promise((res) => setTimeout(res, intervalMs));
const second = await stat(pdfPath);
if (second.size > 0 && second.size === first.size) {
return pdfPath;
}
} catch {
// If stat fails (transient), continue polling
}
}
await new Promise((res) => setTimeout(res, intervalMs));
}
throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`);
}
export async function POST(req: NextRequest) {
try {
await ensureTempDir();
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
if (!file.name.toLowerCase().endsWith('.docx')) {
return NextResponse.json(
{ error: 'File must be a .docx document' },
{ status: 400 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const tempId = randomUUID();
const jobDir = path.join(TEMP_DIR, tempId);
await mkdir(jobDir, { recursive: true });
const profileDir = path.join(jobDir, 'lo-profile');
await mkdir(profileDir, { recursive: true });
const inputPath = path.join(jobDir, 'input.docx');
// Write the uploaded file
await writeFile(inputPath, buffer);
try {
// Convert the file
await convertDocxToPdf(inputPath, jobDir, profileDir);
// Return the PDF file
const pdfPath = await waitForPdfReady(jobDir);
const pdfContent = await readFile(pdfPath);
// Clean up temp files
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
return new NextResponse(pdfContent, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${path.parse(file.name).name}.pdf"`
}
});
} catch (error) {
// Clean up temp files on error
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
throw error;
}
} catch (error) {
console.error('Error converting DOCX to PDF:', error);
return NextResponse.json(
{ error: 'Failed to convert document' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises';
import { spawn } from 'child_process';
import path from 'path';
import { existsSync } from 'fs';
import { randomUUID, createHash } from 'crypto';
import { pathToFileURL } from 'url';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { safeDocumentName } from '@/lib/server/documents/utils';
import { enqueueDocumentPreview } from '@/lib/server/documents/previews';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
async function ensureTempDir() {
if (!existsSync(DOCSTORE_DIR)) {
await mkdir(DOCSTORE_DIR, { recursive: true });
}
if (!existsSync(TEMP_DIR)) {
await mkdir(TEMP_DIR, { recursive: true });
}
}
async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise<void> {
return new Promise((resolve, reject) => {
const args: string[] = [];
if (profileDir) {
args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`);
}
args.push('--headless', '--nologo', '--convert-to', 'pdf', '--outdir', outputDir, inputPath);
const proc = spawn('soffice', args);
proc.on('error', (error) => reject(error));
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`LibreOffice conversion failed with code ${code}`));
});
});
}
async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise<string> {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
const files = await readdir(dir);
const pdf = files.find((f) => f.toLowerCase().endsWith('.pdf'));
if (pdf) {
const pdfPath = path.join(dir, pdf);
try {
const first = await stat(pdfPath);
await new Promise((res) => setTimeout(res, intervalMs));
const second = await stat(pdfPath);
if (second.size > 0 && second.size === first.size) {
return pdfPath;
}
} catch {
// ignore transient errors
}
}
await new Promise((res) => setTimeout(res, intervalMs));
}
throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`);
}
export async function POST(req: NextRequest) {
try {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
await ensureTempDir();
const formData = await req.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!file.name.toLowerCase().endsWith('.docx')) {
return NextResponse.json({ error: 'File must be a .docx document' }, { status: 400 });
}
const docxBytes = Buffer.from(await file.arrayBuffer());
// Keep stable IDs tied to source bytes.
const id = createHash('sha256').update(docxBytes).digest('hex');
const tempId = randomUUID();
const jobDir = path.join(TEMP_DIR, tempId);
await mkdir(jobDir, { recursive: true });
const profileDir = path.join(jobDir, 'lo-profile');
await mkdir(profileDir, { recursive: true });
const inputPath = path.join(jobDir, 'input.docx');
await writeFile(inputPath, docxBytes);
try {
await convertDocxToPdf(inputPath, jobDir, profileDir);
const pdfPath = await waitForPdfReady(jobDir);
const pdfContent = await readFile(pdfPath);
try {
await putDocumentBlob(id, pdfContent, 'application/pdf', testNamespace);
} catch (error) {
// Idempotent behavior: if blob already exists for this sha, continue.
const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } } | undefined;
const isPreconditionFailed = maybe?.$metadata?.httpStatusCode === 412 || maybe?.name === 'PreconditionFailed';
if (!isPreconditionFailed) {
throw error;
}
}
const derivedName = safeDocumentName(`${path.parse(file.name).name}.pdf`, `${id}.pdf`);
const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now();
await db
.insert(documents)
.values({
id,
userId: storageUserId,
name: derivedName,
type: 'pdf',
size: pdfContent.length,
lastModified,
filePath: id,
})
.onConflictDoUpdate({
target: [documents.id, documents.userId],
set: {
name: derivedName,
type: 'pdf',
size: pdfContent.length,
lastModified,
filePath: id,
},
});
await enqueueDocumentPreview(
{
id,
type: 'pdf',
lastModified,
},
testNamespace,
).catch((error) => {
console.error(`Failed to enqueue preview for converted DOCX ${id}:`, error);
});
return NextResponse.json({
stored: {
id,
name: derivedName,
type: 'pdf',
size: pdfContent.length,
lastModified,
},
});
} finally {
await rm(jobDir, { recursive: true, force: true }).catch(() => {});
}
} catch (error) {
console.error('Error converting/uploading DOCX:', error);
return NextResponse.json({ error: 'Failed to convert document' }, { status: 500 });
}
}

View file

@ -1,7 +1,8 @@
import { readFile, stat } from 'fs/promises';
import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { contentTypeForName, decodeLibraryId, isPathWithinRoot, parseLibraryRoots } from '@/lib/server/library';
import { contentTypeForName, decodeLibraryId, isPathWithinRoot, parseLibraryRoots } from '@/lib/server/storage/library-mount';
import { auth } from '@/lib/server/auth/auth';
export const dynamic = 'force-dynamic';
@ -57,6 +58,12 @@ function contentDispositionAttachment(filename: string): string {
}
export async function GET(req: NextRequest) {
// Auth check - require session
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const id = url.searchParams.get('id');
if (!id) {

View file

@ -2,8 +2,9 @@ import type { Dirent } from 'fs';
import { readdir, stat } from 'fs/promises';
import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { parseLibraryRoots } from '@/lib/server/library';
import { parseLibraryRoots } from '@/lib/server/storage/library-mount';
import type { DocumentType } from '@/types/documents';
import { auth } from '@/lib/server/auth/auth';
export const dynamic = 'force-dynamic';
@ -41,10 +42,10 @@ function libraryDocumentTypeFromName(name: string): DocumentType {
let cache:
| {
cacheKey: string;
cachedAt: number;
documents: LibraryDocument[];
}
cacheKey: string;
cachedAt: number;
documents: LibraryDocument[];
}
| undefined;
async function scanLibraryRoot(root: string, rootIndex: number, limit: number): Promise<LibraryDocument[]> {
@ -92,7 +93,7 @@ async function scanLibraryRoot(root: string, rootIndex: number, limit: number):
id,
name: relativePath,
size: fileStat.size,
lastModified: fileStat.mtimeMs,
lastModified: Math.floor(fileStat.mtimeMs),
type: libraryDocumentTypeFromName(relativePath),
});
}
@ -103,6 +104,17 @@ async function scanLibraryRoot(root: string, rootIndex: number, limit: number):
}
export async function GET(req: NextRequest) {
// Auth check - require session
try {
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
} catch (error) {
console.error('Error checking auth:', error);
return NextResponse.json({ error: 'Error checking auth' }, { status: 500 });
}
const url = new URL(req.url);
const refresh = url.searchParams.get('refresh') === '1';
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit') ?? '5000'), 10000));

View file

@ -1,215 +1,320 @@
import { createHash } from 'crypto';
import { readdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises';
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import { DOCUMENTS_V1_DIR, isDocumentsV1Ready } from '@/lib/server/docstore';
import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents';
import { and, count, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
import {
cleanupDocumentPreviewArtifacts,
deleteDocumentPreviewRows,
enqueueDocumentPreview,
} from '@/lib/server/documents/previews';
import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import type { BaseDocument, DocumentType } from '@/types/documents';
export const dynamic = 'force-dynamic';
const SYNC_DIR = DOCUMENTS_V1_DIR;
type RegisterDocument = {
id: string;
name: string;
type: DocumentType;
size: number;
lastModified: number;
};
async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise<void> {
if (!Number.isFinite(lastModifiedMs)) return;
const mtime = new Date(lastModifiedMs);
if (Number.isNaN(mtime.getTime())) return;
try {
await utimes(filePath, mtime, mtime);
} catch (error) {
console.warn('Failed to set document mtime:', filePath, error);
}
function s3NotConfiguredResponse(): NextResponse {
return NextResponse.json(
{ error: 'Documents storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
function toDocumentTypeFromName(name: string): DocumentType {
const ext = path.extname(name).toLowerCase();
if (ext === '.pdf') return 'pdf';
if (ext === '.epub') return 'epub';
if (ext === '.docx') return 'docx';
return 'html';
function normalizeDocumentType(rawType: unknown, safeName: string): DocumentType {
if (rawType === 'pdf' || rawType === 'epub' || rawType === 'docx' || rawType === 'html') {
return rawType;
}
return toDocumentTypeFromName(safeName);
}
function parseSyncedFileName(fileName: string): { id: string; name: string } | null {
const match = /^([a-f0-9]{64})__(.+)$/i.exec(fileName);
if (!match) return null;
try {
return { id: match[1].toLowerCase(), name: decodeURIComponent(match[2]) };
} catch {
return null;
}
function normalizeLastModified(value: unknown): number {
return Number.isFinite(value) && Number(value) > 0 ? Number(value) : Date.now();
}
async function loadSyncedDocuments(includeData: boolean, targetIds?: Set<string>): Promise<(BaseDocument | SyncedDocument)[]> {
const results: (BaseDocument | SyncedDocument)[] = [];
let files: string[] = [];
function parseDocumentPayload(body: unknown): RegisterDocument[] {
if (!body || typeof body !== 'object') return [];
const rawDocs = (body as { documents?: unknown }).documents;
if (!Array.isArray(rawDocs)) return [];
try {
files = await readdir(SYNC_DIR);
} catch {
return results;
const docs: RegisterDocument[] = [];
for (const rawDoc of rawDocs) {
if (!rawDoc || typeof rawDoc !== 'object') continue;
const rec = rawDoc as Record<string, unknown>;
const id = typeof rec.id === 'string' ? rec.id.trim().toLowerCase() : '';
if (!isValidDocumentId(id)) continue;
const fallbackName = `${id}.${typeof rec.type === 'string' ? rec.type : 'txt'}`;
const name = safeDocumentName(typeof rec.name === 'string' ? rec.name : '', fallbackName);
const type = normalizeDocumentType(rec.type, name);
const lastModified = normalizeLastModified(rec.lastModified);
const size = Number.isFinite(rec.size) && Number(rec.size) >= 0 ? Number(rec.size) : 0;
docs.push({ id, name, type, size, lastModified });
}
for (const file of files) {
const parsed = parseSyncedFileName(file);
if (!parsed) continue;
// Filter by ID if specific IDs are requested
if (targetIds && !targetIds.has(parsed.id)) continue;
const filePath = path.join(SYNC_DIR, file);
let fileStat: Awaited<ReturnType<typeof stat>>;
try {
fileStat = await stat(filePath);
} catch {
continue;
}
if (!fileStat.isFile()) continue;
const type = toDocumentTypeFromName(parsed.name);
const metadata: BaseDocument = {
id: parsed.id,
name: parsed.name,
size: fileStat.size,
lastModified: fileStat.mtimeMs,
type,
};
if (!includeData) {
results.push(metadata);
continue;
}
const content = await readFile(filePath);
results.push({
...metadata,
data: Array.from(new Uint8Array(content)),
});
}
return results;
return docs;
}
export async function POST(req: NextRequest) {
try {
if (!(await isDocumentsV1Ready())) {
return NextResponse.json(
{ error: 'Documents storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
const data = await req.json();
const documents = data.documents as SyncedDocument[];
if (!isS3Configured()) return s3NotConfiguredResponse();
let existingFiles: string[] = [];
try {
existingFiles = await readdir(SYNC_DIR);
} catch {
existingFiles = [];
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const body = await req.json().catch(() => null);
const documentsData = parseDocumentPayload(body);
if (documentsData.length === 0) {
return NextResponse.json({ error: 'No valid documents provided' }, { status: 400 });
}
const existingById = new Map<string, string>();
for (const file of existingFiles) {
const parsed = parseSyncedFileName(file);
if (!parsed) continue;
if (!existingById.has(parsed.id)) {
existingById.set(parsed.id, file);
const stored: BaseDocument[] = [];
for (const doc of documentsData) {
let headSize = doc.size;
// Retry HEAD check to handle S3 read-after-write propagation delays.
// The client uploads bytes directly to S3 via presigned URL, then
// immediately calls this endpoint. On serverless platforms the HEAD
// request may reach S3 before the PUT is visible.
const HEAD_RETRIES = 3;
const HEAD_RETRY_DELAY_MS = 500;
let headError: unknown = null;
for (let attempt = 0; attempt < HEAD_RETRIES; attempt++) {
headError = null;
try {
const head = await headDocumentBlob(doc.id, testNamespace);
if (head.contentLength > 0) headSize = head.contentLength;
break;
} catch (error) {
if (isMissingBlobError(error)) {
headError = error;
if (attempt < HEAD_RETRIES - 1) {
await new Promise((r) => setTimeout(r, HEAD_RETRY_DELAY_MS));
continue;
}
} else {
throw error;
}
}
}
}
const stored: Array<{ oldId: string; id: string; name: string }> = [];
for (const doc of documents) {
const content = Buffer.from(new Uint8Array(doc.data));
const id = createHash('sha256').update(content).digest('hex');
const baseName = path.basename(doc.name || `${id}.${doc.type}`);
const safeName = baseName.replaceAll('\u0000', '').slice(0, 240) || `${id}.${doc.type}`;
const existingFile = existingById.get(id);
const targetFileName = existingFile ?? `${id}__${encodeURIComponent(safeName)}`;
const targetPath = path.join(SYNC_DIR, targetFileName);
if (!existingFile) {
await writeFile(targetPath, content);
existingById.set(id, targetFileName);
if (headError && isMissingBlobError(headError)) {
return NextResponse.json(
{
error: `Blob missing for document ${doc.id}. Upload bytes first using /api/documents/blob/upload/presign.`,
},
{ status: 409 },
);
}
await trySetFileMtime(targetPath, doc.lastModified);
await db
.insert(documents)
.values({
id: doc.id,
userId: storageUserId,
name: doc.name,
type: doc.type,
size: headSize,
lastModified: doc.lastModified,
filePath: doc.id,
})
.onConflictDoUpdate({
target: [documents.id, documents.userId],
set: {
name: doc.name,
type: doc.type,
size: headSize,
lastModified: doc.lastModified,
filePath: doc.id,
},
});
stored.push({ oldId: doc.id, id, name: safeName });
stored.push({
id: doc.id,
name: doc.name,
type: doc.type,
size: headSize,
lastModified: doc.lastModified,
scope: storageUserId === unclaimedUserId ? 'unclaimed' : 'user',
});
await enqueueDocumentPreview(
{
id: doc.id,
type: doc.type,
lastModified: doc.lastModified,
},
testNamespace,
).catch((error) => {
console.error(`Failed to enqueue preview for document ${doc.id}:`, error);
});
}
return NextResponse.json({ success: true, stored });
} catch (error) {
console.error('Error saving documents:', error);
return NextResponse.json({ error: 'Failed to save documents' }, { status: 500 });
console.error('Error registering documents:', error);
return NextResponse.json({ error: 'Failed to register documents' }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
try {
if (!(await isDocumentsV1Ready())) {
return NextResponse.json(
{ error: 'Documents storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
if (!isS3Configured()) return s3NotConfiguredResponse();
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
const url = new URL(req.url);
const list = url.searchParams.get('list') === 'true';
const format = url.searchParams.get('format');
const idsParam = url.searchParams.get('ids');
const targetIds = idsParam
? idsParam
.split(',')
.map((id) => id.trim().toLowerCase())
.filter((id) => isValidDocumentId(id))
: null;
// If list=true, force metadata only.
// If format=metadata, force metadata only.
// Otherwise include data.
const includeData = !list && format !== 'metadata';
let targetIds: Set<string> | undefined;
if (idsParam) {
targetIds = new Set(idsParam.split(',').filter(Boolean));
if (idsParam && (!targetIds || targetIds.length === 0)) {
return NextResponse.json({ documents: [] });
}
const documents = await loadSyncedDocuments(includeData, targetIds);
const conditions = [
inArray(documents.userId, allowedUserIds),
...(targetIds && targetIds.length > 0 ? [inArray(documents.id, targetIds)] : []),
];
const rows = (await db.select().from(documents).where(and(...conditions))) as Array<{
id: string;
userId: string;
name: string;
type: string;
size: number;
lastModified: number;
filePath: string;
}>;
return NextResponse.json({ documents });
const results: BaseDocument[] = rows.map((doc) => {
const type = normalizeDocumentType(doc.type, doc.name);
return {
id: doc.id,
name: doc.name,
size: Number(doc.size),
lastModified: Number(doc.lastModified),
type,
scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user',
};
});
return NextResponse.json({ documents: results });
} catch (error) {
console.error('Error loading documents:', error);
console.error('Error loading document metadata:', error);
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
}
}
export async function DELETE() {
export async function DELETE(req: NextRequest) {
try {
if (!(await isDocumentsV1Ready())) {
if (!isS3Configured()) return s3NotConfiguredResponse();
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const url = new URL(req.url);
const idsParam = url.searchParams.get('ids');
const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim();
const wantsUnclaimed = scopeParam === 'unclaimed';
const wantsUser = scopeParam === '' || scopeParam === 'user';
if (!wantsUser && !wantsUnclaimed) {
return NextResponse.json(
{ error: 'Documents storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
{ error: "Invalid scope. Expected 'user' (default) or 'unclaimed'." },
{ status: 400 },
);
}
// Delete synced docs (new format) safely without touching unrelated docstore data.
let syncFiles: string[] = [];
try {
syncFiles = await readdir(SYNC_DIR);
} catch {
syncFiles = [];
if (ctxOrRes.authEnabled && wantsUnclaimed && ctxOrRes.user?.isAnonymous) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
for (const file of syncFiles) {
const filePath = path.join(SYNC_DIR, file);
const targetUserIds = Array.from(
new Set(
[
...(wantsUser ? [storageUserId] : []),
...(wantsUnclaimed ? [unclaimedUserId] : []),
].filter(Boolean),
),
);
if (targetUserIds.length === 0) {
return NextResponse.json({ success: true, deleted: 0 });
}
let targetIds: string[] = [];
if (idsParam) {
targetIds = idsParam
.split(',')
.map((id) => id.trim().toLowerCase())
.filter((id) => isValidDocumentId(id));
} else {
const rows = (await db
.select({ id: documents.id })
.from(documents)
.where(inArray(documents.userId, targetUserIds))) as Array<{ id: string }>;
targetIds = rows.map((row) => row.id);
}
if (targetIds.length === 0) {
return NextResponse.json({ success: true, deleted: 0 });
}
const deletedRows = (await db
.delete(documents)
.where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds)))
.returning({ id: documents.id })) as Array<{ id: string }>;
const uniqueIds = Array.from(new Set(deletedRows.map((row) => row.id)));
for (const id of uniqueIds) {
const [ref] = await db.select({ count: count() }).from(documents).where(eq(documents.id, id));
const refCount = Number(ref?.count ?? 0);
if (refCount > 0) continue;
try {
const st = await stat(filePath);
if (st.isFile()) {
await unlink(filePath);
await deleteDocumentBlob(id, testNamespace);
} catch (error) {
if (!isMissingBlobError(error)) {
console.error(`[best-effort] Failed to delete blob for document ${id}, orphaned blob may need manual cleanup:`, error);
}
} catch {
continue;
}
await cleanupDocumentPreviewArtifacts(id, testNamespace).catch((error) => {
console.error(`Failed to cleanup preview artifacts for document ${id}:`, error);
});
await deleteDocumentPreviewRows(id, testNamespace).catch((error) => {
console.error(`Failed to cleanup preview rows for document ${id}:`, error);
});
}
return NextResponse.json({ success: true });
return NextResponse.json({ success: true, deleted: deletedRows.length });
} catch (error) {
console.error('Error deleting documents:', error);
return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 });

View file

@ -1,137 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { existsSync } from 'fs';
import { mkdir, readdir, rename, rm } from 'fs/promises';
import { join } from 'path';
import {
AUDIOBOOKS_V1_DIR,
ensureAudiobooksV1Ready,
ensureDocumentsV1Ready,
isAudiobooksV1Ready,
isDocumentsV1Ready,
} from '@/lib/server/docstore';
type Mapping = { oldId: string; id: string };
function isSafeId(value: string): boolean {
return /^[a-zA-Z0-9._-]{1,128}$/.test(value);
}
async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> {
let moved = 0;
let skipped = 0;
let entries: Array<import('fs').Dirent> = [];
try {
entries = await readdir(sourceDir, { withFileTypes: true });
} catch {
return { moved, skipped };
}
for (const entry of entries) {
const sourcePath = join(sourceDir, entry.name);
const targetPath = join(targetDir, entry.name);
if (entry.isDirectory()) {
await mkdir(targetPath, { recursive: true });
const nested = await mergeDirectoryContents(sourcePath, targetPath);
moved += nested.moved;
skipped += nested.skipped;
try {
const remaining = await readdir(sourcePath);
if (remaining.length === 0) {
await rm(sourcePath);
}
} catch {}
continue;
}
if (!entry.isFile()) continue;
if (existsSync(targetPath)) {
skipped++;
continue;
}
try {
await rename(sourcePath, targetPath);
moved++;
} catch {
skipped++;
}
}
return { moved, skipped };
}
async function rekeyAudiobooksV1(mappings: Mapping[]): Promise<{ renamed: number; merged: number; skipped: number }> {
let renamed = 0;
let merged = 0;
let skipped = 0;
for (const mapping of mappings) {
if (mapping.oldId === mapping.id) continue;
const sourceDir = join(AUDIOBOOKS_V1_DIR, `${mapping.oldId}-audiobook`);
if (!existsSync(sourceDir)) continue;
const targetDir = join(AUDIOBOOKS_V1_DIR, `${mapping.id}-audiobook`);
if (!existsSync(targetDir)) {
try {
await rename(sourceDir, targetDir);
renamed++;
continue;
} catch {
// Fall through to merge.
}
}
await mkdir(targetDir, { recursive: true });
const res = await mergeDirectoryContents(sourceDir, targetDir);
if (res.moved > 0) merged++;
skipped += res.skipped;
try {
const remaining = await readdir(sourceDir);
if (remaining.length === 0) {
await rm(sourceDir);
}
} catch {}
}
return { renamed, merged, skipped };
}
export async function POST(request: NextRequest) {
try {
const raw = (await request.json().catch(() => null)) as { mappings?: Mapping[] } | null;
const mappings = (raw?.mappings ?? []).filter(
(m): m is Mapping => Boolean(m && typeof m.oldId === 'string' && typeof m.id === 'string'),
);
for (const mapping of mappings) {
if (!isSafeId(mapping.oldId) || !isSafeId(mapping.id)) {
return NextResponse.json({ error: 'Invalid document id mapping' }, { status: 400 });
}
}
const documentsMigrated = await ensureDocumentsV1Ready();
const audiobooksMigrated = await ensureAudiobooksV1Ready();
const rekey = await rekeyAudiobooksV1(mappings);
const documentsReady = await isDocumentsV1Ready();
const audiobooksReady = await isAudiobooksV1Ready();
return NextResponse.json({
success: true,
documentsReady,
audiobooksReady,
documentsMigrated,
audiobooksMigrated,
rekey,
});
} catch (error) {
console.error('Error running v1 migrations:', error);
return NextResponse.json({ error: 'Failed to run v1 migrations' }, { status: 500 });
}
}

View file

@ -0,0 +1,93 @@
import { NextResponse, type NextRequest } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
export const dynamic = 'force-dynamic';
function getUtcResetTimeMs(): number {
return nextUtcMidnightTimestampMs();
}
export async function GET(req: NextRequest) {
try {
const ttsRateLimitEnabled = isTtsRateLimitEnabled();
// If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) {
const resetTimeMs = getUtcResetTimeMs();
return NextResponse.json({
allowed: true,
currentCount: 0,
// Avoid Infinity in JSON (serializes to null). This value is never shown
// because authEnabled=false, but we keep it finite to prevent surprises.
limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
resetTimeMs,
userType: 'unauthenticated',
authEnabled: false
});
}
// Get session from auth
const session = await auth.api.getSession({
headers: await headers()
});
// No session means unauthenticated
if (!session?.user) {
const resetTimeMs = getUtcResetTimeMs();
return NextResponse.json({
allowed: true,
currentCount: 0,
limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
resetTimeMs,
userType: 'unauthenticated',
authEnabled: true
});
}
const isAnonymous = Boolean(session.user.isAnonymous);
const ip = getClientIp(req);
const device = isTtsRateLimitEnabled() ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
const result = await rateLimiter.getCurrentUsage(
{
id: session.user.id,
isAnonymous,
},
{
deviceId: device?.deviceId ?? null,
ip,
}
);
const response = NextResponse.json({
allowed: result.allowed,
currentCount: result.currentCount,
limit: result.limit,
remainingChars: result.remainingChars,
resetTimeMs: result.resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
authEnabled: true
});
if (device?.didCreate) {
setDeviceIdCookie(response, device.deviceId);
}
return response;
} catch (error) {
console.error('Error getting rate limit status:', error);
return NextResponse.json(
{ error: 'Failed to get rate limit status' },
{ status: 500 }
);
}
}

View file

@ -1,11 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import OpenAI from 'openai';
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
import { isKokoroModel } from '@/utils/voice';
import { isKokoroModel } from '@/lib/shared/kokoro';
import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
import type { TTSRequestPayload } from '@/types/client';
import type { TTSError, TTSAudioBuffer } from '@/types/tts';
import { headers } from 'next/headers';
import { auth } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) {
if (didCreate && deviceId) {
setDeviceIdCookie(response, deviceId);
}
}
type CustomVoice = string;
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
@ -31,6 +43,21 @@ type InflightEntry = {
const inflightRequests = new Map<string, InflightEntry>();
const PROBLEM_TYPES = {
dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded',
upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited',
} as const;
type ProblemDetails = {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
code?: string;
[key: string]: unknown;
};
function sleep(ms: number) {
return new Promise((res) => setTimeout(res, ms));
}
@ -47,7 +74,7 @@ async function fetchTTSBufferWithRetry(
const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2);
// Retry on 429 and 5xx only; never retry aborts
for (;;) {
for (; ;) {
try {
const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal });
return await response.arrayBuffer();
@ -96,15 +123,22 @@ function makeCacheKey(input: {
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
}
function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
if (limit >= 1_000_000) {
const m = limit / 1_000_000;
return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`;
}
if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`;
return String(limit);
}
export async function POST(req: NextRequest) {
let providerForError: string | null = null;
try {
// Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
// Parse body first to get text for rate limiting
const body = (await req.json()) as TTSRequestPayload;
const { text, voice, speed, format, model: req_model, instructions } = body;
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
if (!text || !voice || !speed) {
const errorBody: TTSError = {
@ -113,6 +147,88 @@ export async function POST(req: NextRequest) {
};
return NextResponse.json(errorBody, { status: 400 });
}
// Auth and TTS char rate limiting check (only when auth is enabled)
let didCreateDeviceIdCookie = false;
let deviceIdToSet: string | null = null;
if (isAuthEnabled() && auth) {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ code: 'UNAUTHORIZED', message: 'Authentication required' },
{ status: 401 }
);
}
const isAnonymous = Boolean(session.user.isAnonymous);
if (isTtsRateLimitEnabled()) {
const charCount = text.length;
const ip = getClientIp(req);
const device = isAnonymous ? getOrCreateDeviceId(req) : null;
if (device?.didCreate) {
didCreateDeviceIdCookie = true;
deviceIdToSet = device.deviceId;
}
// Check rate limit
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
{ id: session.user.id, isAnonymous },
charCount,
{
deviceId: device?.deviceId ?? null,
ip,
}
);
if (!rateLimitResult.allowed) {
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(
0,
Math.ceil((resetTimeMs - Date.now()) / 1000)
);
const problem: ProblemDetails = {
type: PROBLEM_TYPES.dailyQuotaExceeded,
title: 'Daily quota exceeded',
status: 429,
detail: 'Daily character limit exceeded',
code: 'USER_DAILY_QUOTA_EXCEEDED',
currentCount: rateLimitResult.currentCount,
limit: rateLimitResult.limit,
remainingChars: rateLimitResult.remainingChars,
resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
: undefined,
instance: req.nextUrl.pathname,
};
const response = new NextResponse(JSON.stringify(problem), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
'Retry-After': String(retryAfterSeconds),
},
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
}
}
// Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
providerForError = provider;
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
// Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default
const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
@ -125,10 +241,10 @@ export async function POST(req: NextRequest) {
const normalizedVoice = (
!isKokoroModel(model as string) && voice.includes('+')
? (voice.split('+')[0].trim())
: voice
? (voice.split('+')[0].trim())
: voice
) as SpeechCreateParams['voice'];
const createParams: ExtendedSpeechParams = {
model: model,
voice: normalizedVoice,
@ -165,7 +281,7 @@ export async function POST(req: NextRequest) {
const cachedBuffer = ttsAudioCache.get(cacheKey);
if (cachedBuffer) {
if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) {
return new NextResponse(null, {
const response = new NextResponse(null, {
status: 304,
headers: {
'ETag': etag,
@ -173,9 +289,13 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
console.log('TTS cache HIT for key:', cacheKey.slice(0, 8));
return new NextResponse(cachedBuffer, {
const response = new NextResponse(cachedBuffer, {
headers: {
'Content-Type': contentType,
'X-Cache': 'HIT',
@ -185,6 +305,10 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
}
// De-duplicate identical in-flight requests
@ -203,7 +327,7 @@ export async function POST(req: NextRequest) {
try {
const buffer = await existing.promise;
return new NextResponse(buffer, {
const response = new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'X-Cache': 'INFLIGHT',
@ -213,8 +337,12 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} finally {
try { req.signal.removeEventListener('abort', onAbort); } catch {}
try { req.signal.removeEventListener('abort', onAbort); } catch { }
}
}
@ -248,10 +376,10 @@ export async function POST(req: NextRequest) {
try {
buffer = await entry.promise;
} finally {
try { req.signal.removeEventListener('abort', onAbort); } catch {}
try { req.signal.removeEventListener('abort', onAbort); } catch { }
}
return new NextResponse(buffer, {
const response = new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'X-Cache': 'MISS',
@ -261,6 +389,10 @@ export async function POST(req: NextRequest) {
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
}
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;
} catch (error) {
// Check if this was an abort error
if (error instanceof Error && error.name === 'AbortError') {
@ -268,6 +400,35 @@ export async function POST(req: NextRequest) {
return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request
}
const upstreamStatus = (() => {
if (typeof error === 'object' && error !== null) {
const rec = error as Record<string, unknown>;
if (typeof rec.status === 'number') return rec.status as number;
if (typeof rec.statusCode === 'number') return rec.statusCode as number;
}
return undefined;
})();
if (upstreamStatus === 429) {
const problem: ProblemDetails = {
type: PROBLEM_TYPES.upstreamRateLimited,
title: 'Upstream rate limited',
status: 429,
detail: 'The TTS provider is rate limiting requests. Please try again shortly.',
code: 'UPSTREAM_RATE_LIMIT',
provider: providerForError ?? undefined,
upstreamStatus,
instance: req.nextUrl.pathname,
};
return new NextResponse(JSON.stringify(problem), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
},
});
}
console.warn('Error generating TTS:', error);
const errorBody: TTSError = {
code: 'TTS_GENERATION_FAILED',

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { isKokoroModel } from '@/utils/voice';
import { isKokoroModel } from '@/lib/shared/kokoro';
import { auth } from '@/lib/server/auth/auth';
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
const GPT4O_MINI_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
@ -27,7 +28,7 @@ function getDefaultVoices(provider: string, model: string): string[] {
}
return OPENAI_VOICES;
}
// For Custom OpenAI-Like provider
if (provider === 'custom-openai') {
// If using Kokoro-FastAPI (model string contains 'kokoro'), expose full Kokoro voices
@ -36,7 +37,7 @@ function getDefaultVoices(provider: string, model: string): string[] {
}
return CUSTOM_OPENAI_VOICES;
}
// For Deepinfra provider - model-specific voices
if (provider === 'deepinfra') {
if (model === 'hexgrad/Kokoro-82M') {
@ -58,7 +59,7 @@ function getDefaultVoices(provider: string, model: string): string[] {
// Default Deepinfra voices
return CUSTOM_OPENAI_VOICES;
}
// Default fallback
return OPENAI_VOICES;
}
@ -77,7 +78,7 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
}
const data = await response.json();
// Extract voice names from the response, excluding preset voices
if (data.voices && Array.isArray(data.voices)) {
return data.voices
@ -93,6 +94,12 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
export async function GET(req: NextRequest) {
try {
// Auth check - require session
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
@ -106,9 +113,9 @@ export async function GET(req: NextRequest) {
// For Deepinfra provider with specific models that need API fetching
if (provider === 'deepinfra') {
const needsApiFetch = model === 'ResembleAI/chatterbox' ||
model === 'Zyphra/Zonos-v0.1-hybrid' ||
model === 'Zyphra/Zonos-v0.1-transformer';
model === 'Zyphra/Zonos-v0.1-hybrid' ||
model === 'Zyphra/Zonos-v0.1-transformer';
if (needsApiFetch) {
const apiVoices = await fetchDeepinfraVoices(openApiKey);
// Combine default voice with fetched voices
@ -117,7 +124,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ voices: [...defaultVoice, ...apiVoices] });
}
}
// For other Deepinfra models, return static defaults
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
}
@ -141,7 +148,7 @@ export async function GET(req: NextRequest) {
} catch {
console.log('Custom endpoint does not support voices, using defaults');
}
// Fallback to default voices if API call fails
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
}
@ -154,4 +161,4 @@ export async function GET(req: NextRequest) {
const model = req.headers.get('x-tts-model') || 'tts-1';
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
}
}
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { claimAnonymousData } from '@/lib/server/user/claim-data';
import { auth } from '@/lib/server/auth/auth';
import { db } from '@/db';
import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema';
import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
const [legacyRows] = await db
.select({ count: count() })
.from(documents)
.where(ne(documents.filePath, documents.id));
if (Number(legacyRows?.count ?? 0) > 0) {
return NextResponse.json(
{ error: 'Document metadata migration is still pending. Wait for startup migrations to complete.' },
{ status: 409 },
);
}
return null;
}
async function getClaimableCounts(
unclaimedUserId: string,
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> {
const [[docCount], [bookCount], [preferencesCount], [progressCount]] =
await Promise.all([
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
]);
return {
documents: Number(docCount?.count ?? 0),
audiobooks: Number(bookCount?.count ?? 0),
preferences: Number(preferencesCount?.count ?? 0),
progress: Number(progressCount?.count ?? 0),
};
}
export async function GET(req: NextRequest) {
try {
const session = await auth?.api.getSession({ headers: req.headers });
if (!session || !session.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const readiness = await checkClaimMigrationReadiness();
if (readiness) return readiness;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const counts = await getClaimableCounts(unclaimedUserId);
return NextResponse.json({ success: true, ...counts });
} catch (error) {
console.error('Error checking claimable data:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const session = await auth?.api.getSession({ headers: req.headers });
if (!session || !session.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const readiness = await checkClaimMigrationReadiness();
if (readiness) return readiness;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const userId = session.user.id;
const result = await claimAnonymousData(userId, unclaimedUserId, testNamespace);
return NextResponse.json({
success: true,
claimed: result
});
} catch (error) {
console.error('Error claiming data:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View file

@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server';
import { PassThrough, Readable } from 'stream';
import { auth } from '@/lib/server/auth/auth';
import { db } from '@/db';
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress, userTtsChars } from '@/db/schema';
import { and, desc, eq, inArray } from 'drizzle-orm';
import archiver from 'archiver';
import { appendUserExportArchive } from '@/lib/server/user/data-export';
import { getDocumentBlobStream } from '@/lib/server/documents/blobstore';
import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { nowTimestampMs } from '@/lib/shared/timestamps';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Export storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
if (!auth) {
return NextResponse.json({ error: 'Auth not initialized' }, { status: 500 });
}
const session = await auth.api.getSession({
headers: req.headers,
});
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const [
prefs,
progress,
ttsUsage,
userDocs,
userAudiobooks,
] = await Promise.all([
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
db
.select()
.from(userDocumentProgress)
.where(eq(userDocumentProgress.userId, userId))
.orderBy(desc(userDocumentProgress.updatedAt)),
db
.select()
.from(userTtsChars)
.where(eq(userTtsChars.userId, userId))
.orderBy(desc(userTtsChars.date)),
db
.select()
.from(documents)
.where(eq(documents.userId, userId))
.orderBy(desc(documents.lastModified)),
db
.select()
.from(audiobooks)
.where(eq(audiobooks.userId, userId))
.orderBy(desc(audiobooks.createdAt)),
]);
const archive = archiver('zip', {
zlib: { level: 0 },
forceZip64: true,
});
const output = new PassThrough();
archive.pipe(output);
archive.on('warning', (warning) => {
if ((warning as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('User export warning:', warning);
}
});
archive.on('error', (error) => {
output.destroy(error);
});
const bookIds = userAudiobooks.map((book: typeof audiobooks.$inferSelect) => book.id);
const allChapters = bookIds.length > 0
? await db
.select()
.from(audiobookChapters)
.where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, bookIds)))
: [];
const onAbort = () => {
archive.abort();
output.destroy(new Error('Export request aborted'));
};
req.signal.addEventListener('abort', onAbort, { once: true });
(async () => {
try {
const exportedAtMs = nowTimestampMs();
const profileData = {
user: session.user,
session: session.session,
exportedAtMs,
};
await appendUserExportArchive({
archive,
userId,
exportedAtMs,
profileData,
preferences: prefs[0] ?? null,
readingHistory: progress,
ttsUsage,
documents: userDocs,
audiobooks: userAudiobooks,
audiobookChapters: allChapters,
getDocumentBlobStream: async (documentId: string) => getDocumentBlobStream(documentId, testNamespace),
listAudiobookObjects: async (bookId: string, ownerId: string) => listAudiobookObjects(bookId, ownerId, testNamespace),
getAudiobookObjectStream: async (bookId: string, ownerId: string, fileName: string) =>
getAudiobookObjectStream(bookId, ownerId, fileName, testNamespace),
});
if (!req.signal.aborted) {
await archive.finalize();
}
} catch (error) {
console.error('Export generation failed:', error);
archive.abort();
output.destroy(error instanceof Error ? error : new Error('Failed to generate export archive'));
} finally {
req.signal.removeEventListener('abort', onAbort);
if (req.signal.aborted) {
output.destroy(new Error('Export request aborted'));
}
}
})();
return new NextResponse(Readable.toWeb(output) as ReadableStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="openreader-data-${userId.slice(0, 8)}.zip"`,
'Cache-Control': 'no-store',
},
});
}

View file

@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { userPreferences } from '@/db/schema';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
export const dynamic = 'force-dynamic';
function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string {
if (process.env.POSTGRES_URL) return patch;
return JSON.stringify(patch);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function parseStoredPreferences(value: unknown): SyncedPreferencesPatch {
if (!value) return {};
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {};
} catch {
return {};
}
}
return isRecord(value) ? sanitizePreferencesPatch(value) : {};
}
function sanitizeSavedVoices(value: unknown): Record<string, string> {
if (!isRecord(value)) return {};
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(value)) {
if (typeof key !== 'string' || key.length === 0) continue;
if (typeof val !== 'string') continue;
out[key] = val;
}
return out;
}
function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
if (!isRecord(input)) return {};
const out: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) {
if (!(key in input)) continue;
const value = input[key];
switch (key) {
case 'viewType':
if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
break;
case 'voice':
case 'ttsProvider':
case 'ttsModel':
case 'ttsInstructions':
if (typeof value === 'string') out[key] = value;
break;
case 'voiceSpeed':
case 'audioPlayerSpeed':
case 'headerMargin':
case 'footerMargin':
case 'leftMargin':
case 'rightMargin':
if (Number.isFinite(value)) out[key] = Number(value);
break;
case 'skipBlank':
case 'epubTheme':
case 'smartSentenceSplitting':
case 'pdfHighlightEnabled':
case 'pdfWordHighlightEnabled':
case 'epubHighlightEnabled':
case 'epubWordHighlightEnabled':
if (typeof value === 'boolean') out[key] = value;
break;
case 'savedVoices':
out[key] = sanitizeSavedVoices(value);
break;
default:
break;
}
}
return out;
}
function normalizeClientUpdatedAtMs(value: unknown): number {
const normalized = coerceTimestampMs(value, nowTimestampMs());
if (normalized <= 0) return nowTimestampMs();
return normalized;
}
export async function GET(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const rows = await db
.select({
dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
})
.from(userPreferences)
.where(eq(userPreferences.userId, scope.ownerUserId))
.limit(1);
const row = rows[0];
const storedPatch = parseStoredPreferences(row?.dataJson);
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
return NextResponse.json({
preferences: storedPatch,
clientUpdatedAtMs,
hasStoredPreferences: Boolean(row),
});
} catch (error) {
console.error('Error loading user preferences:', error);
return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const scope = await resolveUserStateScope(req);
if (scope instanceof Response) return scope;
const body = (await req.json().catch(() => null)) as
| { patch?: unknown; clientUpdatedAtMs?: unknown }
| null;
const patch = sanitizePreferencesPatch(body?.patch);
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 });
}
const existingRows = await db
.select({
dataJson: userPreferences.dataJson,
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
})
.from(userPreferences)
.where(eq(userPreferences.userId, scope.ownerUserId))
.limit(1);
const existing = existingRows[0];
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
const existingPatch = parseStoredPreferences(existing?.dataJson);
if (existing && clientUpdatedAtMs < existingUpdated) {
return NextResponse.json({
preferences: existingPatch,
clientUpdatedAtMs: existingUpdated,
applied: false,
});
}
const mergedPatch = { ...existingPatch, ...patch };
const dataJson = serializePreferencesForDb(mergedPatch);
const updatedAt = nowTimestampMs();
await db
.insert(userPreferences)
.values({
userId: scope.ownerUserId,
dataJson,
clientUpdatedAtMs,
updatedAt,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson,
clientUpdatedAtMs,
updatedAt,
},
});
return NextResponse.json({
preferences: mergedPatch,
clientUpdatedAtMs,
applied: true,
});
} catch (error) {
console.error('Error updating user preferences:', error);
return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show more