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:
commit
3555561d3b
233 changed files with 37341 additions and 4107 deletions
81
.env.example
Normal file
81
.env.example
Normal 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
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -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]
|
||||
|
|
|
|||
14
.github/workflows/docker-publish.yml
vendored
14
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
37
.github/workflows/docs-check.yml
vendored
Normal 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
61
.github/workflows/docs-deploy.yml
vendored
Normal 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
|
||||
69
.github/workflows/docs-version-on-tag.yml
vendored
Normal file
69
.github/workflows/docs-version-on-tag.yml
vendored
Normal 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/**
|
||||
15
.github/workflows/playwright.yml
vendored
15
.github/workflows/playwright.yml
vendored
|
|
@ -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
6
.gitignore
vendored
|
|
@ -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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
node-linker=hoisted
|
||||
14
Dockerfile
14
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
OpenReader-WebUI
|
||||
OpenReader
|
||||
Third-Party Notices
|
||||
===================
|
||||
|
||||
|
|
|
|||
344
README.md
344
README.md
|
|
@ -1,319 +1,51 @@
|
|||
[](../../stargazers)
|
||||
[](../../network/members)
|
||||
[](../../watchers)
|
||||
[](../../issues)
|
||||
[](../../commits)
|
||||
[](../../releases)
|
||||
[](https://github.com/richardr1126/openreader/releases)
|
||||
[](LICENSE)
|
||||
[](https://docs.openreader.richardr.dev/)
|
||||
[](https://github.com/richardr1126/openreader/actions/workflows/playwright.yml)
|
||||
[](https://github.com/richardr1126/openreader/actions/workflows/docs-check.yml)
|
||||
|
||||
[](../../discussions)
|
||||
[](https://github.com/richardr1126/openreader/stargazers)
|
||||
[](https://github.com/richardr1126/openreader/network/members)
|
||||
[](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
3
docs-site/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.docusaurus
|
||||
build
|
||||
node_modules
|
||||
134
docs-site/custom.css
Normal file
134
docs-site/custom.css
Normal 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;
|
||||
}
|
||||
16
docs-site/docs/about/acknowledgements.md
Normal file
16
docs-site/docs/about/acknowledgements.md
Normal 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)
|
||||
7
docs-site/docs/about/license.md
Normal file
7
docs-site/docs/about/license.md
Normal 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)
|
||||
19
docs-site/docs/about/support-and-contributing.md
Normal file
19
docs-site/docs/about/support-and-contributing.md
Normal 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
|
||||
46
docs-site/docs/configure/auth.md
Normal file
46
docs-site/docs/configure/auth.md
Normal 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.
|
||||
39
docs-site/docs/configure/database.md
Normal file
39
docs-site/docs/configure/database.md
Normal 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).
|
||||
132
docs-site/docs/configure/migrations.md
Normal file
132
docs-site/docs/configure/migrations.md
Normal 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)
|
||||
103
docs-site/docs/configure/object-blob-storage.md
Normal file
103
docs-site/docs/configure/object-blob-storage.md
Normal 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>
|
||||
68
docs-site/docs/configure/server-library-import.md
Normal file
68
docs-site/docs/configure/server-library-import.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
33
docs-site/docs/configure/tts-provider-guides/deepinfra.md
Normal file
33
docs-site/docs/configure/tts-provider-guides/deepinfra.md
Normal 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)
|
||||
|
|
@ -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)
|
||||
33
docs-site/docs/configure/tts-provider-guides/openai.md
Normal file
33
docs-site/docs/configure/tts-provider-guides/openai.md
Normal 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)
|
||||
|
|
@ -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)
|
||||
79
docs-site/docs/configure/tts-providers.md
Normal file
79
docs-site/docs/configure/tts-providers.md
Normal 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)
|
||||
51
docs-site/docs/configure/tts-rate-limiting.md
Normal file
51
docs-site/docs/configure/tts-rate-limiting.md
Normal 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)
|
||||
138
docs-site/docs/deploy/local-development.md
Normal file
138
docs-site/docs/deploy/local-development.md
Normal 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).
|
||||
108
docs-site/docs/deploy/vercel-deployment.md
Normal file
108
docs-site/docs/deploy/vercel-deployment.md
Normal 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.
|
||||
106
docs-site/docs/docker-quick-start.md
Normal file
106
docs-site/docs/docker-quick-start.md
Normal 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.
|
||||
51
docs-site/docs/introduction.md
Normal file
51
docs-site/docs/introduction.md
Normal 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)
|
||||
395
docs-site/docs/reference/environment-variables.md
Normal file
395
docs-site/docs/reference/environment-variables.md
Normal 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
|
||||
44
docs-site/docs/reference/stack.md
Normal file
44
docs-site/docs/reference/stack.md
Normal 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
|
||||
123
docs-site/docusaurus.config.ts
Normal file
123
docs-site/docusaurus.config.ts
Normal 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
32
docs-site/package.json
Normal 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
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
67
docs-site/sidebars.ts
Normal 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
1
docs-site/static/CNAME
Normal file
|
|
@ -0,0 +1 @@
|
|||
docs.openreader.richardr.dev
|
||||
BIN
docs-site/static/favicon.ico
Normal file
BIN
docs-site/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
2
docs-site/static/robots.txt
Normal file
2
docs-site/static/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
6
docs-site/tsconfig.json
Normal file
6
docs-site/tsconfig.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
||||
19
drizzle.config.pg.ts
Normal file
19
drizzle.config.pg.ts
Normal 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
13
drizzle.config.sqlite.ts
Normal 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;
|
||||
151
drizzle/postgres/0000_black_lucky_pierre.sql
Normal file
151
drizzle/postgres/0000_black_lucky_pierre.sql
Normal 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");
|
||||
1080
drizzle/postgres/meta/0000_snapshot.json
Normal file
1080
drizzle/postgres/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
drizzle/postgres/meta/_journal.json
Normal file
13
drizzle/postgres/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1771386480954,
|
||||
"tag": "0000_black_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
82
drizzle/scripts/generate.mjs
Normal file
82
drizzle/scripts/generate.mjs
Normal 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);
|
||||
56
drizzle/scripts/migrate.mjs
Normal file
56
drizzle/scripts/migrate.mjs
Normal 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);
|
||||
151
drizzle/sqlite/0000_familiar_caretaker.sql
Normal file
151
drizzle/sqlite/0000_familiar_caretaker.sql
Normal 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`);
|
||||
1060
drizzle/sqlite/meta/0000_snapshot.json
Normal file
1060
drizzle/sqlite/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
drizzle/sqlite/meta/_journal.json
Normal file
13
drizzle/sqlite/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1771386480690,
|
||||
"tag": "0000_familiar_caretaker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
73
package.json
73
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
3634
pnpm-lock.yaml
3634
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
930
scripts/migrate-fs-v2.mjs
Normal file
930
scripts/migrate-fs-v2.mjs
Normal 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);
|
||||
});
|
||||
425
scripts/openreader-entrypoint.mjs
Normal file
425
scripts/openreader-entrypoint.mjs
Normal 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();
|
||||
33
src/app/(app)/app/page.tsx
Normal file
33
src/app/(app)/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
@ -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
64
src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
279
src/app/(app)/signin/page.tsx
Normal file
279
src/app/(app)/signin/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
246
src/app/(app)/signup/page.tsx
Normal file
246
src/app/(app)/signup/page.tsx
Normal 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
334
src/app/(public)/layout.tsx
Normal 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
458
src/app/(public)/page.tsx
Normal 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 aloud</span>
|
||||
</h1>
|
||||
<p className="landing-hero-desc">
|
||||
Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with your
|
||||
preferred OpenAI-compatible TTS provider. Your reading progress
|
||||
syncs across devices automatically.
|
||||
</p>
|
||||
<div className="landing-hero-actions">
|
||||
<Link href="/app" className="landing-btn landing-btn-accent">
|
||||
Open App
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4" /></svg>
|
||||
</Link>
|
||||
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
|
||||
<Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>
|
||||
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FEATURES ───────────────── */}
|
||||
<section className="landing-features">
|
||||
<div className="landing-feature-card landing-panel">
|
||||
<div className="landing-feature-icon" aria-hidden="true">↑</div>
|
||||
<h3>Upload documents</h3>
|
||||
<p>
|
||||
Drag and drop EPUB, PDF, TXT, Markdown, or DOCX files directly
|
||||
into the app. Documents process instantly for reading and
|
||||
text-to-speech playback.
|
||||
</p>
|
||||
</div>
|
||||
<div className="landing-feature-card landing-panel">
|
||||
<div className="landing-feature-icon" aria-hidden="true">¶</div>
|
||||
<h3>Your library</h3>
|
||||
<p>
|
||||
Build a personal library with folders. Documents sync
|
||||
automatically so your collection is always within
|
||||
reach.
|
||||
</p>
|
||||
</div>
|
||||
<div className="landing-feature-card landing-panel">
|
||||
<div className="landing-feature-icon" aria-hidden="true">↔</div>
|
||||
<h3>Cross-device sync</h3>
|
||||
<p>
|
||||
Reading progress, preferences, and library state sync across
|
||||
devices. Pick up exactly where you left off on any browser.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── TTS SPOTLIGHT ──────────── */}
|
||||
<section className="landing-tts">
|
||||
<div className="landing-tts-inner landing-panel">
|
||||
<div className="landing-tts-lead">
|
||||
<h2>
|
||||
<span>Text-to-speech</span> that follows along as you read
|
||||
</h2>
|
||||
<p>
|
||||
OpenReader highlights every word as it’s spoken, turning
|
||||
any document into a synchronized read-along experience. Connect
|
||||
any OpenAI-compatible TTS provider — including Kokoro,
|
||||
Deepinfra, or your own self-hosted endpoint.
|
||||
</p>
|
||||
</div>
|
||||
<ul className="landing-tts-list">
|
||||
<li>
|
||||
<span className="landing-tts-list-icon" aria-hidden="true">•</span>
|
||||
<div>
|
||||
<h4>Word-level highlighting</h4>
|
||||
<p>Each word lights up in sync with the audio so you never lose your place.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="landing-tts-list-icon" aria-hidden="true">•</span>
|
||||
<div>
|
||||
<h4>Multiple voices & providers</h4>
|
||||
<p>Choose from dozens of voices across OpenAI, Kokoro, Deepinfra, or any compatible endpoint.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="landing-tts-list-icon" aria-hidden="true">•</span>
|
||||
<div>
|
||||
<h4>Speed controls</h4>
|
||||
<p>Independent model speed and playback speed sliders from 0.5x to 3x.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="landing-tts-list-icon" aria-hidden="true">•</span>
|
||||
<div>
|
||||
<h4>Audiobook export</h4>
|
||||
<p>Convert any document to a downloadable MP3 or M4A audiobook with chapter metadata.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FORMATS ────────────────── */}
|
||||
<section className="landing-formats">
|
||||
<p className="landing-formats-label">Supported formats</p>
|
||||
<div className="landing-formats-row">
|
||||
<span className="landing-format-pill">EPUB</span>
|
||||
<span className="landing-format-pill">PDF</span>
|
||||
<span className="landing-format-pill">TXT</span>
|
||||
<span className="landing-format-pill">MD</span>
|
||||
<span className="landing-format-pill">DOCX</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── CTA ────────────────────── */}
|
||||
<section className="landing-cta">
|
||||
<div className="landing-cta-card landing-panel">
|
||||
<div className="landing-cta-glow" aria-hidden="true" />
|
||||
<h2>Start reading now</h2>
|
||||
<p>
|
||||
Open the app and upload a document to begin.
|
||||
Your progress syncs across devices automatically.
|
||||
</p>
|
||||
<div className="landing-cta-actions">
|
||||
<Link href="/app" className="landing-btn landing-btn-accent">Open App</Link>
|
||||
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
|
||||
<Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>
|
||||
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
303
src/app/(public)/privacy/page.tsx
Normal file
303
src/app/(public)/privacy/page.tsx
Normal 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 & <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 ("<strong>Personal Information</strong>").
|
||||
</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 & 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 "share" (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 & 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 & 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 "Delete Account" 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 "sharing" 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 "Export My Data" button in Settings to download your account metadata plus object-storage-backed document and audiobook files.</li>
|
||||
<li><strong>Delete Data:</strong> Use the "Delete Account" 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/app/api/account/delete/route.ts
Normal file
36
src/app/api/account/delete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/app/api/auth/[...all]/route.ts
Normal file
11
src/app/api/auth/[...all]/route.ts
Normal 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;
|
||||
87
src/app/api/documents/blob/get/fallback/route.ts
Normal file
87
src/app/api/documents/blob/get/fallback/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
68
src/app/api/documents/blob/get/presign/route.ts
Normal file
68
src/app/api/documents/blob/get/presign/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
56
src/app/api/documents/blob/preview/ensure/route.ts
Normal file
56
src/app/api/documents/blob/preview/ensure/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
181
src/app/api/documents/blob/preview/fallback/route.ts
Normal file
181
src/app/api/documents/blob/preview/fallback/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/documents/blob/preview/presign/route.ts
Normal file
55
src/app/api/documents/blob/preview/presign/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
87
src/app/api/documents/blob/preview/utils.ts
Normal file
87
src/app/api/documents/blob/preview/utils.ts
Normal 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
|
||||
};
|
||||
}
|
||||
56
src/app/api/documents/blob/upload/fallback/route.ts
Normal file
56
src/app/api/documents/blob/upload/fallback/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
72
src/app/api/documents/blob/upload/presign/route.ts
Normal file
72
src/app/api/documents/blob/upload/presign/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
178
src/app/api/documents/docx-to-pdf/upload/route.ts
Normal file
178
src/app/api/documents/docx-to-pdf/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
93
src/app/api/rate-limit/status/route.ts
Normal file
93
src/app/api/rate-limit/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
src/app/api/user/claim/route.ts
Normal file
89
src/app/api/user/claim/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
150
src/app/api/user/export/route.ts
Normal file
150
src/app/api/user/export/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
191
src/app/api/user/state/preferences/route.ts
Normal file
191
src/app/api/user/state/preferences/route.ts
Normal 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
Loading…
Reference in a new issue