Merge pull request #94 from richardr1126/refactor/clean-env
refactor(v4): land JSON runtime seeder and remove no-auth mode
This commit is contained in:
commit
da1cb1e2be
82 changed files with 1532 additions and 1372 deletions
81
.env.example
81
.env.example
|
|
@ -11,45 +11,25 @@
|
||||||
# NOTE: On first boot, the server auto-seeds these values into a "default-openai"
|
# NOTE: On first boot, the server auto-seeds these values into a "default-openai"
|
||||||
# admin-managed shared provider (DB-backed, encrypted at rest). After that, the
|
# admin-managed shared provider (DB-backed, encrypted at rest). After that, the
|
||||||
# admin UI is authoritative and changing these env vars has no effect. You may
|
# admin UI is authoritative and changing these env vars has no effect. You may
|
||||||
# remove them once the seed has run. See Settings → Admin → Shared providers.
|
# remove them once the seed has run. Auth must be configured for this seed path.
|
||||||
|
# See Settings → Admin → Shared providers.
|
||||||
API_BASE=http://localhost:8880/v1
|
API_BASE=http://localhost:8880/v1
|
||||||
API_KEY=api_key_optional
|
API_KEY=api_key_optional
|
||||||
|
|
||||||
# (Optional) TTS request/cache tuning (defaults shown below; leave unset to use defaults)
|
# Auth configuration (required in v4+)
|
||||||
# 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
|
|
||||||
# TTS_UPSTREAM_TIMEOUT_MS=285000 # 285 seconds
|
|
||||||
|
|
||||||
# Auth configuration (recommended; required for admin features)
|
|
||||||
# (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)
|
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_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)
|
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`)
|
# (Optional) Allow anonymous auth sessions (default: `false`)
|
||||||
# USE_ANONYMOUS_AUTH_SESSIONS=false
|
# USE_ANONYMOUS_AUTH_SESSIONS=false
|
||||||
# (Optional) Sign in w/ GitHub Configuration
|
# (Optional) Sign in w/ GitHub Configuration
|
||||||
# GITHUB_CLIENT_ID=
|
# GITHUB_CLIENT_ID=
|
||||||
# GITHUB_CLIENT_SECRET=
|
# GITHUB_CLIENT_SECRET=
|
||||||
# (Optional) Disable Better Auth built-in rate limiting (useful for testing; default: `false`)
|
|
||||||
# DISABLE_AUTH_RATE_LIMIT=false
|
|
||||||
|
|
||||||
# (Optional) Honor the x-openreader-test-namespace header on a production build.
|
|
||||||
# This is test/CI scaffolding and MUST stay unset on real deployments; it is set
|
|
||||||
# automatically by the Playwright web server. Non-production builds honor it
|
|
||||||
# without this flag.
|
|
||||||
# ENABLE_TEST_NAMESPACE=false
|
|
||||||
|
|
||||||
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
|
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
|
||||||
# Admins see the "Admin" tab in Settings (TTS shared providers + site features).
|
|
||||||
# Demotion is automatic: removing an email here demotes the user on next login.
|
|
||||||
# Requires auth to be enabled (AUTH_SECRET + BASE_URL).
|
|
||||||
ADMIN_EMAILS=
|
ADMIN_EMAILS=
|
||||||
|
|
||||||
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables.
|
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and auth tables.
|
||||||
# Defaults to SQLite at docstore/sqlite3.db when not set.
|
# Defaults to SQLite at docstore/sqlite3.db when not set.
|
||||||
# POSTGRES_URL=
|
# POSTGRES_URL=
|
||||||
|
|
||||||
|
|
@ -74,60 +54,37 @@ S3_BUCKET=
|
||||||
# IMPORT_LIBRARY_DIR=
|
# IMPORT_LIBRARY_DIR=
|
||||||
# IMPORT_LIBRARY_DIRS=
|
# IMPORT_LIBRARY_DIRS=
|
||||||
|
|
||||||
# Compute
|
# Compute worker configuration
|
||||||
# Embedded/local (default): leave COMPUTE_WORKER_URL empty.
|
# (Optional) Embedded compute worker (automatic startup) for local compute tasks (defaults shown below)
|
||||||
# External worker: set COMPUTE_WORKER_URL + COMPUTE_WORKER_TOKEN.
|
|
||||||
# External worker split:
|
|
||||||
# - App side (this root `.env`): set only routing/auth + shared timeout/stale overrides.
|
|
||||||
# - Worker side (`compute/worker/.env*` or worker platform env): set NATS_*, S3_*, model base URLs, and worker tuning.
|
|
||||||
# Details: docs/deploy/compute-worker
|
|
||||||
# COMPUTE_WORKER_URL=http://localhost:8081
|
|
||||||
# COMPUTE_WORKER_TOKEN=local-compute-token
|
|
||||||
# Optional embedded startup controls:
|
|
||||||
# EMBEDDED_COMPUTE_WORKER_PORT=8081
|
# EMBEDDED_COMPUTE_WORKER_PORT=8081
|
||||||
# EMBEDDED_NATS_PORT=4222
|
# EMBEDDED_NATS_PORT=4222
|
||||||
# EMBEDDED_NATS_MONITOR_PORT=8222
|
# EMBEDDED_NATS_MONITOR_PORT=8222
|
||||||
# EMBEDDED_NATS_STORE_DIR=docstore/nats/jetstream
|
# EMBEDDED_NATS_STORE_DIR=docstore/nats/jetstream
|
||||||
# `NATS_URL` here is for embedded startup only. In external worker mode, set `NATS_URL` on the worker service env.
|
|
||||||
# NATS_URL=nats://127.0.0.1:4222
|
# NATS_URL=nats://127.0.0.1:4222
|
||||||
# Optional worker log level:
|
|
||||||
# COMPUTE_LOG_LEVEL=info
|
# COMPUTE_LOG_LEVEL=info
|
||||||
# Optional shared compute tuning:
|
|
||||||
# COMPUTE_JOB_CONCURRENCY=1
|
# COMPUTE_JOB_CONCURRENCY=1
|
||||||
# COMPUTE_WHISPER_TIMEOUT_MS=30000
|
# COMPUTE_WHISPER_TIMEOUT_MS=30000
|
||||||
# COMPUTE_PDF_TIMEOUT_MS=300000
|
# COMPUTE_PDF_TIMEOUT_MS=300000
|
||||||
# COMPUTE_OP_STALE_MS=1800000
|
# COMPUTE_OP_STALE_MS=1800000
|
||||||
|
|
||||||
# Optional Whisper ONNX base URL override (must contain all expected files)
|
|
||||||
# Default expected Whisper variant is q4:
|
|
||||||
# - onnx/encoder_model_q4.onnx
|
|
||||||
# - onnx/decoder_model_merged_q4.onnx
|
|
||||||
# - onnx/decoder_with_past_model_q4.onnx
|
|
||||||
# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main
|
# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main
|
||||||
|
|
||||||
# Optional PDF layout ONNX base URL override (must contain all expected files)
|
|
||||||
# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main
|
# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main
|
||||||
|
# (Optional) External compute worker config (see docs/deploy/compute-worker)
|
||||||
|
# COMPUTE_WORKER_URL=http://localhost:8081
|
||||||
|
# COMPUTE_WORKER_TOKEN=local-compute-token
|
||||||
|
|
||||||
# (Optional) Override ffmpeg binary path used for audiobook processing
|
# (Optional) Override ffmpeg binary path used for audio processing
|
||||||
# FFMPEG_BIN=
|
# FFMPEG_BIN=
|
||||||
|
|
||||||
# (Optional) Client feature flags — seeded into the admin-managed runtime
|
# (Optional) Test/dev overrides
|
||||||
# config on first boot, then ignored. Edit values from Settings → Admin →
|
# DISABLE_AUTH_RATE_LIMIT=false
|
||||||
# Site features instead of redeploying. SSR-injected so they take effect
|
# ENABLE_TEST_NAMESPACE=false
|
||||||
# without rebuilding (unlike the old build-time public-env pattern).
|
|
||||||
# RUNTIME_SEED_ENABLE_DOCX_CONVERSION=true
|
|
||||||
# RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true
|
|
||||||
# RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB=true
|
|
||||||
# RUNTIME_SEED_ENABLE_USER_SIGNUPS=true
|
|
||||||
# RUNTIME_SEED_RESTRICT_USER_API_KEYS=true
|
|
||||||
# RUNTIME_SEED_DEFAULT_TTS_PROVIDER=custom-openai
|
|
||||||
# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
|
|
||||||
# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true
|
|
||||||
# RUNTIME_SEED_DISABLE_TTS_LIMIT=true
|
|
||||||
# RUNTIME_SEED_DISABLE_COMPUTE_LIMIT=true
|
|
||||||
|
|
||||||
# Migrations configuration
|
|
||||||
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
|
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
|
||||||
# RUN_DRIZZLE_MIGRATIONS=true
|
# RUN_DRIZZLE_MIGRATIONS=true
|
||||||
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
|
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
|
||||||
# RUN_FS_MIGRATIONS=true
|
# RUN_FS_MIGRATIONS=true
|
||||||
|
|
||||||
|
# (Optional) v4 JSON seed for first-boot runtime config + shared providers.
|
||||||
|
# If both are set, RUNTIME_SEED_JSON_PATH is used.
|
||||||
|
# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json
|
||||||
|
# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"enableDestructiveDeleteActions":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]}
|
||||||
|
|
|
||||||
3
.github/workflows/playwright.yml
vendored
3
.github/workflows/playwright.yml
vendored
|
|
@ -11,7 +11,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
USE_EMBEDDED_WEED_MINI: true
|
USE_EMBEDDED_WEED_MINI: true
|
||||||
|
ENABLE_TEST_NAMESPACE: true
|
||||||
BASE_URL: http://127.0.0.1:3003
|
BASE_URL: http://127.0.0.1:3003
|
||||||
|
AUTH_SECRET: ci-auth-secret-change-me
|
||||||
|
USE_ANONYMOUS_AUTH_SESSIONS: true
|
||||||
S3_ENDPOINT: http://127.0.0.1:8333
|
S3_ENDPOINT: http://127.0.0.1:8333
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader
|
||||||
- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra).
|
- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra).
|
||||||
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.
|
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.
|
||||||
- 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync.
|
- 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync.
|
||||||
- 🐳 **Self-host friendly** — Docker (amd64/arm64), optional auth, and automatic startup migrations.
|
- 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations.
|
||||||
|
|
||||||
## 🚀 Start Here
|
## 🚀 Start Here
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,9 @@ Word-by-word highlighting and PDF layout parsing capability are controlled by co
|
||||||
|
|
||||||
Each row shows a source badge:
|
Each row shows a source badge:
|
||||||
|
|
||||||
- **from env** — the value was migrated from the corresponding `RUNTIME_SEED_*` env var on first boot. Editing it in the UI flips the source to **admin**.
|
- **from seed** — the value was seeded on first boot (from `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`).
|
||||||
- **admin** — explicit admin override. Use **Reset** on the row to clear it back to the env-default state.
|
- **admin** — explicit admin override. Use **Reset** on the row to clear it back to built-in default behavior.
|
||||||
- **default** — neither env nor admin set; uses the built-in default.
|
- **default** — no seed/admin row exists; built-in default is active.
|
||||||
|
|
||||||
:::warning Security note for `restrictUserApiKeys`
|
:::warning Security note for `restrictUserApiKeys`
|
||||||
Turning `restrictUserApiKeys` off allows user-supplied API keys to flow through this server. Use this only for trusted/self-hosted deployments where that tradeoff is acceptable.
|
Turning `restrictUserApiKeys` off allows user-supplied API keys to flow through this server. Use this only for trusted/self-hosted deployments where that tradeoff is acceptable.
|
||||||
|
|
@ -105,18 +105,31 @@ The **Disable TTS daily rate limiting** and **Disable PDF parsing rate limiting*
|
||||||
- TTS: anonymous/authenticated per-user daily limits and anonymous/authenticated IP daily backstops.
|
- TTS: anonymous/authenticated per-user daily limits and anonymous/authenticated IP daily backstops.
|
||||||
- PDF parsing: burst limit + window (seconds) and sustained limit + window (seconds). The sustained window doubles as a concurrency cap.
|
- PDF parsing: burst limit + window (seconds) and sustained limit + window (seconds). The sustained window doubles as a concurrency cap.
|
||||||
|
|
||||||
|
## TTS upstream
|
||||||
|
|
||||||
|
At the end of the **Site features** tab, a dedicated **TTS upstream** group controls server-side request and cache tuning (DB-backed runtime settings, not env vars):
|
||||||
|
|
||||||
|
| Key | What it controls |
|
||||||
|
| --- | --- |
|
||||||
|
| `ttsUpstreamMaxRetries` | Maximum retry attempts for upstream TTS 429/5xx responses. |
|
||||||
|
| `ttsUpstreamTimeoutMs` | Upstream request timeout for OpenAI-compatible TTS calls. |
|
||||||
|
| `ttsCacheMaxSizeBytes` | Maximum size of the in-memory TTS audio cache. |
|
||||||
|
| `ttsCacheTtlMs` | Time-to-live for cached TTS audio buffers. |
|
||||||
|
|
||||||
|
In v4 these settings are admin-only and are no longer configurable through environment variables.
|
||||||
|
|
||||||
## Migrating off env vars
|
## Migrating off env vars
|
||||||
|
|
||||||
The future-direction goal is to remove `RUNTIME_SEED_*` / `API_KEY` / `API_BASE` from your `.env` entirely. To do that safely:
|
In v4, runtime site features are managed by admin settings and optional JSON seed. To minimize env surface area:
|
||||||
|
|
||||||
1. Deploy this version with your existing env values in place.
|
1. Deploy this version with your existing env values in place.
|
||||||
2. Boot the app once. Open Settings → Admin and verify:
|
2. Boot the app once. Open Settings → Admin and verify:
|
||||||
- Each `RUNTIME_SEED_*` setting appears as **from env**.
|
- Seeded settings appear as **from seed** (if you supplied a runtime JSON seed).
|
||||||
- A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set).
|
- A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set).
|
||||||
3. Remove the env vars from your `.env`.
|
3. Remove any bootstrap env vars you no longer need from `.env`.
|
||||||
4. Redeploy. Behavior is unchanged — the DB is now the source of truth.
|
4. Redeploy. Behavior is unchanged — the DB is now the source of truth.
|
||||||
|
|
||||||
You can keep the env vars indefinitely if you prefer; they're only read on the first boot when the corresponding DB row is absent, so there's no harm in leaving them around.
|
You can keep `API_BASE` / `API_KEY` if you intentionally want bootstrap fallback behavior on empty provider tables.
|
||||||
|
|
||||||
## How keys are protected
|
## How keys are protected
|
||||||
|
|
||||||
|
|
@ -133,4 +146,4 @@ Because the encryption key for `admin_providers` is derived from `AUTH_SECRET`,
|
||||||
|
|
||||||
- [Auth](./auth) — required to use the admin panel.
|
- [Auth](./auth) — required to use the admin panel.
|
||||||
- [TTS Providers](./tts-providers) — built-in provider catalog and per-user behavior.
|
- [TTS Providers](./tts-providers) — built-in provider catalog and per-user behavior.
|
||||||
- [Environment Variables](../reference/environment-variables) — `ADMIN_EMAILS` and the legacy flags that the admin UI replaces.
|
- [Environment Variables](../reference/environment-variables) — `ADMIN_EMAILS`, provider bootstrap vars, and runtime JSON seed.
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,21 @@ This page covers application-level configuration for provider access and authent
|
||||||
|
|
||||||
## Auth behavior
|
## Auth behavior
|
||||||
|
|
||||||
- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set.
|
- `BASE_URL` and `AUTH_SECRET` are required at startup in v4+.
|
||||||
- Remove either value to disable auth.
|
|
||||||
- Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`.
|
- Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`.
|
||||||
- Anonymous auth sessions are disabled by default.
|
- Anonymous auth sessions are disabled by default.
|
||||||
- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows.
|
- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows.
|
||||||
|
|
||||||
## Runtime modes
|
## Runtime modes
|
||||||
|
|
||||||
OpenReader effectively has three common runtime modes:
|
OpenReader has two common runtime modes:
|
||||||
|
|
||||||
- **Auth disabled** (`BASE_URL` or `AUTH_SECRET` unset): no admin panel. Shared providers can still exist via first-boot seeding (`API_KEY`/`API_BASE`), but you cannot manage them in-app.
|
|
||||||
- **Auth enabled, non-admin user**: user account/session features are available, but no admin controls.
|
- **Auth enabled, non-admin user**: user account/session features are available, but no admin controls.
|
||||||
- **Auth enabled, admin user**: full **Settings → Admin** access (shared providers + site features).
|
- **Auth enabled, admin user**: full **Settings → Admin** access (shared providers + site features).
|
||||||
|
|
||||||
## Admin role
|
## Admin role
|
||||||
|
|
||||||
When auth is enabled, you can designate one or more users as admins via the `ADMIN_EMAILS` env var:
|
You can designate one or more users as admins via the `ADMIN_EMAILS` env var:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
ADMIN_EMAILS=alice@example.com,bob@example.com
|
ADMIN_EMAILS=alice@example.com,bob@example.com
|
||||||
|
|
@ -39,7 +37,7 @@ Admin assignment is reconciled on every session resolution, so removing an email
|
||||||
|
|
||||||
- `/` is a public landing/onboarding page and remains indexable.
|
- `/` is a public landing/onboarding page and remains indexable.
|
||||||
- `/app` is the protected app home (document list and uploader UI).
|
- `/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`.
|
- If 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`.
|
- Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`.
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
@ -60,11 +58,7 @@ Admin assignment is reconciled on every session resolution, so removing an email
|
||||||
- Updates are not instant push-based sync; they use normal client polling/refresh behavior.
|
- 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.
|
- 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
|
## Claim modal note
|
||||||
|
|
||||||
- You may still see old anonymous settings/progress available to claim from older deployments.
|
- You may still see old anonymous settings/progress available to claim from older deployments.
|
||||||
|
- Legacy `unclaimed` data is only surfaced through the claim flow; normal authenticated routes are scoped to your current user id.
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,5 @@ For database variable behavior, see [Environment Variables](../reference/environ
|
||||||
|
|
||||||
## State sync summary
|
## State sync summary
|
||||||
|
|
||||||
- With auth enabled, settings and reading progress are stored in SQL and synced from the app.
|
- 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).
|
- Sync is currently request-based (not realtime push invalidation).
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ OpenReader routes all TTS requests through the Next.js server to an OpenAI-compa
|
||||||
**Environment variables**: `API_KEY` and `API_BASE` exist as a one-shot first-boot seed that auto-creates a `default-openai` admin shared provider. After the first boot they are no longer read by the running app.
|
**Environment variables**: `API_KEY` and `API_BASE` exist as a one-shot first-boot seed that auto-creates a `default-openai` admin shared provider. After the first boot they are no longer read by the running app.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
If you're running a private/self-hosted instance and want per-user BYOK behavior, turn off **Settings → Admin → Site features → Restrict user API keys**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported for no-admin bootstrap flows.
|
If you're running a private/self-hosted instance and want per-user BYOK behavior, turn off **Settings → Admin → Site features → Restrict user API keys**. For first-boot automation, set `runtimeConfig.restrictUserApiKeys=false` in `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ This page explains OpenReader's TTS character rate limiting controls.
|
||||||
|
|
||||||
- TTS rate limiting is disabled by default.
|
- TTS rate limiting is disabled by default.
|
||||||
- Primary control is **Settings → Admin → Site features → Disable TTS daily rate limiting**.
|
- Primary control is **Settings → Admin → Site features → Disable TTS daily rate limiting**.
|
||||||
- Optional first-boot seed: `RUNTIME_SEED_DISABLE_TTS_LIMIT=true`.
|
|
||||||
- Limits are enforced per day in UTC.
|
- Limits are enforced per day in UTC.
|
||||||
- Enforcement applies only when auth is enabled.
|
- Enforcement applies only when auth is enabled.
|
||||||
|
|
||||||
|
|
@ -29,10 +28,11 @@ If a request exceeds the active limit, the TTS API returns `429` with reset meta
|
||||||
- `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling.
|
- `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling.
|
||||||
- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits.
|
- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits.
|
||||||
|
|
||||||
## Runtime config + seed var
|
## Runtime config
|
||||||
|
|
||||||
- First-boot seed toggle: `RUNTIME_SEED_DISABLE_TTS_LIMIT` (default: `true`)
|
- `disableTtsRateLimit` default: `true`
|
||||||
- Per-user and IP backstop limit values are configured in **Settings → Admin → Site features** and stored in DB runtime settings.
|
- Per-user and IP backstop limit values are configured in **Settings → Admin → Site features** and stored in DB runtime settings.
|
||||||
|
- Optional first-boot seeding can be done via `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH` (`runtimeConfig.disableTtsRateLimit`).
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -243,18 +243,7 @@ Use the same ownership split:
|
||||||
Use one of these `.env` mode templates:
|
Use one of these `.env` mode templates:
|
||||||
|
|
||||||
<Tabs groupId="local-env-modes">
|
<Tabs groupId="local-env-modes">
|
||||||
<TabItem value="no-auth" label="No Auth (simple)" default>
|
<TabItem value="auth-enabled" label="Auth Enabled" default>
|
||||||
|
|
||||||
```env
|
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
|
||||||
API_KEY=none
|
|
||||||
# Leave BASE_URL and AUTH_SECRET unset to keep auth disabled.
|
|
||||||
# (Admin panel is unavailable without auth.)
|
|
||||||
# API_BASE/API_KEY seed a shared default provider if you want shared mode.
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="auth-enabled" label="Auth Enabled">
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
|
|
@ -286,6 +275,8 @@ ADMIN_EMAILS=you@example.com
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
API_KEY=none
|
API_KEY=none
|
||||||
USE_EMBEDDED_WEED_MINI=false
|
USE_EMBEDDED_WEED_MINI=false
|
||||||
|
BASE_URL=http://localhost:3003
|
||||||
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
S3_BUCKET=your-bucket
|
S3_BUCKET=your-bucket
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_ACCESS_KEY_ID=your-access-key
|
S3_ACCESS_KEY_ID=your-access-key
|
||||||
|
|
@ -301,6 +292,8 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
API_KEY=none
|
API_KEY=none
|
||||||
|
BASE_URL=http://localhost:3003
|
||||||
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
COMPUTE_WORKER_URL=http://localhost:8081
|
COMPUTE_WORKER_URL=http://localhost:8081
|
||||||
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
||||||
USE_EMBEDDED_WEED_MINI=false
|
USE_EMBEDDED_WEED_MINI=false
|
||||||
|
|
@ -317,11 +310,11 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
:::note Env vars vs. admin panel
|
:::note Env vars vs. admin panel
|
||||||
On first boot, `API_KEY` / `API_BASE` and any `RUNTIME_SEED_*` flags you've set get auto-seeded into the admin-managed runtime config (DB-backed, keys encrypted at rest). After that, the admin UI is authoritative and editing those env vars no longer changes app behavior. See [Admin Panel](../configure/admin-panel).
|
On first boot, `API_KEY` / `API_BASE` can bootstrap `default-openai`, and `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH` can seed runtime config + providers. After that, the admin UI is authoritative and editing bootstrap env vars no longer changes app behavior. See [Admin Panel](../configure/admin-panel).
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::note User BYOK restriction default
|
:::note User BYOK restriction default
|
||||||
If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin** when auth/admin is enabled, or via legacy first-boot seed `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` for no-admin bootstrap flows).
|
If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin**, or by seeding `runtimeConfig.restrictUserApiKeys=false` in runtime seed JSON).
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,14 @@ After the first successful deploy and admin login, open **Settings → Admin** a
|
||||||
- `showAllProviderModels=false` if you want users locked to each provider's default model.
|
- `showAllProviderModels=false` if you want users locked to each provider's default model.
|
||||||
- `enableAudiobookExport=true`.
|
- `enableAudiobookExport=true`.
|
||||||
|
|
||||||
## 3. Legacy first-boot seed (optional)
|
## 3. Runtime JSON seed (optional)
|
||||||
|
|
||||||
If you must pre-seed site features via environment variables, the legacy `RUNTIME_SEED_*` seeds are still supported on first boot only. Prefer the admin panel for ongoing management.
|
If you must pre-seed site features/providers at deploy time, use `RUNTIME_SEED_JSON` or `RUNTIME_SEED_JSON_PATH` (versioned JSON seed document). Prefer the admin panel for ongoing management.
|
||||||
|
|
||||||
See [Environment Variables](../reference/environment-variables#legacy-first-boot-runtime-seeds-optional) for the complete legacy seed list.
|
See [Environment Variables](../reference/environment-variables#runtime-json-seed-v4) for schema and examples.
|
||||||
|
|
||||||
:::warning Auth recommendation
|
:::warning Auth recommendation
|
||||||
For internet-exposed Vercel deployments, set both `BASE_URL` and `AUTH_SECRET` — they are also required for the admin panel and for encrypting admin-stored TTS credentials. Running without auth is possible, but not recommended for public environments.
|
Set both `BASE_URL` and `AUTH_SECRET` — they are required in v4+ and also required for the admin panel and for encrypting admin-stored TTS credentials.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::warning Rotating AUTH_SECRET invalidates admin-stored keys
|
:::warning Rotating AUTH_SECRET invalidates admin-stored keys
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds.
|
||||||
<Tabs groupId="docker-start-mode">
|
<Tabs groupId="docker-start-mode">
|
||||||
<TabItem value="localhost" label="Localhost" default>
|
<TabItem value="localhost" label="Localhost" default>
|
||||||
|
|
||||||
Persistent storage, embedded SeaweedFS `weed mini`, optional auth, optional library mount:
|
Persistent storage, embedded SeaweedFS `weed mini`, required auth, optional library mount:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name openreader \
|
docker run --name openreader \
|
||||||
|
|
@ -57,7 +57,7 @@ What this command enables:
|
||||||
- `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state.
|
- `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state.
|
||||||
- `-v /path/to/your/library:/app/docstore/library:ro`: mounts a read-only importable library source.
|
- `-v /path/to/your/library:/app/docstore/library:ro`: mounts a read-only importable library source.
|
||||||
- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel).
|
- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel).
|
||||||
- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: together they turn on auth/session mode for local sign-in flows.
|
- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup.
|
||||||
- `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.
|
- `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
@ -95,21 +95,23 @@ What this command enables:
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="minimal" label="Minimal">
|
<TabItem value="minimal" label="Minimal">
|
||||||
|
|
||||||
Auth disabled, embedded storage ephemeral, no library import:
|
Auth required, embedded storage ephemeral, no library import:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name openreader \
|
docker run --name openreader \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
-p 3003:3003 \
|
-p 3003:3003 \
|
||||||
-p 8333:8333 \
|
-p 8333:8333 \
|
||||||
|
-e BASE_URL=http://localhost:3003 \
|
||||||
|
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
||||||
ghcr.io/richardr1126/openreader:latest
|
ghcr.io/richardr1126/openreader:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
What this command enables:
|
What this command enables:
|
||||||
|
|
||||||
- Fastest startup with no extra env vars.
|
- Fast startup with only the required auth env vars.
|
||||||
- No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount.
|
- No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount.
|
||||||
- Auth remains disabled because `BASE_URL` and `AUTH_SECRET` are not set. The admin panel requires auth, so it's unavailable in this mode.
|
- The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode.
|
||||||
- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel.
|
- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
@ -117,9 +119,9 @@ What this command enables:
|
||||||
|
|
||||||
:::tip Quick Tips
|
:::tip Quick Tips
|
||||||
- Set `API_BASE` on first boot to a TTS endpoint the container can reach (`host.docker.internal` works for host-local services). After first boot, manage providers in **Settings → Admin → Shared providers**.
|
- Set `API_BASE` on first boot to a TTS endpoint the container can reach (`host.docker.internal` works for host-local services). After first boot, manage providers in **Settings → Admin → Shared providers**.
|
||||||
- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. The admin panel requires auth.
|
- `BASE_URL` and `AUTH_SECRET` are required in v4+. The admin panel requires auth.
|
||||||
- Set `ADMIN_EMAILS` to your email if you want the **Admin** tab in Settings.
|
- Set `ADMIN_EMAILS` to your email if you want the **Admin** tab in Settings.
|
||||||
- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK in auth-enabled setups, toggle it off in **Settings → Admin → Site features**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported.
|
- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON.
|
||||||
- Use a `/app/docstore` mount if you want data to survive container/image replacement.
|
- Use a `/app/docstore` mount if you want data to survive container/image replacement.
|
||||||
- Startup automatically runs DB/storage migrations via the shared entrypoint.
|
- Startup automatically runs DB/storage migrations via the shared entrypoint.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c
|
||||||
- Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others)
|
- Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others)
|
||||||
- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
|
- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
|
||||||
- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync
|
- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync
|
||||||
- 🔐 **Auth Optional** — run no-auth for local use, or enable full user isolation with a claim flow for multi-user deployments
|
- 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows
|
||||||
- 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls
|
- 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls
|
||||||
|
|
||||||
## 🧭 Key Docs
|
## 🧭 Key Docs
|
||||||
|
|
|
||||||
|
|
@ -3,36 +3,29 @@ title: Environment Variables
|
||||||
toc_max_heading_level: 3
|
toc_max_heading_level: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
This is the single reference page for OpenReader environment variables.
|
This page is the source-of-truth reference for OpenReader environment variables.
|
||||||
|
|
||||||
:::note Recommended configuration path
|
:::note Recommended configuration path
|
||||||
For auth-enabled deployments, use **Settings → Admin** as the primary source of truth for shared TTS providers and site features. Legacy env vars (`API_KEY`, `API_BASE`, and `RUNTIME_SEED_*`) are optional first-boot seeds only.
|
Use **Settings → Admin** as the primary source of truth for shared providers and runtime site features.
|
||||||
|
`API_BASE` / `API_KEY` are optional one-time provider bootstrap seeds.
|
||||||
|
Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Quick Reference Table
|
## Quick Reference Table
|
||||||
|
|
||||||
| Variable | Area | Default | When to set |
|
| Variable | Area | Default | When to set |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `LOG_FORMAT` | Runtime logging | `pretty` | Set `json` for structured logs; shared by app server + compute worker |
|
| `LOG_FORMAT` | Runtime logging | `pretty` | Set `json` for structured logs |
|
||||||
| `LOG_LEVEL` | Runtime logging | `info` | Set app server log level |
|
| `LOG_LEVEL` | Runtime logging | `info` | Set app server log level |
|
||||||
| `API_BASE` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers |
|
| `API_BASE` | TTS provider bootstrap seed | unset | Optional first-boot base URL for `default-openai` |
|
||||||
| `API_KEY` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers |
|
| `API_KEY` | TTS provider bootstrap seed | unset | Optional first-boot API key for `default-openai` |
|
||||||
| `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size |
|
| `BASE_URL` | Auth | unset | Required at startup |
|
||||||
| `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL |
|
| `AUTH_SECRET` | Auth | unset | Required at startup |
|
||||||
| `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_UPSTREAM_TIMEOUT_MS` | TTS request timeout | `285000` | Set max upstream TTS request duration before fail-fast |
|
|
||||||
| `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 |
|
| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins |
|
||||||
| `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to enable anonymous auth sessions |
|
| `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to allow anonymous auth sessions |
|
||||||
| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in |
|
| `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 |
|
| `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 |
|
| `ADMIN_EMAILS` | Admin | empty | Comma-separated emails auto-promoted to admin |
|
||||||
| `ENABLE_TEST_NAMESPACE` | Testing/CI | unset | Honor the `x-openreader-test-namespace` header on a production build; leave unset on real deployments |
|
|
||||||
| `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) |
|
|
||||||
| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres |
|
| `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 |
|
| `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_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory |
|
||||||
|
|
@ -46,36 +39,27 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
|
||||||
| `S3_PREFIX` | Storage | `openreader` | Customize object key prefix |
|
| `S3_PREFIX` | Storage | `openreader` | Customize object key prefix |
|
||||||
| `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root |
|
| `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) |
|
| `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) |
|
||||||
| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Set only for standalone external compute worker; leave unset for embedded worker startup |
|
| `EMBEDDED_COMPUTE_WORKER_PORT` | Compute | `8081` | Override embedded worker bind port |
|
||||||
| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset (auto-generated in embedded startup) | Required for standalone external compute worker auth; must match worker |
|
| `EMBEDDED_NATS_PORT` | Compute | `4222` | Override embedded NATS client port |
|
||||||
| `EMBEDDED_COMPUTE_WORKER_PORT` | Heavy compute backend | `8081` | Override embedded worker bind port |
|
| `EMBEDDED_NATS_MONITOR_PORT` | Compute | `8222` | Override embedded NATS monitor port |
|
||||||
| `EMBEDDED_NATS_PORT` | Heavy compute backend | `4222` | Override embedded NATS client port |
|
| `EMBEDDED_NATS_STORE_DIR` | Compute | `docstore/nats/jetstream` | Override embedded JetStream storage directory |
|
||||||
| `EMBEDDED_NATS_MONITOR_PORT` | Heavy compute backend | `8222` | Override embedded NATS monitor port |
|
| `NATS_URL` | Compute | `nats://127.0.0.1:4222` in embedded startup | Override embedded startup or set standalone worker URL |
|
||||||
| `EMBEDDED_NATS_STORE_DIR` | Heavy compute backend | `docstore/nats/jetstream` | Override embedded JetStream storage directory |
|
| `COMPUTE_LOG_LEVEL` | Compute | `info` | Compute worker log level |
|
||||||
| `NATS_URL` | Heavy compute backend | `nats://127.0.0.1:4222` in embedded startup | Optional override for embedded startup or required on standalone worker service |
|
| `COMPUTE_JOB_CONCURRENCY` | Compute | `1` | Shared compute concurrency cap |
|
||||||
| `COMPUTE_LOG_LEVEL` | Heavy compute backend | `info` | Compute worker log level |
|
| `COMPUTE_WHISPER_TIMEOUT_MS` | Compute | `30000` | Whisper alignment timeout budget |
|
||||||
| `COMPUTE_JOB_CONCURRENCY` | Heavy compute backend | `1` | Worker-side shared compute concurrency cap |
|
| `COMPUTE_PDF_TIMEOUT_MS` | Compute | `300000` | PDF parse timeout budget |
|
||||||
| `COMPUTE_WHISPER_TIMEOUT_MS` | Heavy compute backend | `30000` | Shared whisper alignment timeout budget (worker + worker client wait budget) |
|
| `COMPUTE_OP_STALE_MS` | Compute | `max(30m, 4x max compute timeout)` | Shared stale window for compute op replacement |
|
||||||
| `COMPUTE_PDF_TIMEOUT_MS` | Heavy compute backend | `300000` | Shared PDF idle-timeout budget (worker + worker client wait budget) |
|
| `WHISPER_MODEL_BASE_URL` | Compute model source | onnx-community default | Override Whisper ONNX model base URL |
|
||||||
| `COMPUTE_OP_STALE_MS` | Heavy compute backend | `max(30m, 4x max compute timeout)` | Shared stale window for worker op replacement and app-side stale PDF parse-state healing |
|
| `PDF_LAYOUT_MODEL_BASE_URL` | Compute model source | PP-DocLayoutV3 default | Override PDF layout ONNX model base URL |
|
||||||
| `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` |
|
| `COMPUTE_WORKER_URL` | External compute mode | unset | Set only for standalone external worker mode |
|
||||||
| `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped q4 downloads |
|
| `COMPUTE_WORKER_TOKEN` | External compute mode | unset | Required for standalone external worker auth |
|
||||||
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
|
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
|
||||||
| `RUNTIME_SEED_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features |
|
| `DISABLE_AUTH_RATE_LIMIT` | Auth request throttling | `false` | Set `true` to disable Better Auth request rate limiting |
|
||||||
| `RUNTIME_SEED_ENABLE_DOCX_CONVERSION` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable/disable DOCX conversion UI |
|
| `ENABLE_TEST_NAMESPACE` | Testing/CI | unset | Honor `x-openreader-test-namespace` header in production builds |
|
||||||
| `RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide destructive delete actions |
|
| `RUN_DRIZZLE_MIGRATIONS` | DB migrations | `true` | Set `false` to skip startup Drizzle migrations |
|
||||||
| `RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide user TTS providers tab |
|
|
||||||
| `RUNTIME_SEED_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features |
|
|
||||||
| `RUNTIME_SEED_ENABLE_USER_SIGNUPS` | Legacy bootstrap seed | `true` | Optional first-boot seed for whether new accounts can be created; then manage in Settings → Admin → Site features |
|
|
||||||
| `RUNTIME_SEED_RESTRICT_USER_API_KEYS` | Legacy bootstrap seed | runtime-dependent | Optional first-boot seed to restrict per-user BYOK |
|
|
||||||
| `RUNTIME_SEED_DEFAULT_TTS_PROVIDER` | Legacy bootstrap seed | `custom-openai` | Optional first-boot seed for default TTS provider slug |
|
|
||||||
| `RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable audiobook export UI |
|
|
||||||
| `RUNTIME_SEED_DISABLE_TTS_LIMIT` | Legacy bootstrap seed | `true` | Optional first-boot seed that keeps TTS daily rate limiting disabled |
|
|
||||||
| `RUNTIME_SEED_DISABLE_COMPUTE_LIMIT` | Legacy bootstrap seed | `true` | Optional first-boot seed that keeps PDF parsing rate limiting disabled (other compute-limit values + max upload size are admin-only) |
|
|
||||||
| `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 |
|
| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass |
|
||||||
|
| `RUNTIME_SEED_JSON_PATH` | Runtime JSON seed | unset | Absolute path to first-boot JSON seed document |
|
||||||
|
| `RUNTIME_SEED_JSON` | Runtime JSON seed | unset | Inline first-boot JSON seed document |
|
||||||
|
|
||||||
## Runtime Logging
|
## Runtime Logging
|
||||||
|
|
||||||
|
|
@ -86,7 +70,6 @@ Controls log output format for server-side Pino loggers.
|
||||||
- Default: `pretty`
|
- Default: `pretty`
|
||||||
- Allowed values: `pretty`, `json`
|
- Allowed values: `pretty`, `json`
|
||||||
- Applies to app server and compute worker
|
- Applies to app server and compute worker
|
||||||
- Recommended in production (Vercel + external worker): `json`
|
|
||||||
|
|
||||||
### LOG_LEVEL
|
### LOG_LEVEL
|
||||||
|
|
||||||
|
|
@ -98,157 +81,88 @@ App server log level.
|
||||||
|
|
||||||
### API_BASE
|
### API_BASE
|
||||||
|
|
||||||
Bootstrap base URL for the legacy OpenAI-compatible TTS endpoint.
|
Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider.
|
||||||
|
|
||||||
- Example: `http://host.docker.internal:8880/v1`
|
- Example: `http://host.docker.internal:8880/v1`
|
||||||
- **Seeded on first boot** into the auto-created `default-openai` shared provider, then no longer read by the running app. Manage in **Settings → Admin → Shared providers** afterwards.
|
- Read only for provider bootstrap when shared providers are empty and `API_KEY` is set.
|
||||||
- Related docs: [Admin Panel](../configure/admin-panel), [TTS Providers](../configure/tts-providers)
|
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
|
||||||
|
|
||||||
### API_KEY
|
### API_KEY
|
||||||
|
|
||||||
Bootstrap API key for the legacy OpenAI-compatible TTS endpoint.
|
Optional first-boot bootstrap API key for the auto-created `default-openai` shared provider.
|
||||||
|
|
||||||
- Example: your provider token, or omit if the provider doesn't require auth
|
- Read only for provider bootstrap when shared providers are empty.
|
||||||
- **Seeded on first boot** into the auto-created `default-openai` shared provider (encrypted at rest), then no longer read by the running app. Manage in **Settings → Admin → Shared providers** afterwards.
|
- Stored encrypted at rest after bootstrap.
|
||||||
- Related docs: [Admin Panel](../configure/admin-panel), [TTS Providers](../configure/tts-providers)
|
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared 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_UPSTREAM_TIMEOUT_MS
|
|
||||||
|
|
||||||
Maximum upstream TTS request timeout in milliseconds.
|
|
||||||
|
|
||||||
- Default: `285000` (285 seconds)
|
|
||||||
- Applies to outbound provider calls from server routes using shared TTS generation
|
|
||||||
- Increase for slower providers/models; decrease to fail fast and surface retryable errors sooner
|
|
||||||
|
|
||||||
### TTS Daily Rate Limiting (Runtime Settings)
|
### TTS Daily Rate Limiting (Runtime Settings)
|
||||||
|
|
||||||
TTS character rate limiting is now managed from **Settings → Admin → Site features**.
|
Managed as runtime config in **Settings → Admin → Site features**.
|
||||||
|
|
||||||
- `disableTtsRateLimit` default: `true` (rate limiting disabled)
|
- `disableTtsRateLimit` default: `true` (daily TTS limits disabled)
|
||||||
- Daily limit defaults:
|
- `ttsDailyLimitAnonymous` default: `50000`
|
||||||
- Anonymous per-user: `50000`
|
- `ttsDailyLimitAuthenticated` default: `500000`
|
||||||
- Authenticated per-user: `500000`
|
- `ttsIpDailyLimitAnonymous` default: `100000`
|
||||||
- Anonymous IP backstop: `100000`
|
- `ttsIpDailyLimitAuthenticated` default: `1000000`
|
||||||
- Authenticated IP backstop: `1000000`
|
|
||||||
|
|
||||||
Optional first-boot seeds:
|
### TTS Upstream Settings (Runtime Settings)
|
||||||
|
|
||||||
- `RUNTIME_SEED_DISABLE_TTS_LIMIT`
|
Managed as runtime config in **Settings → Admin → Site features → TTS upstream**.
|
||||||
|
|
||||||
After first boot, these values are DB-backed admin runtime settings.
|
- `ttsUpstreamMaxRetries` default: `2`
|
||||||
|
- `ttsUpstreamTimeoutMs` default: `285000`
|
||||||
|
- `ttsCacheMaxSizeBytes` default: `268435456` (256 MB)
|
||||||
|
- `ttsCacheTtlMs` default: `1800000` (30 minutes)
|
||||||
|
|
||||||
|
There are no dedicated env vars for these runtime settings.
|
||||||
|
|
||||||
## Auth and Identity
|
## Auth and Identity
|
||||||
|
|
||||||
### BASE_URL
|
### BASE_URL
|
||||||
|
|
||||||
External base URL for this OpenReader instance.
|
Required external base URL for this OpenReader instance.
|
||||||
|
|
||||||
- Required with `AUTH_SECRET` to enable auth
|
- Required at startup
|
||||||
- Example: `http://localhost:3003` or `https://reader.example.com`
|
- Example: `http://localhost:3003` or `https://reader.example.com`
|
||||||
- Related docs: [Auth](../configure/auth)
|
|
||||||
|
|
||||||
### AUTH_SECRET
|
### AUTH_SECRET
|
||||||
|
|
||||||
Secret key used by auth/session handling.
|
Required secret key used by auth/session handling.
|
||||||
|
|
||||||
- Required with `BASE_URL` to enable auth
|
- Required at startup
|
||||||
- Generate with `openssl rand -hex 32`
|
- Generate with `openssl rand -hex 32`
|
||||||
- Also used to HMAC-hash server-side TTS segment text fingerprints
|
|
||||||
- Related docs: [Auth](../configure/auth)
|
|
||||||
|
|
||||||
### AUTH_TRUSTED_ORIGINS
|
### AUTH_TRUSTED_ORIGINS
|
||||||
|
|
||||||
Additional allowed origins for auth requests.
|
Additional allowed origins for auth requests.
|
||||||
|
|
||||||
- Comma-separated list
|
- Comma-separated list
|
||||||
- `BASE_URL` origin is always trusted automatically
|
- `BASE_URL` origin is trusted automatically
|
||||||
- Related docs: [Auth](../configure/auth)
|
|
||||||
|
|
||||||
### USE_ANONYMOUS_AUTH_SESSIONS
|
### USE_ANONYMOUS_AUTH_SESSIONS
|
||||||
|
|
||||||
Controls whether auth-enabled deployments can create/use anonymous sessions.
|
Controls whether auth-enabled deployments can create/use anonymous sessions.
|
||||||
|
|
||||||
- Default: `false` (anonymous sessions disabled)
|
- Default: `false`
|
||||||
- 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_CLIENT_ID
|
||||||
|
|
||||||
GitHub OAuth client ID.
|
GitHub OAuth client ID.
|
||||||
|
|
||||||
- Enable only with `GITHUB_CLIENT_SECRET`
|
- Set with `GITHUB_CLIENT_SECRET`
|
||||||
|
|
||||||
### GITHUB_CLIENT_SECRET
|
### GITHUB_CLIENT_SECRET
|
||||||
|
|
||||||
GitHub OAuth client secret.
|
GitHub OAuth client secret.
|
||||||
|
|
||||||
- Enable only with `GITHUB_CLIENT_ID`
|
- Set 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)
|
|
||||||
|
|
||||||
### ENABLE_TEST_NAMESPACE
|
|
||||||
|
|
||||||
Honors the `x-openreader-test-namespace` request header, which scopes documents/storage into an isolated namespace for end-to-end tests.
|
|
||||||
|
|
||||||
- Default: unset (header ignored on production builds)
|
|
||||||
- Non-production builds (`NODE_ENV !== 'production'`) honor the header without this flag.
|
|
||||||
- Production builds (`pnpm build && pnpm start`) honor it only when `ENABLE_TEST_NAMESPACE=true`. The Playwright web server sets this automatically.
|
|
||||||
- **Leave unset on real deployments.** It is test/CI scaffolding only.
|
|
||||||
|
|
||||||
### ADMIN_EMAILS
|
### ADMIN_EMAILS
|
||||||
|
|
||||||
Comma-separated list of email addresses that are auto-promoted to admin.
|
Comma-separated list of email addresses auto-promoted to admin.
|
||||||
|
|
||||||
- Default: empty (no admins)
|
- Requires auth to be enabled
|
||||||
- Requires auth to be enabled (`AUTH_SECRET` + `BASE_URL`).
|
- Admins can manage shared providers and runtime site features in-app
|
||||||
- Matched emails get `user.is_admin = true` on every session resolution; removed emails are demoted on the next session resolve.
|
|
||||||
- Admins see a new **Admin** tab in Settings exposing shared TTS providers and site-wide feature toggles. Keys for shared providers are stored encrypted in the DB and never returned to the client.
|
|
||||||
- Example: `ADMIN_EMAILS=alice@example.com,bob@example.com`
|
|
||||||
- Related docs: [Admin Panel](../configure/admin-panel), [Auth](../configure/auth)
|
|
||||||
|
|
||||||
## Database and Object Blob Storage
|
## Database and Object Blob Storage
|
||||||
|
|
||||||
|
|
@ -258,9 +172,6 @@ Switches metadata/auth storage from SQLite to Postgres.
|
||||||
|
|
||||||
- Unset: SQLite at `docstore/sqlite3.db`
|
- Unset: SQLite at `docstore/sqlite3.db`
|
||||||
- Set: Postgres mode
|
- Set: Postgres mode
|
||||||
- Related docs: [Database](../configure/database)
|
|
||||||
|
|
||||||
### Embedded SeaweedFS weed mini config
|
|
||||||
|
|
||||||
### USE_EMBEDDED_WEED_MINI
|
### USE_EMBEDDED_WEED_MINI
|
||||||
|
|
||||||
|
|
@ -268,129 +179,83 @@ Controls embedded SeaweedFS startup.
|
||||||
|
|
||||||
- Default behavior: treated as enabled when unset
|
- Default behavior: treated as enabled when unset
|
||||||
- Set `false` to rely on external S3-compatible storage
|
- Set `false` to rely on external S3-compatible storage
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### WEED_MINI_DIR
|
### WEED_MINI_DIR
|
||||||
|
|
||||||
Data directory for embedded SeaweedFS (`weed mini`).
|
Data directory for embedded SeaweedFS (`weed mini`).
|
||||||
|
|
||||||
- Default: `docstore/seaweedfs`
|
- Default: `docstore/seaweedfs`
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### WEED_MINI_WAIT_SEC
|
### WEED_MINI_WAIT_SEC
|
||||||
|
|
||||||
Maximum seconds to wait for embedded SeaweedFS startup.
|
Max wait time for embedded SeaweedFS startup.
|
||||||
|
|
||||||
- Default: `20`
|
- Default: `20`
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### S3 storage config
|
|
||||||
|
|
||||||
### S3_ACCESS_KEY_ID
|
### S3_ACCESS_KEY_ID
|
||||||
|
|
||||||
Access key for S3-compatible storage.
|
S3 access key.
|
||||||
|
|
||||||
- Auto-generated in embedded mode if unset
|
- Optional in embedded mode (auto-generated when unset)
|
||||||
- Set explicitly for stable credentials or external providers
|
- Required for external S3 mode
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### S3_SECRET_ACCESS_KEY
|
### S3_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
Secret key for S3-compatible storage.
|
S3 secret key.
|
||||||
|
|
||||||
- Auto-generated in embedded mode if unset
|
- Optional in embedded mode (auto-generated when unset)
|
||||||
- Set explicitly for stable credentials or external providers
|
- Required for external S3 mode
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### S3_BUCKET
|
### S3_BUCKET
|
||||||
|
|
||||||
Bucket name used for document blobs.
|
S3 bucket name.
|
||||||
|
|
||||||
- Default in embedded mode: `openreader-documents`
|
- Embedded default: `openreader-documents`
|
||||||
- Required for external S3-compatible storage
|
- Required for external S3 mode
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### S3_REGION
|
### S3_REGION
|
||||||
|
|
||||||
Region used by the S3 client.
|
S3 region.
|
||||||
|
|
||||||
- Default in embedded mode: `us-east-1`
|
- Embedded default: `us-east-1`
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
- Required for external S3 mode
|
||||||
|
|
||||||
### S3_ENDPOINT
|
### S3_ENDPOINT
|
||||||
|
|
||||||
Endpoint URL for S3-compatible storage.
|
Custom endpoint for S3-compatible providers.
|
||||||
|
|
||||||
- In embedded mode, defaults to `http://<BASE_URL host>:8333` (or detected host)
|
- Optional for AWS
|
||||||
- For AWS S3, usually leave unset
|
- Typical for MinIO/SeaweedFS/R2
|
||||||
- For MinIO/SeaweedFS/R2/B2-style APIs, typically set explicitly
|
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### S3_FORCE_PATH_STYLE
|
### S3_FORCE_PATH_STYLE
|
||||||
|
|
||||||
Path-style S3 addressing toggle.
|
Force path-style S3 URLs.
|
||||||
|
|
||||||
- Default in embedded mode: `true`
|
- Embedded default: `true`
|
||||||
- Set according to provider requirements
|
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### S3_PREFIX
|
### S3_PREFIX
|
||||||
|
|
||||||
Prefix prepended to stored object keys.
|
Object key prefix.
|
||||||
|
|
||||||
- Default: `openreader`
|
- Default: `openreader`
|
||||||
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
|
|
||||||
|
|
||||||
### Maximum Upload Size (Runtime Settings)
|
|
||||||
|
|
||||||
The maximum size accepted for a single document upload is an admin runtime setting (no env var).
|
|
||||||
|
|
||||||
- Runtime key: `maxUploadMb` (default: `200`)
|
|
||||||
- Configure in **Settings → Admin → Site features → Max upload size (MB)**.
|
|
||||||
- Enforced up front (`413`) and signed into the presigned S3 PUT so a client cannot stream more than it declared.
|
|
||||||
|
|
||||||
## Library Import
|
## Library Import
|
||||||
|
|
||||||
### IMPORT_LIBRARY_DIR
|
### IMPORT_LIBRARY_DIR
|
||||||
|
|
||||||
Single directory root for server library import.
|
Single library source directory.
|
||||||
|
|
||||||
- Used when `IMPORT_LIBRARY_DIRS` is unset
|
|
||||||
- Default fallback root: `docstore/library`
|
|
||||||
- Related docs: [Server Library Import](../configure/server-library-import)
|
|
||||||
|
|
||||||
### IMPORT_LIBRARY_DIRS
|
### IMPORT_LIBRARY_DIRS
|
||||||
|
|
||||||
Multiple library roots for server library import.
|
Multiple library roots.
|
||||||
|
|
||||||
- Separator: comma, colon, or semicolon
|
- Supports comma, colon, or semicolon-separated values
|
||||||
- Takes precedence over `IMPORT_LIBRARY_DIR`
|
|
||||||
- Related docs: [Server Library Import](../configure/server-library-import)
|
|
||||||
|
|
||||||
## Audio Tooling and Alignment
|
## Compute Worker and Model Configuration
|
||||||
|
|
||||||
### COMPUTE_WORKER_URL
|
|
||||||
|
|
||||||
Base URL for standalone external compute worker mode.
|
|
||||||
|
|
||||||
- Leave unset for embedded/local startup (`pnpm dev` / `pnpm start`) so entrypoint can start embedded worker+NATS.
|
|
||||||
- Embedded startup requires `nats-server` available on host PATH.
|
|
||||||
- Required only when using a standalone external worker service.
|
|
||||||
- App-side only: set on app server/root `.env` (routing target), not worker-only env files.
|
|
||||||
- Example: `http://localhost:8081`
|
|
||||||
|
|
||||||
### COMPUTE_WORKER_TOKEN
|
|
||||||
|
|
||||||
Bearer token for compute-worker auth.
|
|
||||||
|
|
||||||
- Required for standalone external worker service mode.
|
|
||||||
- Must match worker service `COMPUTE_WORKER_TOKEN`.
|
|
||||||
- In embedded startup, entrypoint auto-generates one if unset.
|
|
||||||
- In external worker mode, set this on both app server/root `.env` and worker service env (`compute/worker/.env*` or platform env).
|
|
||||||
|
|
||||||
### EMBEDDED_COMPUTE_WORKER_PORT
|
### EMBEDDED_COMPUTE_WORKER_PORT
|
||||||
|
|
||||||
Embedded compute-worker HTTP port.
|
Embedded compute worker port.
|
||||||
|
|
||||||
- Default: `8081`
|
- Default: `8081`
|
||||||
|
|
||||||
|
|
@ -402,213 +267,200 @@ Embedded NATS client port.
|
||||||
|
|
||||||
### EMBEDDED_NATS_MONITOR_PORT
|
### EMBEDDED_NATS_MONITOR_PORT
|
||||||
|
|
||||||
Embedded NATS monitor (`/healthz`) port.
|
Embedded NATS monitor port.
|
||||||
|
|
||||||
- Default: `8222`
|
- Default: `8222`
|
||||||
|
|
||||||
### EMBEDDED_NATS_STORE_DIR
|
### EMBEDDED_NATS_STORE_DIR
|
||||||
|
|
||||||
Embedded JetStream storage directory.
|
Embedded NATS JetStream data directory.
|
||||||
|
|
||||||
- Default: `docstore/nats/jetstream`
|
- Default: `docstore/nats/jetstream`
|
||||||
|
|
||||||
### NATS_URL
|
### NATS_URL
|
||||||
|
|
||||||
NATS connection URL used by compute worker runtime.
|
NATS URL used by compute services.
|
||||||
|
|
||||||
- Embedded startup default: `nats://127.0.0.1:4222`
|
- Embedded startup default: `nats://127.0.0.1:4222`
|
||||||
- Standalone worker service: set in worker service env (`compute/worker/.env*` or platform env)
|
|
||||||
- For embedded startup, this is optional; startup supplies the default value.
|
|
||||||
- Worker-side only in external mode: set on worker service env, not app/root `.env`.
|
|
||||||
|
|
||||||
### COMPUTE_LOG_LEVEL
|
### COMPUTE_LOG_LEVEL
|
||||||
|
|
||||||
Compute worker log level.
|
Compute worker log level.
|
||||||
|
|
||||||
- Default: `info`
|
- Default: `info`
|
||||||
- In standalone mode, set this on the worker service env.
|
|
||||||
|
|
||||||
### COMPUTE_JOB_CONCURRENCY
|
### COMPUTE_JOB_CONCURRENCY
|
||||||
|
|
||||||
Worker-side shared compute concurrency cap.
|
Max concurrent compute jobs per worker.
|
||||||
|
|
||||||
- Default: `1`
|
- Default: `1`
|
||||||
- Set on the compute-worker service environment
|
|
||||||
|
|
||||||
### COMPUTE_WHISPER_TIMEOUT_MS
|
### COMPUTE_WHISPER_TIMEOUT_MS
|
||||||
|
|
||||||
Shared whisper alignment timeout budget in milliseconds.
|
Whisper alignment timeout budget.
|
||||||
|
|
||||||
- Default: `30000`
|
- Default: `30000`
|
||||||
- Used by:
|
|
||||||
- Worker compute whisper runtime
|
|
||||||
- App server worker-client wait budget (SSE wait timeout)
|
|
||||||
|
|
||||||
### COMPUTE_PDF_TIMEOUT_MS
|
### COMPUTE_PDF_TIMEOUT_MS
|
||||||
|
|
||||||
Shared PDF idle-timeout budget in milliseconds.
|
PDF parse timeout budget.
|
||||||
|
|
||||||
- Default: `300000` (5 minutes)
|
- Default: `300000`
|
||||||
- Used by:
|
|
||||||
- Worker compute PDF runtime (idle timeout)
|
|
||||||
- App server worker-client wait budget (SSE wait timeout)
|
|
||||||
|
|
||||||
### COMPUTE_OP_STALE_MS
|
### COMPUTE_OP_STALE_MS
|
||||||
|
|
||||||
Shared stale window in milliseconds.
|
Stale operation window before worker/app cleanup logic can replace an op.
|
||||||
|
|
||||||
- Default: `max(30m, 4x max(COMPUTE_WHISPER_TIMEOUT_MS, COMPUTE_PDF_TIMEOUT_MS))`
|
- Default: `max(30m, 4x max compute timeout)`
|
||||||
- Used by:
|
|
||||||
- Worker op reuse/replacement guard (`/ops` opKey stale detection)
|
|
||||||
- App-server PDF parse-state stale healing in `/api/documents/[id]/parsed*`
|
|
||||||
- If a parse row is stuck in `pending`/`running` past this window, app routes mark it failed so retries/reparse can proceed.
|
|
||||||
- Keep this value aligned on both app-server and worker service envs.
|
|
||||||
|
|
||||||
### PDF_LAYOUT_MODEL_BASE_URL
|
|
||||||
|
|
||||||
Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureModel()`.
|
|
||||||
|
|
||||||
- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main`
|
|
||||||
- Required files at that base:
|
|
||||||
- `PP-DocLayoutV3.onnx`
|
|
||||||
- `PP-DocLayoutV3.onnx.data`
|
|
||||||
- `config.json`
|
|
||||||
- `preprocessor_config.json`
|
|
||||||
- Configure this on the worker service env (not only the app server env)
|
|
||||||
|
|
||||||
### WHISPER_MODEL_BASE_URL
|
### WHISPER_MODEL_BASE_URL
|
||||||
|
|
||||||
Optional base URL override for the built-in ONNX Whisper alignment model downloader.
|
Base URL for Whisper ONNX model downloads.
|
||||||
|
|
||||||
- Default: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main`
|
### PDF_LAYOUT_MODEL_BASE_URL
|
||||||
- Default model variant: q4 (`encoder_model_q4.onnx`, `decoder_model_merged_q4.onnx`, `decoder_with_past_model_q4.onnx`)
|
|
||||||
- The base URL must host all expected manifest files under the same relative paths.
|
Base URL for PDF layout model downloads.
|
||||||
- Configure this on the worker service env (not only the app server env)
|
|
||||||
|
### COMPUTE_WORKER_URL
|
||||||
|
|
||||||
|
External compute worker URL.
|
||||||
|
|
||||||
|
- Leave unset for embedded worker mode
|
||||||
|
|
||||||
|
### COMPUTE_WORKER_TOKEN
|
||||||
|
|
||||||
|
Shared token for app-to-external-worker requests.
|
||||||
|
|
||||||
|
## Compute PDF Parsing Rate Limiting (Runtime Settings)
|
||||||
|
|
||||||
|
Managed as runtime config in **Settings → Admin → Site features**.
|
||||||
|
|
||||||
|
- `disableComputeRateLimit` default: `true`
|
||||||
|
- `computeParseBurstMax` default: `8`
|
||||||
|
- `computeParseBurstWindowSec` default: `60`
|
||||||
|
- `computeParseSustainedMax` default: `24`
|
||||||
|
- `computeParseSustainedWindowSec` default: `600`
|
||||||
|
- `maxUploadMb` default: `200`
|
||||||
|
|
||||||
|
There are no dedicated env vars for these runtime settings.
|
||||||
|
|
||||||
|
## Audio Runtime
|
||||||
|
|
||||||
### FFMPEG_BIN
|
### FFMPEG_BIN
|
||||||
|
|
||||||
Absolute path or executable name for the ffmpeg binary used by audiobook/processing routes.
|
Override ffmpeg binary path used for audio processing.
|
||||||
|
|
||||||
- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static`
|
- Used by audiobook processing routes and compute worker Whisper audio decode.
|
||||||
- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg`
|
|
||||||
|
|
||||||
### Compute (PDF Parsing) Rate Limiting (Runtime Settings)
|
## Testing and CI
|
||||||
|
|
||||||
Per-user throttling of expensive PDF layout parsing is managed from **Settings → Admin → Site features**, not env vars. Enforcement applies only when auth is enabled.
|
### DISABLE_AUTH_RATE_LIMIT
|
||||||
|
|
||||||
- `disableComputeRateLimit` default: `true` (rate limiting disabled, like the TTS limit; enable it in the admin panel)
|
Disables Better Auth request rate limiting.
|
||||||
- When enabled, the following admin-tunable sub-limits apply:
|
|
||||||
- `computeParseBurstMax` (default `8`) over `computeParseBurstWindowSec` (default `60`)
|
|
||||||
- `computeParseSustainedMax` (default `24`) over `computeParseSustainedWindowSec` (default `600`)
|
|
||||||
- The sustained window also acts as a concurrency cap: because the worker bounds each job's duration, the count of parses started in that window is an upper bound on those still running.
|
|
||||||
- Exceeding a limit returns `429` (`application/problem+json`) with `Retry-After`.
|
|
||||||
|
|
||||||
Optional first-boot seed: `RUNTIME_SEED_DISABLE_COMPUTE_LIMIT`. All other values are DB-backed admin runtime settings.
|
- Default: `false`
|
||||||
|
|
||||||
## Legacy First-Boot Runtime Seeds (optional)
|
### ENABLE_TEST_NAMESPACE
|
||||||
|
|
||||||
These variables exist only as **first-boot seeds** for the admin-managed runtime config. Prefer changing site features from **Settings → Admin → Site features**. Keep these only when you need bootstrap defaults before the first admin login. See [Admin Panel](../configure/admin-panel) for migration behavior.
|
Enables the `x-openreader-test-namespace` header path in production builds.
|
||||||
|
|
||||||
The values are SSR-injected via `window.__RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old build-time public env pattern).
|
|
||||||
|
|
||||||
### RUNTIME_SEED_ENABLE_DOCX_CONVERSION
|
|
||||||
|
|
||||||
Controls whether the experimental DOCX-to-PDF conversion and upload feature is enabled.
|
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
|
||||||
- Runtime key: `enableDocxConversion`
|
|
||||||
|
|
||||||
### RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS
|
|
||||||
|
|
||||||
Controls whether the "Delete all user docs" and other bulk-delete buttons are shown in Settings.
|
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
|
||||||
- Runtime key: `enableDestructiveDeleteActions`
|
|
||||||
|
|
||||||
### RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB
|
|
||||||
|
|
||||||
Controls whether the **TTS Provider** section appears in the user-facing Settings modal.
|
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
|
||||||
- Set `false` to hide provider/model/API controls in the per-user Settings modal (the admin panel is unaffected).
|
|
||||||
- Runtime key: `enableTtsProvidersTab`
|
|
||||||
|
|
||||||
### RUNTIME_SEED_ENABLE_USER_SIGNUPS
|
|
||||||
|
|
||||||
Controls whether new user accounts can be created.
|
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
|
||||||
- When `false`, new account creation is blocked for email sign-up, first-time OAuth signup, and anonymous-to-account upgrades.
|
|
||||||
- Existing users can still sign in.
|
|
||||||
- Runtime key: `enableUserSignups`
|
|
||||||
|
|
||||||
### RUNTIME_SEED_RESTRICT_USER_API_KEYS
|
|
||||||
|
|
||||||
Controls whether users can supply personal API keys/base URLs for built-in providers.
|
|
||||||
|
|
||||||
- Default: runtime-dependent
|
|
||||||
- When `true`, server routes only use admin-managed shared providers.
|
|
||||||
- When `false`, users can use per-user BYOK credentials for built-in providers.
|
|
||||||
- Runtime key: `restrictUserApiKeys`
|
|
||||||
|
|
||||||
### RUNTIME_SEED_DEFAULT_TTS_PROVIDER
|
|
||||||
|
|
||||||
Sets the default TTS provider for new users.
|
|
||||||
|
|
||||||
- Default: `custom-openai`
|
|
||||||
- Example values: `replicate`, `deepinfra`, `openai`, `custom-openai`, or an admin-defined shared provider slug (e.g. `kokoro-prod`)
|
|
||||||
- Runtime key: `defaultTtsProvider`
|
|
||||||
|
|
||||||
`showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**.
|
|
||||||
|
|
||||||
### RUNTIME_SEED_CHANGELOG_FEED_URL
|
|
||||||
|
|
||||||
Sets the changelog manifest URL used by the Settings modal changelog viewer.
|
|
||||||
|
|
||||||
- Default: `https://docs.openreader.richardr.dev/changelog/manifest.json`
|
|
||||||
- Use this in self-hosted deployments when you publish changelog feeds to a custom docs domain/path.
|
|
||||||
- Runtime key: `changelogFeedUrl`
|
|
||||||
|
|
||||||
|
|
||||||
### RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT
|
|
||||||
|
|
||||||
Controls whether audiobook export UI/actions are shown in the client.
|
|
||||||
|
|
||||||
- Default: `true` (enabled)
|
|
||||||
- Affects export entry points in PDF/EPUB pages and document settings UI
|
|
||||||
- Runtime key: `enableAudiobookExport`
|
|
||||||
|
|
||||||
### RUNTIME_SEED_DISABLE_TTS_LIMIT
|
|
||||||
|
|
||||||
Seeds the TTS daily character rate-limit on/off state on first boot.
|
|
||||||
|
|
||||||
- Default: `true` (TTS rate limiting disabled)
|
|
||||||
- Runtime key: `disableTtsRateLimit`
|
|
||||||
- Per-user/IP daily limit values are admin-only runtime settings (see [TTS Daily Rate Limiting](#tts-daily-rate-limiting-runtime-settings)).
|
|
||||||
|
|
||||||
### RUNTIME_SEED_DISABLE_COMPUTE_LIMIT
|
|
||||||
|
|
||||||
Seeds the PDF parsing rate-limit on/off state on first boot.
|
|
||||||
|
|
||||||
- Default: `true` (PDF parsing rate limiting disabled, matching `RUNTIME_SEED_DISABLE_TTS_LIMIT`)
|
|
||||||
- Runtime key: `disableComputeRateLimit`
|
|
||||||
- The burst/sustained limits, their windows, and the max upload size are admin-only runtime settings (see [Compute (PDF Parsing) Rate Limiting](#compute-pdf-parsing-rate-limiting-runtime-settings)).
|
|
||||||
|
|
||||||
## Migration Controls
|
## Migration Controls
|
||||||
|
|
||||||
### RUN_DRIZZLE_MIGRATIONS
|
### RUN_DRIZZLE_MIGRATIONS
|
||||||
|
|
||||||
Controls startup migration execution in shared entrypoint.
|
Controls startup Drizzle schema migrations.
|
||||||
|
|
||||||
- Default: `true`
|
- Default: `true`
|
||||||
- Set `false` to skip automatic startup Drizzle schema migrations
|
- Set `false` to skip startup migration run
|
||||||
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database)
|
|
||||||
|
|
||||||
### RUN_FS_MIGRATIONS
|
### RUN_FS_MIGRATIONS
|
||||||
|
|
||||||
Controls startup filesystem-to-object-store migration execution in shared entrypoint.
|
Controls startup filesystem-to-S3/DB migration pass.
|
||||||
|
|
||||||
- Default: `true`
|
- Default: `true`
|
||||||
- Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations
|
- Set `false` to skip startup storage migration run
|
||||||
- Set `false` to skip automatic storage migration pass
|
|
||||||
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database), [Object / Blob Storage](../configure/object-blob-storage)
|
## Runtime JSON Seed (v4)
|
||||||
|
|
||||||
|
### RUNTIME_SEED_JSON_PATH
|
||||||
|
|
||||||
|
Path-based first-boot seed document.
|
||||||
|
|
||||||
|
- If both `RUNTIME_SEED_JSON_PATH` and `RUNTIME_SEED_JSON` are set, path wins.
|
||||||
|
- Value must point to a JSON file readable by the app process.
|
||||||
|
|
||||||
|
### RUNTIME_SEED_JSON
|
||||||
|
|
||||||
|
Inline first-boot seed document.
|
||||||
|
|
||||||
|
- Used only when `RUNTIME_SEED_JSON_PATH` is unset.
|
||||||
|
- Must be a JSON object with `version: 1`.
|
||||||
|
|
||||||
|
Supported top-level keys:
|
||||||
|
|
||||||
|
- `version` (required, must be `1`)
|
||||||
|
- `runtimeConfig` (optional object, strict-validated against runtime schema)
|
||||||
|
- `providers` (optional array of shared provider seed entries)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"runtimeConfig": {
|
||||||
|
"enableUserSignups": true,
|
||||||
|
"restrictUserApiKeys": true,
|
||||||
|
"defaultTtsProvider": "custom-openai",
|
||||||
|
"enableTtsProvidersTab": true,
|
||||||
|
"enableAudiobookExport": true,
|
||||||
|
"enableDocxConversion": true,
|
||||||
|
"enableDestructiveDeleteActions": true,
|
||||||
|
"showAllProviderModels": true,
|
||||||
|
"disableTtsRateLimit": true,
|
||||||
|
"ttsDailyLimitAnonymous": 50000,
|
||||||
|
"ttsDailyLimitAuthenticated": 500000,
|
||||||
|
"ttsIpDailyLimitAnonymous": 100000,
|
||||||
|
"ttsIpDailyLimitAuthenticated": 1000000,
|
||||||
|
"ttsCacheMaxSizeBytes": 268435456,
|
||||||
|
"ttsCacheTtlMs": 1800000,
|
||||||
|
"ttsUpstreamMaxRetries": 2,
|
||||||
|
"ttsUpstreamTimeoutMs": 285000,
|
||||||
|
"disableComputeRateLimit": true,
|
||||||
|
"computeParseBurstMax": 8,
|
||||||
|
"computeParseBurstWindowSec": 60,
|
||||||
|
"computeParseSustainedMax": 24,
|
||||||
|
"computeParseSustainedWindowSec": 600,
|
||||||
|
"maxUploadMb": 200,
|
||||||
|
"changelogFeedUrl": "https://docs.openreader.richardr.dev/changelog/manifest.json"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"slug": "default-openai",
|
||||||
|
"displayName": "Default (seeded)",
|
||||||
|
"providerType": "custom-openai",
|
||||||
|
"baseUrl": "http://localhost:8880/v1",
|
||||||
|
"apiKey": "api_key_optional",
|
||||||
|
"defaultModel": "kokoro",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider fallback behavior:
|
||||||
|
|
||||||
|
- If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped.
|
||||||
|
- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty.
|
||||||
|
|
||||||
|
Precedence summary:
|
||||||
|
|
||||||
|
- Runtime reads: admin DB runtime rows override built-in defaults.
|
||||||
|
- Seed input (`RUNTIME_SEED_JSON*`) only populates missing runtime rows on first boot; it does not overwrite existing/admin-edited rows.
|
||||||
|
- Provider bootstrap order: JSON `providers` section > `API_BASE`/`API_KEY` fallback > no provider bootstrap.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Admin Panel](../configure/admin-panel)
|
||||||
|
- [TTS Providers](../configure/tts-providers)
|
||||||
|
- [Local Development](../deploy/local-development)
|
||||||
|
- [Vercel Deployment](../deploy/vercel-deployment)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:compute": "vitest run --project compute-core --project compute-worker",
|
"test:compute": "vitest run --project compute-core --project compute-worker",
|
||||||
"test:ci-env": "CI=true playwright test",
|
|
||||||
"migrate": "node drizzle/scripts/migrate.mjs",
|
"migrate": "node drizzle/scripts/migrate.mjs",
|
||||||
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
|
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
|
||||||
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",
|
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
if (process.env.CI === 'true') {
|
|
||||||
const envCiPath = path.join(process.cwd(), '.env.ci');
|
|
||||||
if (fs.existsSync(envCiPath)) {
|
|
||||||
dotenv.config({ path: envCiPath, override: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,6 @@ import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
function loadEnvFiles() {
|
function loadEnvFiles() {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const isCi = isTrue(process.env.CI, false);
|
|
||||||
if (isCi) {
|
|
||||||
const envCiPath = path.join(cwd, '.env.ci');
|
|
||||||
if (fs.existsSync(envCiPath)) {
|
|
||||||
dotenv.config({ path: envCiPath });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envPath = path.join(cwd, '.env');
|
const envPath = path.join(cwd, '.env');
|
||||||
const envLocalPath = path.join(cwd, '.env.local');
|
const envLocalPath = path.join(cwd, '.env.local');
|
||||||
|
|
||||||
|
|
@ -46,6 +37,18 @@ function withDefault(value, fallback) {
|
||||||
return value && value.trim() ? value.trim() : fallback;
|
return value && value.trim() ? value.trim() : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireAuthEnv(env) {
|
||||||
|
const missing = [];
|
||||||
|
if (!env.AUTH_SECRET?.trim()) missing.push('AUTH_SECRET');
|
||||||
|
if (!env.BASE_URL?.trim()) missing.push('BASE_URL');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing required auth env vars: ${missing.join(', ')}. `
|
||||||
|
+ 'OpenReader v4 requires both AUTH_SECRET and BASE_URL at startup.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isPrivateIPv4(address) {
|
function isPrivateIPv4(address) {
|
||||||
if (!address) return false;
|
if (!address) return false;
|
||||||
if (address.startsWith('10.')) return true;
|
if (address.startsWith('10.')) return true;
|
||||||
|
|
@ -328,6 +331,7 @@ async function main() {
|
||||||
|
|
||||||
const runtimeEnv = { ...process.env };
|
const runtimeEnv = { ...process.env };
|
||||||
runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty');
|
runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty');
|
||||||
|
requireAuthEnv(runtimeEnv);
|
||||||
let weedProc = null;
|
let weedProc = null;
|
||||||
let weedExitPromise = Promise.resolve();
|
let weedExitPromise = Promise.resolve();
|
||||||
let natsProc = null;
|
let natsProc = null;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-loc
|
||||||
import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment';
|
import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment';
|
||||||
import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates';
|
import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates';
|
||||||
import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext';
|
import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext';
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|
||||||
import { createRangeCfi } from '@/lib/client/epub';
|
import { createRangeCfi } from '@/lib/client/epub';
|
||||||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
|
@ -94,7 +93,6 @@ export interface EpubDocumentState {
|
||||||
*/
|
*/
|
||||||
export function useEpubDocument(documentId?: string): EpubDocumentState {
|
export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
|
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
|
||||||
const { authEnabled } = useAuthConfig();
|
|
||||||
// Configuration context to get TTS settings
|
// Configuration context to get TTS settings
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
|
|
@ -372,7 +370,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
|
|
||||||
const handleLocationChanged = useEPUBLocationController({
|
const handleLocationChanged = useEPUBLocationController({
|
||||||
documentId,
|
documentId,
|
||||||
authEnabled,
|
|
||||||
isEpubSetOnceRef: isEPUBSetOnce,
|
isEpubSetOnceRef: isEPUBSetOnce,
|
||||||
shouldPauseRef,
|
shouldPauseRef,
|
||||||
setIsEpub: setIsEPUB,
|
setIsEpub: setIsEPUB,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
import { Providers } from '@/app/providers';
|
import { Providers } from '@/app/providers';
|
||||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -22,14 +22,12 @@ export const metadata: Metadata = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const authEnabled = isAuthEnabled();
|
|
||||||
const authBaseUrl = getAuthBaseUrl();
|
const authBaseUrl = getAuthBaseUrl();
|
||||||
const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled();
|
const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled();
|
||||||
const githubAuthEnabled = isGithubAuthEnabled();
|
const githubAuthEnabled = isGithubAuthEnabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers
|
<Providers
|
||||||
authEnabled={authEnabled}
|
|
||||||
authBaseUrl={authBaseUrl}
|
authBaseUrl={authBaseUrl}
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
|
|
|
||||||
|
|
@ -31,19 +31,12 @@ function SignInContent() {
|
||||||
const [rememberMe, setRememberMe] = useState(true);
|
const [rememberMe, setRememberMe] = useState(true);
|
||||||
const [sessionExpired, setSessionExpired] = useState(false);
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { authEnabled, baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig();
|
const { baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig();
|
||||||
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
||||||
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
||||||
|
|
||||||
const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous;
|
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 => {
|
const validateEmail = (email: string): boolean => {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
};
|
};
|
||||||
|
|
@ -121,10 +114,6 @@ function SignInContent() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!authEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|
@ -253,7 +242,7 @@ function SignInContent() {
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
By signing in, you agree to our{' '}
|
By signing in, you agree to our{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => showPrivacyModal({ authEnabled })}
|
onClick={() => showPrivacyModal()}
|
||||||
className="underline hover:text-foreground"
|
className="underline hover:text-foreground"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Input } from '@headlessui/react';
|
import { Button, Input } from '@headlessui/react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
@ -20,17 +20,10 @@ export default function SignUpPage() {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { authEnabled, baseUrl } = useAuthConfig();
|
const { baseUrl } = useAuthConfig();
|
||||||
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
||||||
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
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 => {
|
const validateEmail = (email: string): boolean => {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
};
|
};
|
||||||
|
|
@ -109,10 +102,6 @@ export default function SignUpPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!authEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enableUserSignups) {
|
if (!enableUserSignups) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
|
|
@ -262,7 +251,7 @@ export default function SignUpPage() {
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
By creating an account, you agree to our{' '}
|
By creating an account, you agree to our{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => showPrivacyModal({ authEnabled })}
|
onClick={() => showPrivacyModal()}
|
||||||
className="underline hover:text-foreground"
|
className="underline hover:text-foreground"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { auth } from '@/lib/server/auth/auth';
|
import { auth } from '@/lib/server/auth/auth';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
|
||||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
|
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
|
||||||
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
|
||||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
if (!isAuthEnabled() || !auth) {
|
|
||||||
return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const reqHeaders = await headers();
|
const reqHeaders = await headers();
|
||||||
|
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
|
|
@ -26,8 +26,7 @@ import {
|
||||||
ffprobeAudio,
|
ffprobeAudio,
|
||||||
} from '@/lib/server/audiobooks/chapters';
|
} from '@/lib/server/audiobooks/chapters';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
|
|
||||||
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
|
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
|
||||||
import { generateTTSBuffer } from '@/lib/server/tts/generate';
|
import { generateTTSBuffer } from '@/lib/server/tts/generate';
|
||||||
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||||
|
|
@ -285,29 +284,18 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { userId, authEnabled, user } = ctxOrRes;
|
const { user } = ctxOrRes;
|
||||||
|
const storageUserId = ctxOrRes.userId;
|
||||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
|
||||||
const bookId = data.bookId || randomUUID();
|
const bookId = data.bookId || randomUUID();
|
||||||
|
|
||||||
if (!isSafeId(bookId)) {
|
if (!isSafeId(bookId)) {
|
||||||
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
|
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
|
await db
|
||||||
.insert(audiobooks)
|
.insert(audiobooks)
|
||||||
.values({
|
.values({
|
||||||
|
|
@ -540,7 +528,7 @@ export async function POST(request: NextRequest) {
|
||||||
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
|
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (authEnabled && userId && ttsRateLimitEnabled) {
|
if (ttsRateLimitEnabled) {
|
||||||
const isAnonymous = Boolean(user?.isAnonymous);
|
const isAnonymous = Boolean(user?.isAnonymous);
|
||||||
const charCount = data.text.length;
|
const charCount = data.text.length;
|
||||||
const ip = getClientIp(request);
|
const ip = getClientIp(request);
|
||||||
|
|
@ -551,7 +539,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
|
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
|
||||||
{ id: userId, isAnonymous },
|
{ id: storageUserId, isAnonymous },
|
||||||
charCount,
|
charCount,
|
||||||
{
|
{
|
||||||
deviceId: device?.deviceId ?? null,
|
deviceId: device?.deviceId ?? null,
|
||||||
|
|
@ -614,6 +602,12 @@ export async function POST(request: NextRequest) {
|
||||||
testNamespace,
|
testNamespace,
|
||||||
},
|
},
|
||||||
request.signal,
|
request.signal,
|
||||||
|
{
|
||||||
|
ttsCacheMaxSizeBytes: runtimeConfig.ttsCacheMaxSizeBytes,
|
||||||
|
ttsCacheTtlMs: runtimeConfig.ttsCacheTtlMs,
|
||||||
|
ttsUpstreamMaxRetries: runtimeConfig.ttsUpstreamMaxRetries,
|
||||||
|
ttsUpstreamTimeoutMs: runtimeConfig.ttsUpstreamTimeoutMs,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-'));
|
workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-'));
|
||||||
|
|
@ -802,26 +796,20 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { userId, authEnabled } = ctxOrRes;
|
const storageUserId = ctxOrRes.userId;
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
|
||||||
const existingBookRows = await db
|
const existingBookRows = await db
|
||||||
.select({ userId: audiobooks.userId })
|
.select({ userId: audiobooks.userId })
|
||||||
.from(audiobooks)
|
.from(audiobooks)
|
||||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||||
const existingBookUserId = pickAudiobookOwner(
|
|
||||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
|
||||||
preferredUserId,
|
|
||||||
unclaimedUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existingBookUserId) {
|
if (existingBookRows.length === 0) {
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
|
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
|
||||||
const chapter = findChapterFileNameByIndex(
|
const chapter = findChapterFileNameByIndex(
|
||||||
objects.map((object) => object.fileName),
|
objects.map((object) => object.fileName),
|
||||||
chapterIndex,
|
chapterIndex,
|
||||||
|
|
@ -833,7 +821,7 @@ export async function GET(request: NextRequest) {
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(audiobookChapters.bookId, bookId),
|
eq(audiobookChapters.bookId, bookId),
|
||||||
eq(audiobookChapters.userId, existingBookUserId),
|
eq(audiobookChapters.userId, storageUserId),
|
||||||
eq(audiobookChapters.chapterIndex, chapterIndex),
|
eq(audiobookChapters.chapterIndex, chapterIndex),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -842,7 +830,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
try {
|
try {
|
||||||
buffer = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
|
buffer = await getAudiobookObjectBuffer(bookId, storageUserId, chapter.fileName, testNamespace);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingBlobError(error)) {
|
if (isMissingBlobError(error)) {
|
||||||
await db
|
await db
|
||||||
|
|
@ -850,7 +838,7 @@ export async function GET(request: NextRequest) {
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(audiobookChapters.bookId, bookId),
|
eq(audiobookChapters.bookId, bookId),
|
||||||
eq(audiobookChapters.userId, existingBookUserId),
|
eq(audiobookChapters.userId, storageUserId),
|
||||||
eq(audiobookChapters.chapterIndex, chapterIndex),
|
eq(audiobookChapters.chapterIndex, chapterIndex),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -899,22 +887,16 @@ export async function DELETE(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { userId, authEnabled } = ctxOrRes;
|
const storageUserId = ctxOrRes.userId;
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
|
||||||
const existingBookRows = await db
|
const existingBookRows = await db
|
||||||
.select({ userId: audiobooks.userId })
|
.select({ userId: audiobooks.userId })
|
||||||
.from(audiobooks)
|
.from(audiobooks)
|
||||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||||
const storageUserId = pickAudiobookOwner(
|
|
||||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
|
||||||
preferredUserId,
|
|
||||||
unclaimedUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!storageUserId) {
|
if (existingBookRows.length === 0) {
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { spawn } from 'child_process';
|
||||||
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
|
|
@ -23,9 +23,8 @@ import {
|
||||||
ffprobeAudio,
|
ffprobeAudio,
|
||||||
} from '@/lib/server/audiobooks/chapters';
|
} from '@/lib/server/audiobooks/chapters';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
|
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
|
||||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
|
|
||||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -168,25 +167,19 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { userId, authEnabled } = ctxOrRes;
|
const storageUserId = ctxOrRes.userId;
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
|
||||||
const existingBookRows = await db
|
const existingBookRows = await db
|
||||||
.select({ userId: audiobooks.userId })
|
.select({ userId: audiobooks.userId })
|
||||||
.from(audiobooks)
|
.from(audiobooks)
|
||||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||||
const existingBookUserId = pickAudiobookOwner(
|
if (existingBookRows.length === 0) {
|
||||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
|
||||||
preferredUserId,
|
|
||||||
unclaimedUserId,
|
|
||||||
);
|
|
||||||
if (!existingBookUserId) {
|
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
|
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
|
||||||
const objectNames = objects.map((item) => item.fileName);
|
const objectNames = objects.map((item) => item.fileName);
|
||||||
const chapters = listChapterObjects(objectNames);
|
const chapters = listChapterObjects(objectNames);
|
||||||
if (chapters.length === 0) {
|
if (chapters.length === 0) {
|
||||||
|
|
@ -205,9 +198,9 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
if (objectNames.includes(completeName) && objectNames.includes(manifestName)) {
|
if (objectNames.includes(completeName) && objectNames.includes(manifestName)) {
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, manifestName, testNamespace)).toString('utf8'));
|
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, manifestName, testNamespace)).toString('utf8'));
|
||||||
if (JSON.stringify(manifest) === JSON.stringify(signature)) {
|
if (JSON.stringify(manifest) === JSON.stringify(signature)) {
|
||||||
const cached = await getAudiobookObjectBuffer(bookId, existingBookUserId, completeName, testNamespace);
|
const cached = await getAudiobookObjectBuffer(bookId, storageUserId, completeName, testNamespace);
|
||||||
return new NextResponse(streamBuffer(cached), {
|
return new NextResponse(streamBuffer(cached), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': chapterFileMimeType(format),
|
'Content-Type': chapterFileMimeType(format),
|
||||||
|
|
@ -220,14 +213,14 @@ export async function GET(request: NextRequest) {
|
||||||
// Force regeneration below.
|
// Force regeneration below.
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteAudiobookObject(bookId, existingBookUserId, completeName, testNamespace).catch(() => {});
|
await deleteAudiobookObject(bookId, storageUserId, completeName, testNamespace).catch(() => {});
|
||||||
await deleteAudiobookObject(bookId, existingBookUserId, manifestName, testNamespace).catch(() => {});
|
await deleteAudiobookObject(bookId, storageUserId, manifestName, testNamespace).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const chapterRows = await db
|
const chapterRows = await db
|
||||||
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
|
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
|
||||||
.from(audiobookChapters)
|
.from(audiobookChapters)
|
||||||
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
|
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId)));
|
||||||
const durationByIndex = new Map<number, number>();
|
const durationByIndex = new Map<number, number>();
|
||||||
for (const row of chapterRows) {
|
for (const row of chapterRows) {
|
||||||
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
|
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
|
||||||
|
|
@ -241,7 +234,7 @@ export async function GET(request: NextRequest) {
|
||||||
const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = [];
|
const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = [];
|
||||||
for (const chapter of chapters) {
|
for (const chapter of chapters) {
|
||||||
const localPath = join(workDir, chapter.fileName);
|
const localPath = join(workDir, chapter.fileName);
|
||||||
const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
|
const bytes = await getAudiobookObjectBuffer(bookId, storageUserId, chapter.fileName, testNamespace);
|
||||||
await writeFile(localPath, bytes);
|
await writeFile(localPath, bytes);
|
||||||
|
|
||||||
let duration = 0;
|
let duration = 0;
|
||||||
|
|
@ -356,10 +349,10 @@ export async function GET(request: NextRequest) {
|
||||||
await ensurePositiveDuration(outputPath, request.signal);
|
await ensurePositiveDuration(outputPath, request.signal);
|
||||||
|
|
||||||
const outputBytes = await readFile(outputPath);
|
const outputBytes = await readFile(outputPath);
|
||||||
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
|
await putAudiobookObject(bookId, storageUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
|
||||||
await putAudiobookObject(
|
await putAudiobookObject(
|
||||||
bookId,
|
bookId,
|
||||||
existingBookUserId,
|
storageUserId,
|
||||||
manifestName,
|
manifestName,
|
||||||
Buffer.from(JSON.stringify(signature, null, 2), 'utf8'),
|
Buffer.from(JSON.stringify(signature, null, 2), 'utf8'),
|
||||||
'application/json; charset=utf-8',
|
'application/json; charset=utf-8',
|
||||||
|
|
@ -404,21 +397,15 @@ export async function DELETE(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
const { userId, authEnabled } = ctxOrRes;
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const storageUserId = ctxOrRes.userId;
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
|
||||||
const existingBookRows = await db
|
const existingBookRows = await db
|
||||||
.select({ userId: audiobooks.userId })
|
.select({ userId: audiobooks.userId })
|
||||||
.from(audiobooks)
|
.from(audiobooks)
|
||||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||||
const storageUserId = pickAudiobookOwner(
|
|
||||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
|
||||||
preferredUserId,
|
|
||||||
unclaimedUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!storageUserId) {
|
if (existingBookRows.length === 0) {
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
|
|
@ -7,8 +7,7 @@ import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } fr
|
||||||
import { decodeChapterFileName } from '@/lib/server/audiobooks/chapters';
|
import { decodeChapterFileName } from '@/lib/server/audiobooks/chapters';
|
||||||
import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobooks/prune';
|
import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobooks/prune';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
|
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
|
@ -73,22 +72,16 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const { userId, authEnabled } = ctxOrRes;
|
const storageUserId = ctxOrRes.userId;
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
|
||||||
const existingBookRows = await db
|
const existingBookRows = await db
|
||||||
.select({ userId: audiobooks.userId })
|
.select({ userId: audiobooks.userId })
|
||||||
.from(audiobooks)
|
.from(audiobooks)
|
||||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||||
const existingBookUserId = pickAudiobookOwner(
|
|
||||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
|
||||||
preferredUserId,
|
|
||||||
unclaimedUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existingBookUserId) {
|
if (existingBookRows.length === 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
chapters: [],
|
chapters: [],
|
||||||
exists: false,
|
exists: false,
|
||||||
|
|
@ -98,20 +91,20 @@ export async function GET(request: NextRequest) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
|
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
|
||||||
const objectNames = objects.map((object) => object.fileName);
|
const objectNames = objects.map((object) => object.fileName);
|
||||||
const chapterObjects = listChapterObjects(objectNames);
|
const chapterObjects = listChapterObjects(objectNames);
|
||||||
|
|
||||||
await pruneAudiobookChaptersNotOnDisk(
|
await pruneAudiobookChaptersNotOnDisk(
|
||||||
bookId,
|
bookId,
|
||||||
existingBookUserId,
|
storageUserId,
|
||||||
chapterObjects.map((chapter) => chapter.index),
|
chapterObjects.map((chapter) => chapter.index),
|
||||||
);
|
);
|
||||||
|
|
||||||
const chapterRows = await db
|
const chapterRows = await db
|
||||||
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
|
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
|
||||||
.from(audiobookChapters)
|
.from(audiobookChapters)
|
||||||
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
|
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId)));
|
||||||
const durationByIndex = new Map<number, number>();
|
const durationByIndex = new Map<number, number>();
|
||||||
for (const row of chapterRows) {
|
for (const row of chapterRows) {
|
||||||
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
|
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
|
||||||
|
|
@ -128,7 +121,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
let settings: AudiobookGenerationSettings | null = null;
|
let settings: AudiobookGenerationSettings | null = null;
|
||||||
try {
|
try {
|
||||||
settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
|
settings = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingBlobError(error)) throw error;
|
if (!isMissingBlobError(error)) throw error;
|
||||||
settings = null;
|
settings = null;
|
||||||
|
|
@ -139,7 +132,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
// Deleting the audiobook row cascades to audiobookChapters via bookFk
|
// Deleting the audiobook row cascades to audiobookChapters via bookFk
|
||||||
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId)));
|
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
chapters: [],
|
chapters: [],
|
||||||
exists: false,
|
exists: false,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { auth } from "@/lib/server/auth/auth"; // path to your auth file
|
import { auth } from "@/lib/server/auth/auth"; // path to your auth file
|
||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
const handlers = auth
|
const handlers = toNextJsHandler(auth);
|
||||||
? toNextJsHandler(auth)
|
|
||||||
: {
|
|
||||||
POST: async () => new Response("Auth disabled", { status: 404 }),
|
|
||||||
GET: async () => new Response("Auth disabled", { status: 404 })
|
|
||||||
};
|
|
||||||
|
|
||||||
export const { POST, GET } = handlers;
|
export const { POST, GET } = handlers;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'
|
||||||
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||||
import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state';
|
import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state';
|
||||||
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { createRequestLogger, hashForLog } from '@/lib/server/logger';
|
import { createRequestLogger, hashForLog } from '@/lib/server/logger';
|
||||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
@ -147,6 +147,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
|
|
||||||
const authCtxOrRes = await requireAuthContext(req);
|
const authCtxOrRes = await requireAuthContext(req);
|
||||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||||
|
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const params = await ctx.params;
|
const params = await ctx.params;
|
||||||
const id = (params.id || '').trim().toLowerCase();
|
const id = (params.id || '').trim().toLowerCase();
|
||||||
|
|
@ -159,10 +160,9 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = authCtxOrRes.userId;
|
||||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
|
||||||
const storageUserIdHash = hashForLog(storageUserId);
|
const storageUserIdHash = hashForLog(storageUserId);
|
||||||
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
const allowedUserIds = [storageUserId];
|
||||||
|
|
||||||
const row = await loadPreferredRow({
|
const row = await loadPreferredRow({
|
||||||
documentId: id,
|
documentId: id,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
stringifyDocumentParseState,
|
stringifyDocumentParseState,
|
||||||
} from '@/lib/server/documents/parse-state';
|
} from '@/lib/server/documents/parse-state';
|
||||||
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { checkJobRate, recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
|
import { checkJobRate, recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
|
||||||
import { buildComputeRateLimitedResponse } from '@/lib/server/rate-limit/problem-response';
|
import { buildComputeRateLimitedResponse } from '@/lib/server/rate-limit/problem-response';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
|
|
@ -196,6 +196,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
|
|
||||||
const authCtxOrRes = await requireAuthContext(req);
|
const authCtxOrRes = await requireAuthContext(req);
|
||||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||||
|
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const params = await ctx.params;
|
const params = await ctx.params;
|
||||||
const id = (params.id || '').trim().toLowerCase();
|
const id = (params.id || '').trim().toLowerCase();
|
||||||
|
|
@ -206,9 +207,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = authCtxOrRes.userId;
|
||||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
const allowedUserIds = [storageUserId];
|
||||||
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const rows = await loadRows({ documentId: id, allowedUserIds });
|
const rows = await loadRows({ documentId: id, allowedUserIds });
|
||||||
const row = pickPreferredRow(rows, storageUserId);
|
const row = pickPreferredRow(rows, storageUserId);
|
||||||
|
|
@ -347,6 +347,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
|
|
||||||
const authCtxOrRes = await requireAuthContext(req);
|
const authCtxOrRes = await requireAuthContext(req);
|
||||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||||
|
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const params = await ctx.params;
|
const params = await ctx.params;
|
||||||
const id = (params.id || '').trim().toLowerCase();
|
const id = (params.id || '').trim().toLowerCase();
|
||||||
|
|
@ -363,9 +364,8 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = authCtxOrRes.userId;
|
||||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
const allowedUserIds = [storageUserId];
|
||||||
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const rows = await loadRows({ documentId: id, allowedUserIds });
|
const rows = await loadRows({ documentId: id, allowedUserIds });
|
||||||
const row = pickPreferredRow(rows, storageUserId);
|
const row = pickPreferredRow(rows, storageUserId);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { documentSettings, documents } from '@/db/schema';
|
import { documentSettings, documents } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
|
||||||
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
|
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
|
||||||
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
||||||
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
@ -35,21 +34,19 @@ function parseStored(value: unknown): DocumentSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise<
|
async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise<
|
||||||
| { ownerUserId: string; allowedUserIds: string[] }
|
| { ownerUserId: string }
|
||||||
| Response
|
| Response
|
||||||
> {
|
> {
|
||||||
const authCtxOrRes = await requireAuthContext(req);
|
const authCtxOrRes = await requireAuthContext(req);
|
||||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||||
|
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const storageUserId = authCtxOrRes.userId;
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
|
||||||
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ userId: documents.userId })
|
.select({ userId: documents.userId })
|
||||||
.from(documents)
|
.from(documents)
|
||||||
.where(and(eq(documents.id, documentId), inArray(documents.userId, allowedUserIds)))
|
.where(and(eq(documents.id, documentId), eq(documents.userId, storageUserId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!rows[0]) {
|
if (!rows[0]) {
|
||||||
|
|
@ -58,7 +55,6 @@ async function resolveDocumentAccess(req: NextRequest, documentId: string): Prom
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ownerUserId: rows[0].userId,
|
ownerUserId: rows[0].userId,
|
||||||
allowedUserIds,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { documents } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { contentTypeForName } from '@/lib/server/storage/library-mount';
|
import { contentTypeForName } from '@/lib/server/storage/library-mount';
|
||||||
import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
|
import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
@ -34,11 +34,11 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
const allowedUserIds = [storageUserId];
|
||||||
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { db } from '@/db';
|
||||||
import { documents } from '@/db/schema';
|
import { documents } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore';
|
import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||||
|
|
@ -24,11 +24,11 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
const allowedUserIds = [storageUserId];
|
||||||
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
isPreviewableDocumentType,
|
isPreviewableDocumentType,
|
||||||
} from '@/lib/server/documents/previews';
|
} from '@/lib/server/documents/previews';
|
||||||
import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets';
|
import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -52,11 +52,11 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
const allowedUserIds = [storageUserId];
|
||||||
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { documents } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||||
import { isPreviewableDocumentType } from '@/lib/server/documents/previews';
|
import { isPreviewableDocumentType } from '@/lib/server/documents/previews';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
|
|
||||||
export function s3NotConfiguredResponse(): NextResponse {
|
export function s3NotConfiguredResponse(): NextResponse {
|
||||||
|
|
@ -37,15 +37,11 @@ export async function validatePreviewRequest(req: NextRequest): Promise<Validate
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return { errorResponse: ctxOrRes };
|
if (ctxOrRes instanceof Response) return { errorResponse: ctxOrRes };
|
||||||
|
if (!ctxOrRes.userId) return { errorResponse: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
const allowedUserIds = [storageUserId];
|
||||||
|
|
||||||
// Deduplicate allowedUserIds
|
|
||||||
const allowedUserIds = Array.from(new Set(
|
|
||||||
ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]
|
|
||||||
));
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { enqueueParsePdfJob } from '@/lib/server/jobs/user-pdf-layout-job';
|
||||||
import { recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
|
import { recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state';
|
import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
|
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
|
@ -82,11 +82,11 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const storageUserId = ctxOrRes.userId;
|
||||||
|
|
||||||
await ensureTempDir();
|
await ensureTempDir();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
parseDocumentParseState,
|
parseDocumentParseState,
|
||||||
stringifyDocumentParseState,
|
stringifyDocumentParseState,
|
||||||
} from '@/lib/server/documents/parse-state';
|
} from '@/lib/server/documents/parse-state';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import type { BaseDocument, DocumentType } from '@/types/documents';
|
import type { BaseDocument, DocumentType } from '@/types/documents';
|
||||||
|
|
||||||
|
|
@ -80,10 +80,10 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
|
||||||
|
|
||||||
const body = await req.json().catch(() => null);
|
const body = await req.json().catch(() => null);
|
||||||
const documentsData = parseDocumentPayload(body);
|
const documentsData = parseDocumentPayload(body);
|
||||||
|
|
@ -171,7 +171,7 @@ export async function POST(req: NextRequest) {
|
||||||
type: doc.type,
|
type: doc.type,
|
||||||
size: headSize,
|
size: headSize,
|
||||||
lastModified: doc.lastModified,
|
lastModified: doc.lastModified,
|
||||||
scope: storageUserId === unclaimedUserId ? 'unclaimed' : 'user',
|
scope: 'user',
|
||||||
});
|
});
|
||||||
|
|
||||||
await enqueueDocumentPreview(
|
await enqueueDocumentPreview(
|
||||||
|
|
@ -224,11 +224,10 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const allowedUserIds = [storageUserId];
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
|
||||||
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const idsParam = url.searchParams.get('ids');
|
const idsParam = url.searchParams.get('ids');
|
||||||
|
|
@ -259,21 +258,7 @@ export async function GET(req: NextRequest) {
|
||||||
parsedJsonKey: string | null;
|
parsedJsonKey: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const preferredById = new Map<string, (typeof rows)[number]>();
|
const results: BaseDocument[] = rows.map((doc) => {
|
||||||
for (const row of rows) {
|
|
||||||
const existing = preferredById.get(row.id);
|
|
||||||
if (!existing) {
|
|
||||||
preferredById.set(row.id, row);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isRowPrimary = row.userId === storageUserId;
|
|
||||||
const isExistingPrimary = existing.userId === storageUserId;
|
|
||||||
if (isRowPrimary && !isExistingPrimary) {
|
|
||||||
preferredById.set(row.id, row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: BaseDocument[] = Array.from(preferredById.values()).map((doc) => {
|
|
||||||
const type = normalizeDocumentType(doc.type, doc.name);
|
const type = normalizeDocumentType(doc.type, doc.name);
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
|
|
@ -283,7 +268,7 @@ export async function GET(req: NextRequest) {
|
||||||
type,
|
type,
|
||||||
parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null,
|
parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null,
|
||||||
parsedJsonKey: doc.parsedJsonKey,
|
parsedJsonKey: doc.parsedJsonKey,
|
||||||
scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user',
|
scope: 'user',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -306,37 +291,19 @@ export async function DELETE(req: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(req);
|
const ctxOrRes = await requireAuthContext(req);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const idsParam = url.searchParams.get('ids');
|
const idsParam = url.searchParams.get('ids');
|
||||||
const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim();
|
const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim();
|
||||||
|
if (scopeParam && scopeParam !== 'user') {
|
||||||
const wantsUnclaimed = scopeParam === 'unclaimed';
|
return NextResponse.json({ error: "Invalid scope. Expected 'user' (default)." }, { status: 400 });
|
||||||
const wantsUser = scopeParam === '' || scopeParam === 'user';
|
|
||||||
|
|
||||||
if (!wantsUser && !wantsUnclaimed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid scope. Expected 'user' (default) or 'unclaimed'." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctxOrRes.authEnabled && wantsUnclaimed && ctxOrRes.user?.isAnonymous) {
|
const targetUserIds = [storageUserId];
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUserIds = Array.from(
|
|
||||||
new Set(
|
|
||||||
[
|
|
||||||
...(wantsUser ? [storageUserId] : []),
|
|
||||||
...(wantsUnclaimed ? [unclaimedUserId] : []),
|
|
||||||
].filter(Boolean),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetUserIds.length === 0) {
|
if (targetUserIds.length === 0) {
|
||||||
return NextResponse.json({ success: true, deleted: 0 });
|
return NextResponse.json({ success: true, deleted: 0 });
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { NextResponse, type NextRequest } from 'next/server';
|
||||||
import { auth } from '@/lib/server/auth/auth';
|
import { auth } from '@/lib/server/auth/auth';
|
||||||
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
|
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
|
||||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||||
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
|
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
@ -27,22 +26,6 @@ export async function GET(req: NextRequest) {
|
||||||
});
|
});
|
||||||
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
|
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
|
||||||
|
|
||||||
// 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
|
// Get session from auth
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers()
|
headers: await headers()
|
||||||
|
|
@ -58,7 +41,6 @@ export async function GET(req: NextRequest) {
|
||||||
remainingChars: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
|
remainingChars: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
|
||||||
resetTimeMs,
|
resetTimeMs,
|
||||||
userType: 'unauthenticated',
|
userType: 'unauthenticated',
|
||||||
authEnabled: true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +71,6 @@ export async function GET(req: NextRequest) {
|
||||||
remainingChars: result.remainingChars,
|
remainingChars: result.remainingChars,
|
||||||
resetTimeMs: result.resetTimeMs,
|
resetTimeMs: result.resetTimeMs,
|
||||||
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
||||||
authEnabled: true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (device?.didCreate) {
|
if (device?.didCreate) {
|
||||||
|
|
|
||||||
|
|
@ -468,7 +468,7 @@ export async function POST(request: NextRequest) {
|
||||||
segmentId: segment.segmentId,
|
segmentId: segment.segmentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (scope.authEnabled && scope.userId && ttsRateLimitEnabled) {
|
if (ttsRateLimitEnabled) {
|
||||||
const charCount = segment.text.length;
|
const charCount = segment.text.length;
|
||||||
const ip = getClientIp(request);
|
const ip = getClientIp(request);
|
||||||
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
|
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
|
||||||
|
|
@ -641,7 +641,12 @@ export async function POST(request: NextRequest) {
|
||||||
apiKey: requestCreds.apiKey || 'none',
|
apiKey: requestCreds.apiKey || 'none',
|
||||||
baseUrl: requestCreds.baseUrl,
|
baseUrl: requestCreds.baseUrl,
|
||||||
testNamespace: scope.testNamespace,
|
testNamespace: scope.testNamespace,
|
||||||
}, request.signal);
|
}, request.signal, {
|
||||||
|
ttsCacheMaxSizeBytes: runtimeConfig.ttsCacheMaxSizeBytes,
|
||||||
|
ttsCacheTtlMs: runtimeConfig.ttsCacheTtlMs,
|
||||||
|
ttsUpstreamMaxRetries: runtimeConfig.ttsUpstreamMaxRetries,
|
||||||
|
ttsUpstreamTimeoutMs: runtimeConfig.ttsUpstreamTimeoutMs,
|
||||||
|
});
|
||||||
stageTimings.generateTtsMs = Date.now() - ttsStartedAt;
|
stageTimings.generateTtsMs = Date.now() - ttsStartedAt;
|
||||||
|
|
||||||
failedStage = 's3.put_audio';
|
failedStage = 's3.put_audio';
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { auth } from '@/lib/server/auth/auth';
|
import { auth } from '@/lib/server/auth/auth';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
|
||||||
import { listAdminProviders, toPublic } from '@/lib/server/admin/providers';
|
import { listAdminProviders, toPublic } from '@/lib/server/admin/providers';
|
||||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public list of admin-configured TTS providers. Auth-gated when auth is
|
* Public list of admin-configured TTS providers. Auth-gated and never returns
|
||||||
* enabled. Never returns keys, base URLs, or ciphertext — only the data the
|
* keys, base URLs, or ciphertext — only the data the
|
||||||
* client needs to render the provider picker.
|
* client needs to render the provider picker.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
if (isAuthEnabled()) {
|
const session = await auth.api.getSession({ headers: req.headers });
|
||||||
const session = await auth?.api.getSession({ headers: req.headers });
|
if (!session?.user) {
|
||||||
if (!session?.user) {
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,12 @@ import { AuthLoader } from '@/components/auth/AuthLoader';
|
||||||
|
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
authEnabled: boolean;
|
|
||||||
authBaseUrl: string | null;
|
authBaseUrl: string | null;
|
||||||
allowAnonymousAuthSessions: boolean;
|
allowAnonymousAuthSessions: boolean;
|
||||||
githubAuthEnabled: boolean;
|
githubAuthEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }: ProvidersProps) {
|
export function Providers({ children, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }: ProvidersProps) {
|
||||||
const [queryClient] = useState(() => new QueryClient({
|
const [queryClient] = useState(() => new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
|
@ -30,7 +29,6 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RuntimeConfigProvider>
|
<RuntimeConfigProvider>
|
||||||
<AuthRateLimitProvider
|
<AuthRateLimitProvider
|
||||||
authEnabled={authEnabled}
|
|
||||||
authBaseUrl={authBaseUrl}
|
authBaseUrl={authBaseUrl}
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
|
|
|
||||||
|
|
@ -150,15 +150,13 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
||||||
* Function to programmatically show the privacy popup
|
* Function to programmatically show the privacy popup
|
||||||
* This can be called from signin/signup components
|
* This can be called from signin/signup components
|
||||||
*/
|
*/
|
||||||
export function showPrivacyModal(options?: { authEnabled?: boolean }): void {
|
export function showPrivacyModal(): void {
|
||||||
// Create a temporary container for the popup
|
// Create a temporary container for the popup
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.id = 'privacy-modal-container';
|
container.id = 'privacy-modal-container';
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
void options;
|
|
||||||
|
|
||||||
// Import React and render the popup
|
// Import React and render the popup
|
||||||
import('react-dom/client').then(({ createRoot }) => {
|
import('react-dom/client').then(({ createRoot }) => {
|
||||||
import('react').then((React) => {
|
import('react').then((React) => {
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ export function SettingsModal({
|
||||||
const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false);
|
const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false);
|
||||||
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
|
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
|
||||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||||
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
|
const { baseUrl: authBaseUrl } = useAuthConfig();
|
||||||
const { data: session } = useAuthSession();
|
const { data: session } = useAuthSession();
|
||||||
const { changelogOpenSignal } = useOnboardingFlow();
|
const { changelogOpenSignal } = useOnboardingFlow();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -470,11 +470,10 @@ export function SettingsModal({
|
||||||
if (section.id === 'api' && !enableTTSProvidersTab) {
|
if (section.id === 'api' && !enableTTSProvidersTab) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (section.authOnly && !authEnabled) return false;
|
|
||||||
if (section.adminOnly && !isAdmin) return false;
|
if (section.adminOnly && !isAdmin) return false;
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[authEnabled, isAdmin, enableTTSProvidersTab]
|
[isAdmin, enableTTSProvidersTab]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -556,14 +555,12 @@ export function SettingsModal({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{authEnabled && (
|
<Button
|
||||||
<Button
|
onClick={() => showPrivacyModal()}
|
||||||
onClick={() => showPrivacyModal({ authEnabled })}
|
className="text-sm font-medium text-muted hover:text-accent transition-colors"
|
||||||
className="text-sm font-medium text-muted hover:text-accent transition-colors"
|
>
|
||||||
>
|
Privacy
|
||||||
Privacy
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1161,15 +1158,6 @@ export function SettingsModal({
|
||||||
>
|
>
|
||||||
Clear cache
|
Clear cache
|
||||||
</Button>
|
</Button>
|
||||||
{enableDestructiveDelete && !authEnabled && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowDeleteDocsConfirm(true)}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={buttonClass({ variant: 'danger', size: 'md' })}
|
|
||||||
>
|
|
||||||
Delete all data
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1208,7 +1196,7 @@ export function SettingsModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Account Section */}
|
{/* Account Section */}
|
||||||
{activeSection === 'account' && authEnabled && (
|
{activeSection === 'account' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Session info */}
|
{/* Session info */}
|
||||||
<div className="rounded-lg bg-background border border-offbase p-4 space-y-2">
|
<div className="rounded-lg bg-background border border-offbase p-4 space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||||
|
|
||||||
type RuntimeConfigSource = 'env-seed' | 'admin' | 'default';
|
type RuntimeConfigSource = 'json-seed' | 'env-seed' | 'admin' | 'default';
|
||||||
|
|
||||||
interface SettingsResponse {
|
interface SettingsResponse {
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
|
|
@ -506,6 +506,71 @@ export function AdminFeaturesPanel() {
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="TTS upstream"
|
||||||
|
subtitle="Server-side retry, timeout, and cache controls for TTS generation."
|
||||||
|
action={<Badge tone="foreground">Upstream</Badge>}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-xs font-medium text-foreground">Retry attempts</label>
|
||||||
|
{renderSource('ttsUpstreamMaxRetries')}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
className={inputClass}
|
||||||
|
value={String(draft.ttsUpstreamMaxRetries ?? '')}
|
||||||
|
onChange={(event) => updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-xs font-medium text-foreground">Upstream timeout (ms)</label>
|
||||||
|
{renderSource('ttsUpstreamTimeoutMs')}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
className={inputClass}
|
||||||
|
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
|
||||||
|
onChange={(event) => updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-xs font-medium text-foreground">Audio cache size (bytes)</label>
|
||||||
|
{renderSource('ttsCacheMaxSizeBytes')}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
className={inputClass}
|
||||||
|
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
|
||||||
|
onChange={(event) => updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-xs font-medium text-foreground">Audio cache TTL (ms)</label>
|
||||||
|
{renderSource('ttsCacheTtlMs')}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
className={inputClass}
|
||||||
|
value={String(draft.ttsCacheTtlMs ?? '')}
|
||||||
|
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
{dirty.size > 0
|
{dirty.size > 0
|
||||||
|
|
@ -600,6 +665,8 @@ function SourceBadge({
|
||||||
)}
|
)}
|
||||||
{dirty ? (
|
{dirty ? (
|
||||||
<Badge tone="accent">Modified</Badge>
|
<Badge tone="accent">Modified</Badge>
|
||||||
|
) : source === 'json-seed' ? (
|
||||||
|
<Badge tone="muted">from seed</Badge>
|
||||||
) : source === 'env-seed' ? (
|
) : source === 'env-seed' ? (
|
||||||
<Badge tone="muted">from env</Badge>
|
<Badge tone="muted">from env</Badge>
|
||||||
) : source === 'admin' ? (
|
) : source === 'admin' ? (
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ function isRateLimited(info: ErrorInfo | null): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthLoader({ children }: { children: ReactNode }) {
|
export function AuthLoader({ children }: { children: ReactNode }) {
|
||||||
const { authEnabled, baseUrl, allowAnonymousAuthSessions } = useAuthConfig();
|
const { baseUrl, allowAnonymousAuthSessions } = useAuthConfig();
|
||||||
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
||||||
const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession();
|
const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -109,11 +109,10 @@ export function AuthLoader({ children }: { children: ReactNode }) {
|
||||||
attemptedForNullSessionRef.current = false;
|
attemptedForNullSessionRef.current = false;
|
||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
setIsRedirecting(false);
|
setIsRedirecting(false);
|
||||||
}, [authEnabled, baseUrl, allowAnonymousAuthSessions, pathname]);
|
}, [baseUrl, allowAnonymousAuthSessions, pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
if (!authEnabled) return;
|
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
|
|
@ -225,7 +224,6 @@ export function AuthLoader({ children }: { children: ReactNode }) {
|
||||||
}, [
|
}, [
|
||||||
session,
|
session,
|
||||||
isPending,
|
isPending,
|
||||||
authEnabled,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
allowAnonymousAuthSessions,
|
allowAnonymousAuthSessions,
|
||||||
refreshRateLimit,
|
refreshRateLimit,
|
||||||
|
|
@ -236,17 +234,16 @@ export function AuthLoader({ children }: { children: ReactNode }) {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authEnabled) return;
|
|
||||||
if (sessionError) {
|
if (sessionError) {
|
||||||
console.warn('[AuthLoader] useSession error', sessionError);
|
console.warn('[AuthLoader] useSession error', sessionError);
|
||||||
}
|
}
|
||||||
}, [authEnabled, sessionError]);
|
}, [sessionError]);
|
||||||
|
|
||||||
const shouldBlockForProtectedNoSession =
|
const shouldBlockForProtectedNoSession =
|
||||||
authEnabled && !allowAnonymousAuthSessions && !isAuthPage && !session;
|
!allowAnonymousAuthSessions && !isAuthPage && !session;
|
||||||
const shouldBlockForDisallowedAnonymous =
|
const shouldBlockForDisallowedAnonymous =
|
||||||
authEnabled && !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous);
|
!allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous);
|
||||||
const isLoading = authEnabled && (
|
const isLoading = (
|
||||||
(allowAnonymousAuthSessions && (isPending || isAutoLoggingIn || !session)) ||
|
(allowAnonymousAuthSessions && (isPending || isAutoLoggingIn || !session)) ||
|
||||||
(!allowAnonymousAuthSessions && !isAuthPage && (
|
(!allowAnonymousAuthSessions && !isAuthPage && (
|
||||||
isPending || isRedirecting || shouldBlockForProtectedNoSession || shouldBlockForDisallowedAnonymous
|
isPending || isRedirecting || shouldBlockForProtectedNoSession || shouldBlockForDisallowedAnonymous
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,10 @@ interface RateLimitBannerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
|
export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
|
||||||
const { status, isAtLimit, timeUntilReset, authEnabled } = useAuthRateLimit();
|
const { status, isAtLimit, timeUntilReset } = useAuthRateLimit();
|
||||||
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
||||||
|
|
||||||
// Don't show banner if auth is not enabled or if not at limit
|
if (!status || !isAtLimit) {
|
||||||
if (!authEnabled || !status?.authEnabled || !isAtLimit) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,10 +50,9 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
|
||||||
* Compact version for inline display
|
* Compact version for inline display
|
||||||
*/
|
*/
|
||||||
export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
|
export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
|
||||||
const { status, isAtLimit, authEnabled } = useAuthRateLimit();
|
const { status, isAtLimit } = useAuthRateLimit();
|
||||||
|
|
||||||
// Don't show if auth is not enabled
|
if (!status) {
|
||||||
if (!authEnabled || !status?.authEnabled) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@ export function UserMenu({
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: UserMenuVariant;
|
variant?: UserMenuVariant;
|
||||||
}) {
|
}) {
|
||||||
const { authEnabled, baseUrl } = useAuthConfig();
|
const { baseUrl } = useAuthConfig();
|
||||||
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
const enableUserSignups = useFeatureFlag('enableUserSignups');
|
||||||
const { data: session, isPending } = useAuthSession();
|
const { data: session, isPending } = useAuthSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (!authEnabled || isPending) return null;
|
if (isPending) return null;
|
||||||
|
|
||||||
const handleDisconnectAccount = async () => {
|
const handleDisconnectAccount = async () => {
|
||||||
const client = getAuthClient(baseUrl);
|
const client = getAuthClient(baseUrl);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { Button } from '@headlessui/react';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
import type { DocumentListDocument, IconSize } from '@/types/documents';
|
import type { DocumentListDocument, IconSize } from '@/types/documents';
|
||||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|
||||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||||
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
|
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
|
||||||
|
|
||||||
|
|
@ -69,13 +67,10 @@ export function DocumentTile({
|
||||||
onDelete,
|
onDelete,
|
||||||
onMergeIntoFolder,
|
onMergeIntoFolder,
|
||||||
}: DocumentTileProps) {
|
}: DocumentTileProps) {
|
||||||
const { authEnabled } = useAuthConfig();
|
|
||||||
const { data: session } = useAuthSession();
|
|
||||||
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
||||||
const selection = useDocumentSelection();
|
const selection = useDocumentSelection();
|
||||||
|
|
||||||
const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous);
|
const showDeleteButton = true;
|
||||||
const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed');
|
|
||||||
const isSelected = selection.isSelected(doc);
|
const isSelected = selection.isSelected(doc);
|
||||||
const isInFolder = Boolean(doc.folderId);
|
const isInFolder = Boolean(doc.folderId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,10 @@ export interface RateLimitStatus {
|
||||||
remainingChars: number;
|
remainingChars: number;
|
||||||
resetTimeMs: number;
|
resetTimeMs: number;
|
||||||
userType: 'anonymous' | 'authenticated' | 'unauthenticated';
|
userType: 'anonymous' | 'authenticated' | 'unauthenticated';
|
||||||
authEnabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthRateLimitContextType {
|
interface AuthRateLimitContextType {
|
||||||
// Auth Config
|
// Auth Config
|
||||||
authEnabled: boolean;
|
|
||||||
authBaseUrl: string | null;
|
authBaseUrl: string | null;
|
||||||
allowAnonymousAuthSessions: boolean;
|
allowAnonymousAuthSessions: boolean;
|
||||||
githubAuthEnabled: boolean;
|
githubAuthEnabled: boolean;
|
||||||
|
|
@ -45,8 +43,8 @@ export function useAuthRateLimit(): AuthRateLimitContextType {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthConfig() {
|
export function useAuthConfig() {
|
||||||
const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit();
|
const { authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit();
|
||||||
return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled };
|
return { baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRateLimit() {
|
export function useRateLimit() {
|
||||||
|
|
@ -87,7 +85,6 @@ function parseRateLimitStatus(raw: unknown): RateLimitStatus | null {
|
||||||
remainingChars: Number(data.remainingChars ?? 0),
|
remainingChars: Number(data.remainingChars ?? 0),
|
||||||
resetTimeMs: coerceTimestampMs(data.resetTimeMs ?? data.resetTime, nextUtcMidnightTimestampMs()),
|
resetTimeMs: coerceTimestampMs(data.resetTimeMs ?? data.resetTime, nextUtcMidnightTimestampMs()),
|
||||||
userType,
|
userType,
|
||||||
authEnabled: Boolean(data.authEnabled),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +102,6 @@ export function formatCharCount(count: number): string {
|
||||||
|
|
||||||
interface AuthRateLimitProviderProps {
|
interface AuthRateLimitProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
authEnabled: boolean;
|
|
||||||
authBaseUrl: string | null;
|
authBaseUrl: string | null;
|
||||||
allowAnonymousAuthSessions: boolean;
|
allowAnonymousAuthSessions: boolean;
|
||||||
githubAuthEnabled: boolean;
|
githubAuthEnabled: boolean;
|
||||||
|
|
@ -115,7 +111,6 @@ const RATE_LIMIT_QUERY_KEY = ['rate-limit-status'] as const;
|
||||||
|
|
||||||
export function AuthRateLimitProvider({
|
export function AuthRateLimitProvider({
|
||||||
children,
|
children,
|
||||||
authEnabled,
|
|
||||||
authBaseUrl,
|
authBaseUrl,
|
||||||
allowAnonymousAuthSessions,
|
allowAnonymousAuthSessions,
|
||||||
githubAuthEnabled,
|
githubAuthEnabled,
|
||||||
|
|
@ -140,26 +135,13 @@ export function AuthRateLimitProvider({
|
||||||
}
|
}
|
||||||
return parseRateLimitStatus(await response.json());
|
return parseRateLimitStatus(await response.json());
|
||||||
},
|
},
|
||||||
enabled: authEnabled,
|
enabled: true,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = authEnabled
|
const status = queryStatus ?? null;
|
||||||
? (queryStatus ?? null)
|
const loading = isPending || isFetching;
|
||||||
: {
|
const error = queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null;
|
||||||
allowed: true,
|
|
||||||
currentCount: 0,
|
|
||||||
// Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
|
|
||||||
limit: Number.MAX_SAFE_INTEGER,
|
|
||||||
remainingChars: Number.MAX_SAFE_INTEGER,
|
|
||||||
resetTimeMs: nextUtcMidnightTimestampMs(),
|
|
||||||
userType: 'unauthenticated' as const,
|
|
||||||
authEnabled: false,
|
|
||||||
};
|
|
||||||
const loading = authEnabled ? (isPending || isFetching) : false;
|
|
||||||
const error = authEnabled
|
|
||||||
? (queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queryError) return;
|
if (!queryError) return;
|
||||||
|
|
@ -167,15 +149,13 @@ export function AuthRateLimitProvider({
|
||||||
}, [queryError]);
|
}, [queryError]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!authEnabled) return;
|
|
||||||
await refetch();
|
await refetch();
|
||||||
}, [authEnabled, refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : '';
|
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : '';
|
||||||
const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false;
|
const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false;
|
||||||
|
|
||||||
const incrementCount = useCallback((charCount: number) => {
|
const incrementCount = useCallback((charCount: number) => {
|
||||||
if (!authEnabled) return;
|
|
||||||
queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prevStatus) => {
|
queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prevStatus) => {
|
||||||
if (!prevStatus) return prevStatus;
|
if (!prevStatus) return prevStatus;
|
||||||
|
|
||||||
|
|
@ -189,7 +169,7 @@ export function AuthRateLimitProvider({
|
||||||
allowed: newRemainingChars > 0
|
allowed: newRemainingChars > 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [authEnabled, queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const onTTSStart = useCallback(() => {
|
const onTTSStart = useCallback(() => {
|
||||||
pendingTTSRef.current += 1;
|
pendingTTSRef.current += 1;
|
||||||
|
|
@ -225,7 +205,6 @@ export function AuthRateLimitProvider({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const contextValue: AuthRateLimitContextType = {
|
const contextValue: AuthRateLimitContextType = {
|
||||||
authEnabled,
|
|
||||||
authBaseUrl,
|
authBaseUrl,
|
||||||
allowAnonymousAuthSessions,
|
allowAnonymousAuthSessions,
|
||||||
githubAuthEnabled,
|
githubAuthEnabled,
|
||||||
|
|
@ -239,7 +218,6 @@ export function AuthRateLimitProvider({
|
||||||
onTTSStart,
|
onTTSStart,
|
||||||
onTTSComplete,
|
onTTSComplete,
|
||||||
triggerRateLimit: () => {
|
triggerRateLimit: () => {
|
||||||
if (!authEnabled) return;
|
|
||||||
queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prev) =>
|
queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prev) =>
|
||||||
prev ? { ...prev, remainingChars: 0, allowed: false } : null,
|
prev ? { ...prev, remainingChars: 0, allowed: false } : null,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/sha
|
||||||
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
|
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
|
||||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
|
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
|
||||||
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
import { applyConfigUpdate } from '@/lib/client/config/updates';
|
||||||
|
|
@ -70,7 +69,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const didRunStartupMigrations = useRef(false);
|
const didRunStartupMigrations = useRef(false);
|
||||||
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
||||||
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
|
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
|
||||||
const { authEnabled } = useAuthConfig();
|
|
||||||
const { providers: sharedProviders } = useSharedProviders();
|
const { providers: sharedProviders } = useSharedProviders();
|
||||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||||
|
|
@ -83,7 +81,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
}, [sharedProviders]);
|
}, [sharedProviders]);
|
||||||
|
|
||||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||||
if (!authEnabled || sessionKey === 'no-session') return;
|
if (sessionKey === 'no-session') return;
|
||||||
|
|
||||||
const syncedPatch: SyncedPreferencesPatch = {};
|
const syncedPatch: SyncedPreferencesPatch = {};
|
||||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||||
|
|
@ -94,7 +92,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
if (Object.keys(syncedPatch).length === 0) return;
|
if (Object.keys(syncedPatch).length === 0) return;
|
||||||
scheduleUserPreferencesSync(syncedPatch, sessionKey);
|
scheduleUserPreferencesSync(syncedPatch, sessionKey);
|
||||||
}, [authEnabled, sessionKey]);
|
}, [sessionKey]);
|
||||||
|
|
||||||
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
|
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -157,7 +155,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
}, [isDBReady]);
|
}, [isDBReady]);
|
||||||
|
|
||||||
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
|
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
|
||||||
if (!isDBReady || !authEnabled) return;
|
if (!isDBReady) return;
|
||||||
try {
|
try {
|
||||||
const remote = await getUserPreferences({ signal });
|
const remote = await getUserPreferences({ signal });
|
||||||
if (!remote?.hasStoredPreferences) return;
|
if (!remote?.hasStoredPreferences) return;
|
||||||
|
|
@ -167,20 +165,20 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
if ((error as Error)?.name === 'AbortError') return;
|
if ((error as Error)?.name === 'AbortError') return;
|
||||||
console.warn('Failed to load synced preferences:', error);
|
console.warn('Failed to load synced preferences:', error);
|
||||||
}
|
}
|
||||||
}, [isDBReady, authEnabled]);
|
}, [isDBReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDBReady || !authEnabled || isSessionPending) return;
|
if (!isDBReady || isSessionPending) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
|
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
|
||||||
if ((error as Error)?.name === 'AbortError') return;
|
if ((error as Error)?.name === 'AbortError') return;
|
||||||
console.warn('Synced preferences refresh failed:', error);
|
console.warn('Synced preferences refresh failed:', error);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
|
}, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDBReady || !authEnabled) return;
|
if (!isDBReady) return;
|
||||||
let activeController: AbortController | null = null;
|
let activeController: AbortController | null = null;
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
if (activeController) activeController.abort();
|
if (activeController) activeController.abort();
|
||||||
|
|
@ -195,7 +193,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
window.removeEventListener('focus', onFocus);
|
window.removeEventListener('focus', onFocus);
|
||||||
if (activeController) activeController.abort();
|
if (activeController) activeController.abort();
|
||||||
};
|
};
|
||||||
}, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]);
|
}, [isDBReady, refreshSyncedPreferencesFromServer]);
|
||||||
|
|
||||||
const appConfig = useLiveQuery(
|
const appConfig = useLiveQuery(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
@ -301,7 +299,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
|
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
|
if (!isDBReady || !appConfig || isSessionPending) return;
|
||||||
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
|
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
|
||||||
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
|
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
|
||||||
|
|
||||||
|
|
@ -330,7 +328,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [isDBReady, authEnabled, appConfig, isSessionPending, sessionKey]);
|
}, [isDBReady, appConfig, isSessionPending, sessionKey]);
|
||||||
|
|
||||||
// Destructure for convenience and to match context shape
|
// Destructure for convenience and to match context shape
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||||
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
|
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
|
||||||
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
||||||
import { PrivacyModal } from '@/components/PrivacyModal';
|
import { PrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
|
||||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
|
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
|
||||||
|
|
@ -105,7 +104,6 @@ async function readLocalOnboardingSnapshot(): Promise<LocalOnboardingSnapshot> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
const { authEnabled } = useAuthConfig();
|
|
||||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
const { data: session, isPending: isSessionPending } = useAuthSession();
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
|
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
|
||||||
|
|
@ -136,12 +134,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const runOnceFlow = useCallback(async () => {
|
const runOnceFlow = useCallback(async () => {
|
||||||
const local = await readLocalOnboardingSnapshot();
|
const local = await readLocalOnboardingSnapshot();
|
||||||
const privacyRequired = authEnabled;
|
const privacyRequired = true;
|
||||||
const privacyAccepted = !privacyRequired || local.privacyAccepted;
|
const privacyAccepted = !privacyRequired || local.privacyAccepted;
|
||||||
|
|
||||||
const isClaimEligible = Boolean(
|
const isClaimEligible = Boolean(
|
||||||
authEnabled
|
userId
|
||||||
&& userId
|
|
||||||
&& !isAnonymous
|
&& !isAnonymous
|
||||||
&& !claimDismissedUsersRef.current.has(userId),
|
&& !claimDismissedUsersRef.current.has(userId),
|
||||||
);
|
);
|
||||||
|
|
@ -199,7 +196,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
pendingChangelogOpenRef.current = false;
|
pendingChangelogOpenRef.current = false;
|
||||||
setChangelogOpenSignal((value) => value + 1);
|
setChangelogOpenSignal((value) => value + 1);
|
||||||
}
|
}
|
||||||
}, [authEnabled, isAnonymous, userId]);
|
}, [isAnonymous, userId]);
|
||||||
|
|
||||||
runOnceFlowRef.current = runOnceFlow;
|
runOnceFlowRef.current = runOnceFlow;
|
||||||
|
|
||||||
|
|
@ -223,12 +220,9 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void runFlow();
|
void runFlow();
|
||||||
}, [authEnabled, isAnonymous, runFlow, userId]);
|
}, [isAnonymous, runFlow, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const onPrivacyAccepted = () => {
|
const onPrivacyAccepted = () => {
|
||||||
void runFlow();
|
void runFlow();
|
||||||
};
|
};
|
||||||
|
|
@ -236,15 +230,10 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
|
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
|
||||||
};
|
};
|
||||||
}, [authEnabled, runFlow]);
|
}, [runFlow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authEnabled) {
|
|
||||||
return () => { };
|
|
||||||
}
|
|
||||||
|
|
||||||
return scheduleChangelogCheck({
|
return scheduleChangelogCheck({
|
||||||
authEnabled,
|
|
||||||
isSessionPending,
|
isSessionPending,
|
||||||
sessionUserId: userId,
|
sessionUserId: userId,
|
||||||
appVersion: runtimeConfig.appVersion,
|
appVersion: runtimeConfig.appVersion,
|
||||||
|
|
@ -258,7 +247,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
delayMs: 120,
|
delayMs: 120,
|
||||||
retryDelayMs: 400,
|
retryDelayMs: 400,
|
||||||
});
|
});
|
||||||
}, [authEnabled, isSessionPending, runFlow, runtimeConfig.appVersion, userId]);
|
}, [isSessionPending, runFlow, runtimeConfig.appVersion, userId]);
|
||||||
|
|
||||||
const contextValue = useMemo<OnboardingFlowContextValue>(() => ({
|
const contextValue = useMemo<OnboardingFlowContextValue>(() => ({
|
||||||
changelogOpenSignal,
|
changelogOpenSignal,
|
||||||
|
|
@ -267,21 +256,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<OnboardingFlowContext.Provider value={contextValue}>
|
<OnboardingFlowContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
{authEnabled && (
|
<PrivacyModal
|
||||||
<PrivacyModal
|
isOpen={activeBlockingModal === 'privacy'}
|
||||||
isOpen={activeBlockingModal === 'privacy'}
|
onAccept={handlePrivacyAccepted}
|
||||||
onAccept={handlePrivacyAccepted}
|
onDismiss={() => { }}
|
||||||
onDismiss={() => { }}
|
/>
|
||||||
/>
|
<ClaimDataModal
|
||||||
)}
|
isOpen={activeBlockingModal === 'claim'}
|
||||||
{authEnabled && (
|
claimableCounts={claimableCounts}
|
||||||
<ClaimDataModal
|
onDismiss={handleClaimComplete}
|
||||||
isOpen={activeBlockingModal === 'claim'}
|
onClaimed={handleClaimComplete}
|
||||||
claimableCounts={claimableCounts}
|
/>
|
||||||
onDismiss={handleClaimComplete}
|
|
||||||
onClaimed={handleClaimComplete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<DexieMigrationModal
|
<DexieMigrationModal
|
||||||
isOpen={activeBlockingModal === 'migration'}
|
isOpen={activeBlockingModal === 'migration'}
|
||||||
localCount={migrationCounts.localCount}
|
localCount={migrationCounts.localCount}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ export interface RuntimeConfig {
|
||||||
ttsDailyLimitAuthenticated: number;
|
ttsDailyLimitAuthenticated: number;
|
||||||
ttsIpDailyLimitAnonymous: number;
|
ttsIpDailyLimitAnonymous: number;
|
||||||
ttsIpDailyLimitAuthenticated: number;
|
ttsIpDailyLimitAuthenticated: number;
|
||||||
|
ttsCacheMaxSizeBytes: number;
|
||||||
|
ttsCacheTtlMs: number;
|
||||||
|
ttsUpstreamMaxRetries: number;
|
||||||
|
ttsUpstreamTimeoutMs: number;
|
||||||
computeAvailable: boolean;
|
computeAvailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +50,10 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
|
||||||
ttsDailyLimitAuthenticated: 500_000,
|
ttsDailyLimitAuthenticated: 500_000,
|
||||||
ttsIpDailyLimitAnonymous: 100_000,
|
ttsIpDailyLimitAnonymous: 100_000,
|
||||||
ttsIpDailyLimitAuthenticated: 1_000_000,
|
ttsIpDailyLimitAuthenticated: 1_000_000,
|
||||||
|
ttsCacheMaxSizeBytes: 256 * 1024 * 1024,
|
||||||
|
ttsCacheTtlMs: 1000 * 60 * 30,
|
||||||
|
ttsUpstreamMaxRetries: 2,
|
||||||
|
ttsUpstreamTimeoutMs: 285_000,
|
||||||
computeAvailable: true,
|
computeAvailable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
configTTSModel,
|
configTTSModel,
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
authEnabled,
|
|
||||||
onTTSStart,
|
onTTSStart,
|
||||||
onTTSComplete,
|
onTTSComplete,
|
||||||
refresh: refreshRateLimit,
|
refresh: refreshRateLimit,
|
||||||
|
|
@ -3229,21 +3228,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (authEnabled) {
|
|
||||||
try {
|
|
||||||
const remote = await getDocumentProgress(docId);
|
|
||||||
if (!cancelled && remote?.location) {
|
|
||||||
await setLastDocumentLocation(docId, remote.location).catch((error) => {
|
|
||||||
console.warn('Error caching remote location locally:', error);
|
|
||||||
});
|
|
||||||
applyLocation(remote.location);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error loading remote progress:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const local = await getLastDocumentLocation(docId);
|
const local = await getLastDocumentLocation(docId);
|
||||||
if (!cancelled && local) {
|
if (!cancelled && local) {
|
||||||
|
|
@ -3252,6 +3236,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error loading local last location:', error);
|
console.warn('Error loading local last location:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remote = await getDocumentProgress(docId);
|
||||||
|
if (!cancelled && remote?.location) {
|
||||||
|
await setLastDocumentLocation(docId, remote.location).catch((error) => {
|
||||||
|
console.warn('Error caching remote location locally:', error);
|
||||||
|
});
|
||||||
|
applyLocation(remote.location);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error loading remote progress:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
|
@ -3259,7 +3255,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [id, isEPUB, currentReaderType, authEnabled]);
|
}, [id, isEPUB, currentReaderType]);
|
||||||
|
|
||||||
// Save current position periodically for non-EPUB readers.
|
// Save current position periodically for non-EPUB readers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3271,18 +3267,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setLastDocumentLocation(id as string, location).catch(error => {
|
setLastDocumentLocation(id as string, location).catch(error => {
|
||||||
console.warn('Error saving non-EPUB location:', error);
|
console.warn('Error saving non-EPUB location:', error);
|
||||||
});
|
});
|
||||||
if (authEnabled) {
|
scheduleDocumentProgressSync({
|
||||||
scheduleDocumentProgressSync({
|
documentId: id as string,
|
||||||
documentId: id as string,
|
readerType: currentReaderType,
|
||||||
readerType: currentReaderType,
|
location,
|
||||||
location,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000); // Debounce saves by 1 second
|
}, 1000); // Debounce saves by 1 second
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]);
|
}, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the TTS context provider with its children
|
* Renders the TTS context provider with its children
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@ const seededUserIds = new Set<string>();
|
||||||
/**
|
/**
|
||||||
* Ensure a system/placeholder user row exists in the user table for `userId`.
|
* Ensure a system/placeholder user row exists in the user table for `userId`.
|
||||||
* All user-facing tables now have userId foreign keys with ON DELETE CASCADE
|
* All user-facing tables now have userId foreign keys with ON DELETE CASCADE
|
||||||
* referencing the user table. When auth is disabled the app stores data under
|
* referencing the user table. The legacy first-enable-auth claim migration can
|
||||||
* the 'unclaimed' userId (and namespace variants like 'unclaimed::ns'), so
|
* still move rows from the historical 'unclaimed' userId (and namespaced
|
||||||
* those rows must exist before any data can be inserted.
|
* variants like 'unclaimed::ns'), so those placeholder rows must exist before
|
||||||
|
* any migration transfer can run.
|
||||||
*
|
*
|
||||||
* This is safe to call repeatedly — it short-circuits via an in-memory Set
|
* This is safe to call repeatedly — it short-circuits via an in-memory Set
|
||||||
* and uses INSERT … ON CONFLICT/OR IGNORE at the SQL level.
|
* and uses INSERT … ON CONFLICT/OR IGNORE at the SQL level.
|
||||||
|
|
@ -109,4 +110,3 @@ export const db: any = new Proxy({} as any, {
|
||||||
return typeof value === 'function' ? value.bind(instance) : value;
|
return typeof value === 'function' ? value.bind(instance) : value;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ export function shouldPersistEpubLocation(
|
||||||
|
|
||||||
type UseEpubLocationControllerParams = {
|
type UseEpubLocationControllerParams = {
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
authEnabled: boolean;
|
|
||||||
isEpubSetOnceRef: MutableRefObject<boolean>;
|
isEpubSetOnceRef: MutableRefObject<boolean>;
|
||||||
shouldPauseRef: MutableRefObject<boolean>;
|
shouldPauseRef: MutableRefObject<boolean>;
|
||||||
setIsEpub: (isEpub: boolean) => void;
|
setIsEpub: (isEpub: boolean) => void;
|
||||||
|
|
@ -46,7 +45,6 @@ type UseEpubLocationControllerParams = {
|
||||||
|
|
||||||
export function useEPUBLocationController({
|
export function useEPUBLocationController({
|
||||||
documentId,
|
documentId,
|
||||||
authEnabled,
|
|
||||||
isEpubSetOnceRef,
|
isEpubSetOnceRef,
|
||||||
shouldPauseRef,
|
shouldPauseRef,
|
||||||
setIsEpub,
|
setIsEpub,
|
||||||
|
|
@ -138,13 +136,11 @@ export function useEPUBLocationController({
|
||||||
// Save the location to IndexedDB if not initial
|
// Save the location to IndexedDB if not initial
|
||||||
if (shouldPersistEpubLocation(documentId, locationRef.current)) {
|
if (shouldPersistEpubLocation(documentId, locationRef.current)) {
|
||||||
setLastDocumentLocation(documentId, location.toString());
|
setLastDocumentLocation(documentId, location.toString());
|
||||||
if (authEnabled) {
|
scheduleDocumentProgressSync({
|
||||||
scheduleDocumentProgressSync({
|
documentId,
|
||||||
documentId,
|
readerType: 'epub',
|
||||||
readerType: 'epub',
|
location: location.toString(),
|
||||||
location: location.toString(),
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
skipToLocation(location);
|
skipToLocation(location);
|
||||||
|
|
@ -155,7 +151,6 @@ export function useEPUBLocationController({
|
||||||
shouldPauseRef.current = true;
|
shouldPauseRef.current = true;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
authEnabled,
|
|
||||||
bookRef,
|
bookRef,
|
||||||
documentId,
|
documentId,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
|
|
||||||
|
|
@ -4,35 +4,18 @@ import { useMemo } from 'react';
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||||
import { getAuthClient } from '@/lib/client/auth-client';
|
import { getAuthClient } from '@/lib/client/auth-client';
|
||||||
|
|
||||||
type SessionHookResult = ReturnType<ReturnType<typeof getAuthClient>['useSession']>;
|
|
||||||
|
|
||||||
/** Stable empty result returned when auth is disabled. */
|
|
||||||
const EMPTY_SESSION: SessionHookResult = {
|
|
||||||
data: null,
|
|
||||||
isPending: false,
|
|
||||||
isRefetching: false,
|
|
||||||
// better-auth types use BetterFetchError | null
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
error: null as any,
|
|
||||||
refetch: async () => { },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Stub client whose useSession() always returns the empty shape. */
|
|
||||||
const STUB_CLIENT = { useSession: () => EMPTY_SESSION } as ReturnType<typeof getAuthClient>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for session that uses the correct baseUrl from context.
|
* Hook for session that uses the correct baseUrl from context.
|
||||||
* A stub client is used when auth is disabled so that useSession()
|
|
||||||
* is always called unconditionally (Rules of Hooks).
|
|
||||||
*/
|
*/
|
||||||
export function useAuthSession() {
|
export function useAuthSession() {
|
||||||
const { baseUrl, authEnabled } = useAuthConfig();
|
const { baseUrl } = useAuthConfig();
|
||||||
|
|
||||||
const client = useMemo(() => {
|
const client = useMemo(() => {
|
||||||
if (!authEnabled || !baseUrl) return STUB_CLIENT;
|
if (!baseUrl) {
|
||||||
|
throw new Error('Auth base URL is required');
|
||||||
|
}
|
||||||
return getAuthClient(baseUrl);
|
return getAuthClient(baseUrl);
|
||||||
}, [baseUrl, authEnabled]);
|
}, [baseUrl]);
|
||||||
|
|
||||||
return client.useSession();
|
return client.useSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,14 +341,11 @@ export async function uploadDocuments(files: File[], options?: UploadOptions): P
|
||||||
return uploadDocumentSources(sources, options);
|
return uploadDocumentSources(sources, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' | 'unclaimed'; signal?: AbortSignal }): Promise<void> {
|
export async function deleteDocuments(options?: { ids?: string[]; signal?: AbortSignal }): Promise<void> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.ids?.length) {
|
if (options?.ids?.length) {
|
||||||
params.set('ids', options.ids.join(','));
|
params.set('ids', options.ids.join(','));
|
||||||
}
|
}
|
||||||
if (options?.scope) {
|
|
||||||
params.set('scope', options.scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = params.toString() ? `/api/documents?${params.toString()}` : '/api/documents';
|
const url = params.toString() ? `/api/documents?${params.toString()}` : '/api/documents';
|
||||||
const res = await fetch(url, { method: 'DELETE', signal: options?.signal });
|
const res = await fetch(url, { method: 'DELETE', signal: options?.signal });
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ export type ChangelogVersionCheckResponse = {
|
||||||
type RefLike = { current: string | null };
|
type RefLike = { current: string | null };
|
||||||
|
|
||||||
export type RunChangelogCheckArgs = {
|
export type RunChangelogCheckArgs = {
|
||||||
authEnabled: boolean;
|
|
||||||
isSessionPending: boolean;
|
isSessionPending: boolean;
|
||||||
sessionUserId: string | null | undefined;
|
sessionUserId: string | null | undefined;
|
||||||
appVersion: string | null | undefined;
|
appVersion: string | null | undefined;
|
||||||
|
|
@ -23,13 +22,12 @@ export type RunChangelogCheckArgs = {
|
||||||
|
|
||||||
export async function runChangelogCheck(args: RunChangelogCheckArgs): Promise<void> {
|
export async function runChangelogCheck(args: RunChangelogCheckArgs): Promise<void> {
|
||||||
const sessionUserId = args.sessionUserId ?? null;
|
const sessionUserId = args.sessionUserId ?? null;
|
||||||
if (args.authEnabled && (args.isSessionPending || !sessionUserId)) return;
|
if (args.isSessionPending || !sessionUserId) return;
|
||||||
|
|
||||||
const currentVersion = normalizeVersion(args.appVersion || '');
|
const currentVersion = normalizeVersion(args.appVersion || '');
|
||||||
if (!currentVersion) return;
|
if (!currentVersion) return;
|
||||||
|
|
||||||
const userKey = sessionUserId ?? 'server-unclaimed';
|
const checkKey = `${sessionUserId}:${currentVersion}`;
|
||||||
const checkKey = `${userKey}:${currentVersion}`;
|
|
||||||
if (args.completedRef.current === checkKey) return;
|
if (args.completedRef.current === checkKey) return;
|
||||||
if (args.inFlightRef.current === checkKey) return;
|
if (args.inFlightRef.current === checkKey) return;
|
||||||
args.inFlightRef.current = checkKey;
|
args.inFlightRef.current = checkKey;
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,53 @@ import { adminProviders, adminSettings } from '@/db/schema';
|
||||||
import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets';
|
import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { serverLogger } from '@/lib/server/logger';
|
import { serverLogger } from '@/lib/server/logger';
|
||||||
import {
|
import {
|
||||||
RUNTIME_CONFIG_SCHEMA,
|
seedRuntimeConfigFromValues,
|
||||||
seedRuntimeConfigFromEnv,
|
|
||||||
} from '@/lib/server/admin/settings';
|
} from '@/lib/server/admin/settings';
|
||||||
import { logDegraded } from '@/lib/server/errors/logging';
|
import { logDegraded } from '@/lib/server/errors/logging';
|
||||||
|
import { validateProviderType, validateSlug } from '@/lib/server/admin/providers';
|
||||||
|
import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotent boot-time seeding for the admin layer. Safe to call multiple
|
* Idempotent boot-time seeding for the admin layer. Safe to call multiple times.
|
||||||
* times. Runs:
|
|
||||||
*
|
*
|
||||||
* 1. `seedRuntimeConfigFromEnv()` — for each `RUNTIME_SEED_*` env var that
|
* v4 behavior:
|
||||||
* maps to a runtime config key, write the value as `source='env-seed'`.
|
* 1) Optional JSON seed from RUNTIME_SEED_JSON_PATH or RUNTIME_SEED_JSON
|
||||||
*
|
* - runtimeConfig: strict validation against RUNTIME_CONFIG_SCHEMA
|
||||||
* 2. Default admin provider seed — if `admin_providers` is empty AND
|
* - providers: optional shared providers seed list
|
||||||
* `API_KEY` is set, create a single `default-openai` row from the
|
* 2) Legacy provider fallback: if providers were not supplied in JSON and
|
||||||
* legacy `API_KEY` / `API_BASE` env vars. After this runs, the TTS
|
* no provider rows exist, seed default-openai from API_KEY/API_BASE.
|
||||||
* routes no longer fall back to those env vars.
|
* 3) Legacy row cleanup for historical defaultTtsProvider/defaultTtsModel rows.
|
||||||
*
|
|
||||||
* 3. Legacy row cleanup — remove historical env-seeded
|
|
||||||
* `defaultTtsProvider="default-openai"` rows so the shared default is
|
|
||||||
* treated as an implicit baseline, not an override.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const RUNTIME_SEED_JSON = 'RUNTIME_SEED_JSON';
|
||||||
|
const RUNTIME_SEED_JSON_PATH = 'RUNTIME_SEED_JSON_PATH';
|
||||||
|
const SEED_VERSION = 1;
|
||||||
|
|
||||||
|
type SeedProviderInput = {
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
providerType: TtsProviderId;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
defaultModel?: string | null;
|
||||||
|
defaultInstructions?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServerSeedDocument = {
|
||||||
|
version: number;
|
||||||
|
runtimeConfig?: Record<string, unknown>;
|
||||||
|
providers?: SeedProviderInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedSeedResult = {
|
||||||
|
seed: ServerSeedDocument;
|
||||||
|
hasProvidersSection: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
let seedPromise: Promise<void> | null = null;
|
let seedPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function ensureAdminSeed(): Promise<void> {
|
export async function ensureAdminSeed(): Promise<void> {
|
||||||
|
|
@ -38,7 +61,6 @@ export async function ensureAdminSeed(): Promise<void> {
|
||||||
step: 'run_admin_seed',
|
step: 'run_admin_seed',
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
// Reset so a subsequent call can retry (e.g. once migrations run).
|
|
||||||
seedPromise = null;
|
seedPromise = null;
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
@ -51,13 +73,211 @@ export async function ensureAdminSeed(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSeed(): Promise<void> {
|
async function runSeed(): Promise<void> {
|
||||||
await seedRuntimeConfigFromEnv();
|
const parsedSeed = await loadRuntimeSeedFromEnv();
|
||||||
await seedDefaultAdminProvider();
|
|
||||||
|
if (parsedSeed?.seed.runtimeConfig) {
|
||||||
|
await seedRuntimeConfigStrict(parsedSeed.seed.runtimeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedSeed?.seed.providers) {
|
||||||
|
await seedAdminProvidersFromJson(parsedSeed.seed.providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldUseEnvProviderFallback(parsedSeed?.hasProvidersSection ?? false)) {
|
||||||
|
await seedDefaultAdminProviderFromEnvFallback();
|
||||||
|
}
|
||||||
|
|
||||||
await cleanupLegacyDefaultTtsProviderSeedRow();
|
await cleanupLegacyDefaultTtsProviderSeedRow();
|
||||||
await cleanupLegacyDefaultTtsModelRows();
|
await cleanupLegacyDefaultTtsModelRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedDefaultAdminProvider(): Promise<void> {
|
function shouldUseEnvProviderFallback(hasProvidersSection: boolean): boolean {
|
||||||
|
return !hasProvidersSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRuntimeSeedFromEnv(): Promise<ParsedSeedResult | null> {
|
||||||
|
const pathValue = process.env[RUNTIME_SEED_JSON_PATH]?.trim();
|
||||||
|
const inlineValue = process.env[RUNTIME_SEED_JSON]?.trim();
|
||||||
|
|
||||||
|
if (!pathValue && !inlineValue) return null;
|
||||||
|
|
||||||
|
const raw = pathValue
|
||||||
|
? await readFile(pathValue, 'utf8')
|
||||||
|
: inlineValue as string;
|
||||||
|
|
||||||
|
return parseRuntimeSeedDocument(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuntimeSeedDocument(raw: string): ParsedSeedResult {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid ${RUNTIME_SEED_JSON}/${RUNTIME_SEED_JSON_PATH} JSON: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error('Seed JSON root must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const allowedTopLevel = new Set(['version', 'runtimeConfig', 'providers']);
|
||||||
|
const unknownTopLevel = Object.keys(record).filter((key) => !allowedTopLevel.has(key));
|
||||||
|
if (unknownTopLevel.length > 0) {
|
||||||
|
throw new Error(`Seed JSON contains unknown top-level keys: ${unknownTopLevel.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.version !== SEED_VERSION) {
|
||||||
|
throw new Error(`Seed JSON version must be ${SEED_VERSION}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeConfig: Record<string, unknown> | undefined;
|
||||||
|
if (record.runtimeConfig !== undefined) {
|
||||||
|
if (!record.runtimeConfig || typeof record.runtimeConfig !== 'object' || Array.isArray(record.runtimeConfig)) {
|
||||||
|
throw new Error('Seed JSON runtimeConfig must be an object when provided');
|
||||||
|
}
|
||||||
|
runtimeConfig = record.runtimeConfig as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers: SeedProviderInput[] | undefined;
|
||||||
|
if (record.providers !== undefined) {
|
||||||
|
if (!Array.isArray(record.providers)) {
|
||||||
|
throw new Error('Seed JSON providers must be an array when provided');
|
||||||
|
}
|
||||||
|
providers = record.providers.map((entry, index) => validateSeedProviderEntry(entry, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seed: {
|
||||||
|
version: SEED_VERSION,
|
||||||
|
...(runtimeConfig ? { runtimeConfig } : {}),
|
||||||
|
...(providers ? { providers } : {}),
|
||||||
|
},
|
||||||
|
hasProvidersSection: Object.prototype.hasOwnProperty.call(record, 'providers'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSeedProviderEntry(value: unknown, index: number): SeedProviderInput {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error(`Seed JSON providers[${index}] must be an object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = value as Record<string, unknown>;
|
||||||
|
const allowed = new Set([
|
||||||
|
'slug',
|
||||||
|
'displayName',
|
||||||
|
'providerType',
|
||||||
|
'apiKey',
|
||||||
|
'baseUrl',
|
||||||
|
'defaultModel',
|
||||||
|
'defaultInstructions',
|
||||||
|
'enabled',
|
||||||
|
]);
|
||||||
|
const unknown = Object.keys(rec).filter((key) => !allowed.has(key));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new Error(`Seed JSON providers[${index}] contains unknown keys: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rec.slug !== 'string' || !rec.slug.trim()) {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].slug must be a non-empty string`);
|
||||||
|
}
|
||||||
|
if (typeof rec.displayName !== 'string' || !rec.displayName.trim()) {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].displayName must be a non-empty string`);
|
||||||
|
}
|
||||||
|
if (typeof rec.providerType !== 'string') {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].providerType must be a string`);
|
||||||
|
}
|
||||||
|
if (typeof rec.apiKey !== 'string' || !rec.apiKey.trim()) {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].apiKey must be a non-empty string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rec.baseUrl !== undefined && rec.baseUrl !== null && typeof rec.baseUrl !== 'string') {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].baseUrl must be a string or null`);
|
||||||
|
}
|
||||||
|
if (rec.defaultModel !== undefined && rec.defaultModel !== null && typeof rec.defaultModel !== 'string') {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].defaultModel must be a string or null`);
|
||||||
|
}
|
||||||
|
if (rec.defaultInstructions !== undefined && rec.defaultInstructions !== null && typeof rec.defaultInstructions !== 'string') {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].defaultInstructions must be a string or null`);
|
||||||
|
}
|
||||||
|
if (rec.enabled !== undefined && typeof rec.enabled !== 'boolean') {
|
||||||
|
throw new Error(`Seed JSON providers[${index}].enabled must be a boolean when provided`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: validateSlug(rec.slug),
|
||||||
|
displayName: rec.displayName.trim(),
|
||||||
|
providerType: validateProviderType(rec.providerType),
|
||||||
|
apiKey: rec.apiKey.trim(),
|
||||||
|
...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}),
|
||||||
|
...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}),
|
||||||
|
...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}),
|
||||||
|
...(rec.enabled !== undefined ? { enabled: rec.enabled } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedRuntimeConfigStrict(input: Record<string, unknown>): Promise<void> {
|
||||||
|
const result = await seedRuntimeConfigFromValues(input, 'json-seed');
|
||||||
|
if (result.unknown.length > 0 || result.invalid.length > 0) {
|
||||||
|
const issues = [
|
||||||
|
...(result.unknown.length > 0 ? [`unknown keys: ${result.unknown.join(', ')}`] : []),
|
||||||
|
...(result.invalid.length > 0 ? [`invalid values for: ${result.invalid.join(', ')}`] : []),
|
||||||
|
].join('; ');
|
||||||
|
throw new Error(`Seed JSON runtimeConfig validation failed (${issues})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promise<void> {
|
||||||
|
if (providers.length === 0) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const provider of providers) {
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: adminProviders.id })
|
||||||
|
.from(adminProviders)
|
||||||
|
.where(eq(adminProviders.slug, provider.slug))
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length > 0) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encrypted = encryptSecret(provider.apiKey);
|
||||||
|
await db.insert(adminProviders).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
slug: provider.slug,
|
||||||
|
displayName: provider.displayName,
|
||||||
|
providerType: provider.providerType,
|
||||||
|
baseUrl: provider.baseUrl ?? null,
|
||||||
|
apiKeyCiphertext: encrypted.ciphertext,
|
||||||
|
apiKeyIv: encrypted.iv,
|
||||||
|
apiKeyLast4: apiKeyLast4(provider.apiKey),
|
||||||
|
defaultModel: provider.defaultModel ?? null,
|
||||||
|
defaultInstructions: provider.defaultInstructions ?? null,
|
||||||
|
enabled: provider.enabled === false ? 0 : 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logDegraded(serverLogger, {
|
||||||
|
event: 'admin.seed.provider_insert.failed',
|
||||||
|
msg: 'Failed to seed provider from JSON seed',
|
||||||
|
step: 'seed_json_provider',
|
||||||
|
context: {
|
||||||
|
providerSlug: provider.slug,
|
||||||
|
providerDisplayName: provider.displayName,
|
||||||
|
},
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
|
||||||
const apiKey = process.env.API_KEY;
|
const apiKey = process.env.API_KEY;
|
||||||
if (!apiKey || !apiKey.trim()) return;
|
if (!apiKey || !apiKey.trim()) return;
|
||||||
|
|
||||||
|
|
@ -81,36 +301,6 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
enc = encryptSecret(apiKey);
|
enc = encryptSecret(apiKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const hasExplicitRestriction =
|
|
||||||
Boolean(
|
|
||||||
RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar
|
|
||||||
&& process.env[RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar]?.trim(),
|
|
||||||
);
|
|
||||||
if (!hasExplicitRestriction) {
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.insert(adminSettings)
|
|
||||||
.values({
|
|
||||||
key: 'restrictUserApiKeys',
|
|
||||||
valueJson: JSON.stringify(false) as never,
|
|
||||||
source: 'env-seed',
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing({ target: adminSettings.key });
|
|
||||||
logDegraded(serverLogger, {
|
|
||||||
event: 'admin.seed.restrict_user_api_keys.defaulted',
|
|
||||||
msg: 'API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false',
|
|
||||||
step: 'set_restrict_user_api_keys_fallback',
|
|
||||||
});
|
|
||||||
} catch (fallbackError) {
|
|
||||||
logDegraded(serverLogger, {
|
|
||||||
event: 'admin.seed.restrict_user_api_keys.fallback_write_failed',
|
|
||||||
msg: 'Failed to write restrictUserApiKeys fallback after encryption failure',
|
|
||||||
step: 'set_restrict_user_api_keys_fallback',
|
|
||||||
error: fallbackError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logDegraded(serverLogger, {
|
logDegraded(serverLogger, {
|
||||||
event: 'admin.seed.provider_key_encrypt.failed',
|
event: 'admin.seed.provider_key_encrypt.failed',
|
||||||
msg: 'Failed to encrypt default provider API key',
|
msg: 'Failed to encrypt default provider API key',
|
||||||
|
|
@ -138,7 +328,7 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
||||||
serverLogger.info({
|
serverLogger.info({
|
||||||
event: 'admin.seed.provider_insert.succeeded',
|
event: 'admin.seed.provider_insert.succeeded',
|
||||||
providerSlug: 'default-openai',
|
providerSlug: 'default-openai',
|
||||||
}, 'Created default-openai admin provider from env');
|
}, 'Created default-openai admin provider from env fallback');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logDegraded(serverLogger, {
|
logDegraded(serverLogger, {
|
||||||
event: 'admin.seed.provider_insert.failed',
|
event: 'admin.seed.provider_insert.failed',
|
||||||
|
|
@ -151,12 +341,6 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
||||||
// If an explicit env default exists, keep env-seeded behavior.
|
|
||||||
const explicit = RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar
|
|
||||||
? process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar]
|
|
||||||
: undefined;
|
|
||||||
if (explicit && explicit.trim()) return;
|
|
||||||
|
|
||||||
const key = 'defaultTtsProvider';
|
const key = 'defaultTtsProvider';
|
||||||
const seededValue = JSON.stringify('default-openai');
|
const seededValue = JSON.stringify('default-openai');
|
||||||
try {
|
try {
|
||||||
|
|
@ -193,3 +377,10 @@ async function cleanupLegacyDefaultTtsModelRows(): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __seedInternals = {
|
||||||
|
runSeed,
|
||||||
|
parseRuntimeSeedDocument,
|
||||||
|
validateSeedProviderEntry,
|
||||||
|
shouldUseEnvProviderFallback,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,29 @@
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { adminProviders, adminSettings } from '@/db/schema';
|
import { adminProviders, adminSettings } from '@/db/schema';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
|
||||||
import { serverLogger } from '@/lib/server/logger';
|
import { serverLogger } from '@/lib/server/logger';
|
||||||
import { logDegraded } from '@/lib/server/errors/logging';
|
import { logDegraded } from '@/lib/server/errors/logging';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime config: site-wide settings that used to live in build-time env vars.
|
* Runtime config: site-wide settings that are persisted in admin_settings.
|
||||||
* env vars. Each key has:
|
* Each key has:
|
||||||
* - a TypeScript value type
|
* - a TypeScript value type
|
||||||
* - an env var name used for the first-run seed
|
* - a default used when no DB value exists
|
||||||
* - a parser that turns a string env value into the typed value
|
* - a validator for admin/seed writes
|
||||||
* - a default applied when neither the DB nor the env has a value
|
|
||||||
*
|
|
||||||
* On first boot, `seedRuntimeConfigFromEnv()` writes a row for every key
|
|
||||||
* whose env var is set. After that, `getRuntimeConfig()` reads from DB only.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type RuntimeConfigSource = 'env-seed' | 'admin';
|
export type RuntimeConfigSource = 'json-seed' | 'env-seed' | 'admin';
|
||||||
|
|
||||||
export interface RuntimeConfigKeyDef<T> {
|
export interface RuntimeConfigKeyDef<T> {
|
||||||
/** TS-level default. Used when neither DB nor env have a value. */
|
/** TS-level default. Used when neither DB nor env have a value. */
|
||||||
default: T;
|
default: T;
|
||||||
/** Env var name to seed from on first run. Omit for DB/admin-only keys. */
|
|
||||||
envVar?: string;
|
|
||||||
/** Parse a string env value to T. Returns undefined to skip seeding. */
|
|
||||||
parseEnv(raw: string): T | undefined;
|
|
||||||
/** Validate an incoming admin-supplied value. */
|
/** Validate an incoming admin-supplied value. */
|
||||||
validate(value: unknown): T | undefined;
|
validate(value: unknown): T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function booleanFlag(defaultValue: boolean, envVar: string): RuntimeConfigKeyDef<boolean> {
|
function booleanFlag(defaultValue: boolean): RuntimeConfigKeyDef<boolean> {
|
||||||
return {
|
return {
|
||||||
default: defaultValue,
|
default: defaultValue,
|
||||||
envVar,
|
|
||||||
parseEnv(raw) {
|
|
||||||
const lower = raw.trim().toLowerCase();
|
|
||||||
if (lower === '' ) return undefined;
|
|
||||||
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
|
|
||||||
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
validate(value) {
|
validate(value) {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -51,9 +34,6 @@ function booleanFlag(defaultValue: boolean, envVar: string): RuntimeConfigKeyDef
|
||||||
function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef<boolean> {
|
function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef<boolean> {
|
||||||
return {
|
return {
|
||||||
default: defaultValue,
|
default: defaultValue,
|
||||||
parseEnv() {
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
validate(value) {
|
validate(value) {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -61,14 +41,9 @@ function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef<boolean> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<string> {
|
function stringValue(defaultValue: string): RuntimeConfigKeyDef<string> {
|
||||||
return {
|
return {
|
||||||
default: defaultValue,
|
default: defaultValue,
|
||||||
envVar,
|
|
||||||
parseEnv(raw) {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
return trimmed ? trimmed : undefined;
|
|
||||||
},
|
|
||||||
validate(value) {
|
validate(value) {
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return value;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -76,17 +51,9 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigKeyDef<number> {
|
function positiveIntValue(defaultValue: number): RuntimeConfigKeyDef<number> {
|
||||||
return {
|
return {
|
||||||
default: defaultValue,
|
default: defaultValue,
|
||||||
envVar,
|
|
||||||
parseEnv(raw) {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
const parsed = Number(trimmed);
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 1) return undefined;
|
|
||||||
return Math.floor(parsed);
|
|
||||||
},
|
|
||||||
validate(value) {
|
validate(value) {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return undefined;
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return undefined;
|
||||||
return Math.floor(value);
|
return Math.floor(value);
|
||||||
|
|
@ -95,28 +62,32 @@ function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigK
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RUNTIME_CONFIG_SCHEMA = {
|
export const RUNTIME_CONFIG_SCHEMA = {
|
||||||
defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'),
|
defaultTtsProvider: stringValue('custom-openai'),
|
||||||
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'),
|
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json'),
|
||||||
enableUserSignups: booleanFlag(true, 'RUNTIME_SEED_ENABLE_USER_SIGNUPS'),
|
enableUserSignups: booleanFlag(true),
|
||||||
restrictUserApiKeys: booleanFlag(true, 'RUNTIME_SEED_RESTRICT_USER_API_KEYS'),
|
restrictUserApiKeys: booleanFlag(true),
|
||||||
// Historically the env semantics were "true unless explicitly 'false'",
|
// Historically the env semantics were "true unless explicitly 'false'",
|
||||||
// i.e. the feature defaults to ON.
|
// i.e. the feature defaults to ON.
|
||||||
enableTtsProvidersTab: booleanFlag(true, 'RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB'),
|
enableTtsProvidersTab: booleanFlag(true),
|
||||||
enableAudiobookExport: booleanFlag(true, 'RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT'),
|
enableAudiobookExport: booleanFlag(true),
|
||||||
enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'),
|
enableDocxConversion: booleanFlag(true),
|
||||||
enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
|
enableDestructiveDeleteActions: booleanFlag(true),
|
||||||
showAllProviderModels: runtimeBoolean(true),
|
showAllProviderModels: runtimeBoolean(true),
|
||||||
disableTtsRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_TTS_LIMIT'),
|
disableTtsRateLimit: booleanFlag(true),
|
||||||
ttsDailyLimitAnonymous: positiveIntValue(50_000),
|
ttsDailyLimitAnonymous: positiveIntValue(50_000),
|
||||||
ttsDailyLimitAuthenticated: positiveIntValue(500_000),
|
ttsDailyLimitAuthenticated: positiveIntValue(500_000),
|
||||||
ttsIpDailyLimitAnonymous: positiveIntValue(100_000),
|
ttsIpDailyLimitAnonymous: positiveIntValue(100_000),
|
||||||
ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000),
|
ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000),
|
||||||
|
ttsCacheMaxSizeBytes: positiveIntValue(256 * 1024 * 1024),
|
||||||
|
ttsCacheTtlMs: positiveIntValue(1000 * 60 * 30),
|
||||||
|
ttsUpstreamMaxRetries: positiveIntValue(2),
|
||||||
|
ttsUpstreamTimeoutMs: positiveIntValue(285_000),
|
||||||
// Per-user throttle for expensive PDF-layout parsing. Disabled by default
|
// Per-user throttle for expensive PDF-layout parsing. Disabled by default
|
||||||
// (admins enable it in Settings → Admin), mirroring disableTtsRateLimit.
|
// (admins enable it in Settings → Admin), mirroring disableTtsRateLimit.
|
||||||
// When enabled, the sub-limits below apply (admin-tunable, no env seed):
|
// When enabled, the sub-limits below apply (admin-tunable, no env seed):
|
||||||
// a short "burst" window plus a wider "sustained" window that also bounds
|
// a short "burst" window plus a wider "sustained" window that also bounds
|
||||||
// concurrency (the worker caps each job's duration).
|
// concurrency (the worker caps each job's duration).
|
||||||
disableComputeRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_COMPUTE_LIMIT'),
|
disableComputeRateLimit: booleanFlag(true),
|
||||||
computeParseBurstMax: positiveIntValue(8),
|
computeParseBurstMax: positiveIntValue(8),
|
||||||
computeParseBurstWindowSec: positiveIntValue(60),
|
computeParseBurstWindowSec: positiveIntValue(60),
|
||||||
computeParseSustainedMax: positiveIntValue(24),
|
computeParseSustainedMax: positiveIntValue(24),
|
||||||
|
|
@ -165,11 +136,6 @@ function buildDefaults(): RuntimeConfig {
|
||||||
for (const key of RUNTIME_KEYS) {
|
for (const key of RUNTIME_KEYS) {
|
||||||
(out as Record<string, unknown>)[key] = RUNTIME_CONFIG_SCHEMA[key].default;
|
(out as Record<string, unknown>)[key] = RUNTIME_CONFIG_SCHEMA[key].default;
|
||||||
}
|
}
|
||||||
// In no-auth mode there is no admin UI to configure shared providers.
|
|
||||||
// Keep legacy BYOK available by default unless explicitly overridden.
|
|
||||||
if (!isAuthEnabled()) {
|
|
||||||
out.restrictUserApiKeys = false;
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,7 +230,12 @@ export async function getRuntimeConfigWithSources(): Promise<{
|
||||||
const validated = RUNTIME_CONFIG_SCHEMA[key].validate(row.value);
|
const validated = RUNTIME_CONFIG_SCHEMA[key].validate(row.value);
|
||||||
if (validated !== undefined) {
|
if (validated !== undefined) {
|
||||||
(values as Record<string, unknown>)[key] = validated;
|
(values as Record<string, unknown>)[key] = validated;
|
||||||
sources[key] = (row.source === 'env-seed' ? 'env-seed' : 'admin');
|
sources[key] = (
|
||||||
|
row.source === 'env-seed'
|
||||||
|
|| row.source === 'json-seed'
|
||||||
|
? row.source
|
||||||
|
: 'admin'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
sources[key] = 'default';
|
sources[key] = 'default';
|
||||||
}
|
}
|
||||||
|
|
@ -305,55 +276,70 @@ export async function setRuntimeConfigKey<K extends RuntimeConfigKey>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a runtime config row (resets to env/default behavior). */
|
/** Delete a runtime config row (resets to default/implicit behavior). */
|
||||||
export async function clearRuntimeConfigKey(key: RuntimeConfigKey): Promise<void> {
|
export async function clearRuntimeConfigKey(key: RuntimeConfigKey): Promise<void> {
|
||||||
await db.delete(adminSettings).where(eq(adminSettings.key, key));
|
await db.delete(adminSettings).where(eq(adminSettings.key, key));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run seed: for every key whose row is absent AND env var is set,
|
* First-run seed from a parsed object. Only inserts keys that are absent,
|
||||||
* write a row with `source = 'env-seed'`. Idempotent — never overwrites
|
* never overwriting an existing row.
|
||||||
* an existing row.
|
|
||||||
*/
|
*/
|
||||||
export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfigKey[] }> {
|
export async function seedRuntimeConfigFromValues(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
source: RuntimeConfigSource = 'json-seed',
|
||||||
|
): Promise<{ seeded: RuntimeConfigKey[]; invalid: string[]; unknown: string[] }> {
|
||||||
const seeded: RuntimeConfigKey[] = [];
|
const seeded: RuntimeConfigKey[] = [];
|
||||||
let existing: Map<string, { value: unknown; source: string }>;
|
const invalid: string[] = [];
|
||||||
try {
|
const unknown: string[] = [];
|
||||||
existing = await readAllRows();
|
const validEntries: Array<{ key: RuntimeConfigKey; value: RuntimeConfig[RuntimeConfigKey] }> = [];
|
||||||
} catch {
|
const existing = await readAllRows();
|
||||||
return { seeded };
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const key of RUNTIME_KEYS) {
|
|
||||||
if (existing.has(key)) continue;
|
for (const [rawKey, rawValue] of Object.entries(input)) {
|
||||||
const def = RUNTIME_CONFIG_SCHEMA[key];
|
if (!RUNTIME_KEYS.includes(rawKey as RuntimeConfigKey)) {
|
||||||
if (!def.envVar) continue;
|
unknown.push(rawKey);
|
||||||
const raw = process.env[def.envVar];
|
continue;
|
||||||
if (raw === undefined || raw === null || raw === '') continue;
|
}
|
||||||
const parsed = def.parseEnv(raw);
|
const key = rawKey as RuntimeConfigKey;
|
||||||
if (parsed === undefined) continue;
|
const def = RUNTIME_CONFIG_SCHEMA[key] as RuntimeConfigKeyDef<RuntimeConfig[RuntimeConfigKey]>;
|
||||||
|
const validated = def.validate(rawValue);
|
||||||
|
if (validated === undefined) {
|
||||||
|
invalid.push(rawKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validEntries.push({ key, value: validated });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unknown.length > 0 || invalid.length > 0) {
|
||||||
|
return { seeded, invalid, unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of validEntries) {
|
||||||
|
if (existing.has(entry.key)) continue;
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
.insert(adminSettings)
|
.insert(adminSettings)
|
||||||
.values({
|
.values({
|
||||||
key,
|
key: entry.key,
|
||||||
valueJson: serializeForStorage(parsed) as never,
|
valueJson: serializeForStorage(entry.value) as never,
|
||||||
source: 'env-seed',
|
source,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: adminSettings.key });
|
.onConflictDoNothing({ target: adminSettings.key });
|
||||||
seeded.push(key);
|
seeded.push(entry.key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logDegraded(serverLogger, {
|
logDegraded(serverLogger, {
|
||||||
event: 'admin.runtime_config.seed.failed',
|
event: 'admin.runtime_config.seed.failed',
|
||||||
msg: 'Runtime config seed failed',
|
msg: 'Runtime config seed failed',
|
||||||
step: 'seed_runtime_config_key',
|
step: 'seed_runtime_config_key',
|
||||||
context: { key },
|
context: { key: entry.key, source },
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { seeded };
|
|
||||||
|
return { seeded, invalid, unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { RUNTIME_KEYS };
|
export { RUNTIME_KEYS };
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
export function buildAllowedAudiobookUserIds(
|
|
||||||
authEnabled: boolean,
|
|
||||||
userId: string | null,
|
|
||||||
unclaimedUserId: string,
|
|
||||||
): { preferredUserId: string; allowedUserIds: string[] } {
|
|
||||||
if (!authEnabled) {
|
|
||||||
return { preferredUserId: unclaimedUserId, allowedUserIds: [unclaimedUserId] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredUserId = userId ?? unclaimedUserId;
|
|
||||||
const allowedUserIds = Array.from(new Set([preferredUserId, unclaimedUserId]));
|
|
||||||
return { preferredUserId, allowedUserIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickAudiobookOwner(
|
|
||||||
existingUserIds: string[],
|
|
||||||
preferredUserId: string,
|
|
||||||
unclaimedUserId: string,
|
|
||||||
): string | null {
|
|
||||||
const existing = new Set(existingUserIds);
|
|
||||||
// Keep resumed writes on unclaimed scope when the book already exists there.
|
|
||||||
if (existing.has(unclaimedUserId)) return unclaimedUserId;
|
|
||||||
if (existing.has(preferredUserId)) return preferredUserId;
|
|
||||||
return existingUserIds[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
@ -9,16 +9,13 @@ export type AdminAuthContext = AuthContext & {
|
||||||
/**
|
/**
|
||||||
* Returns the admin auth context, or a 401/403 Response if the requester is
|
* Returns the admin auth context, or a 401/403 Response if the requester is
|
||||||
* not authenticated / not an admin. Mirrors the `requireAuthContext` shape.
|
* not authenticated / not an admin. Mirrors the `requireAuthContext` shape.
|
||||||
*
|
|
||||||
* When auth is disabled, this always returns 403 — there is no notion of
|
|
||||||
* "admin" without authentication.
|
|
||||||
*/
|
*/
|
||||||
export async function requireAdminContext(
|
export async function requireAdminContext(
|
||||||
request: Pick<NextRequest, 'headers'>,
|
request: Pick<NextRequest, 'headers'>,
|
||||||
): Promise<AdminAuthContext | Response> {
|
): Promise<AdminAuthContext | Response> {
|
||||||
const ctx = await getAuthContext(request);
|
const ctx = await getAuthContext(request);
|
||||||
|
|
||||||
if (!ctx.authEnabled || !ctx.userId || !ctx.user) {
|
if (!ctx.userId || !ctx.user) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config";
|
import { getRequiredAuthEnv, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config";
|
||||||
import { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync";
|
import { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync";
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy';
|
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy';
|
||||||
|
|
@ -58,6 +58,7 @@ function envFlagEnabled(name: string, defaultValue: boolean): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
|
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
|
||||||
|
const requiredAuthEnv = getRequiredAuthEnv();
|
||||||
|
|
||||||
const createAuth = () => betterAuth({
|
const createAuth = () => betterAuth({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
@ -65,8 +66,8 @@ const createAuth = () => betterAuth({
|
||||||
provider: process.env.POSTGRES_URL ? "pg" : "sqlite",
|
provider: process.env.POSTGRES_URL ? "pg" : "sqlite",
|
||||||
schema: authSchema as Record<string, unknown>,
|
schema: authSchema as Record<string, unknown>,
|
||||||
}),
|
}),
|
||||||
secret: process.env.AUTH_SECRET!,
|
secret: requiredAuthEnv.authSecret,
|
||||||
baseURL: process.env.BASE_URL!,
|
baseURL: requiredAuthEnv.baseUrl,
|
||||||
trustedOrigins: getTrustedOrigins(),
|
trustedOrigins: getTrustedOrigins(),
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -309,7 +310,7 @@ const createAuth = () => betterAuth({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const auth = isAuthEnabled() ? createAuth() : null;
|
export const auth = createAuth();
|
||||||
|
|
||||||
type AuthInstance = ReturnType<typeof createAuth>;
|
type AuthInstance = ReturnType<typeof createAuth>;
|
||||||
export type Session = AuthInstance["$Infer"]["Session"];
|
export type Session = AuthInstance["$Infer"]["Session"];
|
||||||
|
|
@ -319,19 +320,12 @@ export type User = AuthSessionUser & {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthContext = {
|
export type AuthContext = {
|
||||||
authEnabled: boolean;
|
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Promise<AuthContext> {
|
export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Promise<AuthContext> {
|
||||||
const authEnabled = isAuthEnabled();
|
|
||||||
|
|
||||||
if (!authEnabled || !auth) {
|
|
||||||
return { authEnabled, session: null, user: null, userId: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await auth.api.getSession({ headers: request.headers });
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
const user = (session?.user ?? null) as User | null;
|
const user = (session?.user ?? null) as User | null;
|
||||||
const userId = user?.id ?? null;
|
const userId = user?.id ?? null;
|
||||||
|
|
@ -347,7 +341,7 @@ export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Pro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { authEnabled, session, user, userId };
|
return { session, user, userId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuthContext(
|
export async function requireAuthContext(
|
||||||
|
|
@ -356,10 +350,6 @@ export async function requireAuthContext(
|
||||||
): Promise<AuthContext | Response> {
|
): Promise<AuthContext | Response> {
|
||||||
const ctx = await getAuthContext(request);
|
const ctx = await getAuthContext(request);
|
||||||
|
|
||||||
if (!ctx.authEnabled) {
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.userId) {
|
if (!ctx.userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
/**
|
export type RequiredAuthEnv = {
|
||||||
* Centralized auth configuration check.
|
authSecret: string;
|
||||||
* Auth is only enabled when BOTH AUTH_SECRET and BASE_URL are set.
|
baseUrl: string;
|
||||||
*/
|
};
|
||||||
export function isAuthEnabled(): boolean {
|
|
||||||
return !!(process.env.AUTH_SECRET && process.env.BASE_URL);
|
function getRequiredEnvValue(name: 'AUTH_SECRET' | 'BASE_URL'): string {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing required environment variable: ${name}. `
|
||||||
|
+ 'OpenReader v4 requires both AUTH_SECRET and BASE_URL at startup.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequiredAuthEnv(): RequiredAuthEnv {
|
||||||
|
return {
|
||||||
|
authSecret: getRequiredEnvValue('AUTH_SECRET'),
|
||||||
|
baseUrl: getRequiredEnvValue('BASE_URL'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBooleanEnv(name: string, defaultValue: boolean): boolean {
|
function parseBooleanEnv(name: string, defaultValue: boolean): boolean {
|
||||||
|
|
@ -21,25 +36,21 @@ function parseBooleanEnv(name: string, defaultValue: boolean): boolean {
|
||||||
* Defaults to false when unset or invalid.
|
* Defaults to false when unset or invalid.
|
||||||
*/
|
*/
|
||||||
export function isAnonymousAuthSessionsEnabled(): boolean {
|
export function isAnonymousAuthSessionsEnabled(): boolean {
|
||||||
if (!isAuthEnabled()) return false;
|
getRequiredAuthEnv();
|
||||||
return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false);
|
return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GitHub sign-in is available only when auth is enabled AND
|
* GitHub sign-in is available when both GITHUB_CLIENT_ID and
|
||||||
* both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set.
|
* GITHUB_CLIENT_SECRET are set.
|
||||||
*/
|
*/
|
||||||
export function isGithubAuthEnabled(): boolean {
|
export function isGithubAuthEnabled(): boolean {
|
||||||
if (!isAuthEnabled()) return false;
|
|
||||||
return !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
return !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the auth base URL if auth is enabled, otherwise null.
|
* Get the required auth base URL.
|
||||||
*/
|
*/
|
||||||
export function getAuthBaseUrl(): string | null {
|
export function getAuthBaseUrl(): string {
|
||||||
if (!isAuthEnabled()) {
|
return getRequiredAuthEnv().baseUrl;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return process.env.BASE_URL || null;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { userJobEvents } from '@/db/schema';
|
import { userJobEvents } from '@/db/schema';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
|
||||||
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
import type { RuntimeConfig } from '@/lib/server/admin/settings';
|
import type { RuntimeConfig } from '@/lib/server/admin/settings';
|
||||||
|
|
||||||
|
|
@ -63,7 +62,7 @@ export function getPdfLayoutRateConfig(runtime: Pick<
|
||||||
const safeDb = () => db as any;
|
const safeDb = () => db as any;
|
||||||
|
|
||||||
function isActive(config: Pick<JobRateConfig, 'enabled'>, userId: string | null | undefined): userId is string {
|
function isActive(config: Pick<JobRateConfig, 'enabled'>, userId: string | null | undefined): userId is string {
|
||||||
return isAuthEnabled() && config.enabled && Boolean(userId);
|
return config.enabled && Boolean(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countSince(userId: string, action: JobRateAction, since: number): Promise<number> {
|
async function countSince(userId: string, action: JobRateAction, since: number): Promise<number> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { userTtsChars } from '@/db/schema';
|
import { userTtsChars } from '@/db/schema';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
|
||||||
import { eq, and, lt, sql } from 'drizzle-orm';
|
import { eq, and, lt, sql } from 'drizzle-orm';
|
||||||
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
|
|
@ -158,7 +157,7 @@ export class RateLimiter {
|
||||||
): Promise<RateLimitResult> {
|
): Promise<RateLimitResult> {
|
||||||
const limits = resolveRateLimitThresholds(options?.limits);
|
const limits = resolveRateLimitThresholds(options?.limits);
|
||||||
const enabled = options?.enabled ?? true;
|
const enabled = options?.enabled ?? true;
|
||||||
if (!isAuthEnabled() || !enabled) {
|
if (!enabled) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: 0,
|
currentCount: 0,
|
||||||
|
|
@ -265,7 +264,7 @@ export class RateLimiter {
|
||||||
): Promise<RateLimitResult> {
|
): Promise<RateLimitResult> {
|
||||||
const limits = resolveRateLimitThresholds(options?.limits);
|
const limits = resolveRateLimitThresholds(options?.limits);
|
||||||
const enabled = options?.enabled ?? true;
|
const enabled = options?.enabled ?? true;
|
||||||
if (!isAuthEnabled() || !enabled) {
|
if (!enabled) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: 0,
|
currentCount: 0,
|
||||||
|
|
@ -321,8 +320,6 @@ export class RateLimiter {
|
||||||
* Transfer char counts when anonymous user creates an account
|
* Transfer char counts when anonymous user creates an account
|
||||||
*/
|
*/
|
||||||
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
|
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
|
||||||
if (!isAuthEnabled()) return;
|
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const dateValue = today as unknown as UserTtsCharsDateValue;
|
const dateValue = today as unknown as UserTtsCharsDateValue;
|
||||||
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
|
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
|
||||||
|
|
|
||||||
|
|
@ -56,14 +56,83 @@ const replicateBlockedUntilByScope = new LRUCache<string, number>({
|
||||||
max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES,
|
max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
|
const DEFAULT_TTS_CACHE_MAX_SIZE_BYTES = 256 * 1024 * 1024;
|
||||||
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
|
const DEFAULT_TTS_CACHE_TTL_MS = 1000 * 60 * 30;
|
||||||
|
const DEFAULT_TTS_UPSTREAM_MAX_RETRIES = 2;
|
||||||
|
const DEFAULT_TTS_UPSTREAM_TIMEOUT_MS = 285_000;
|
||||||
|
const OPENAI_RETRY_INITIAL_DELAY_MS = 250;
|
||||||
|
const OPENAI_RETRY_MAX_DELAY_MS = 2000;
|
||||||
|
const OPENAI_RETRY_BACKOFF = 2;
|
||||||
|
|
||||||
const ttsAudioCache = new LRUCache<string, Buffer>({
|
export interface TtsUpstreamRuntimeSettings {
|
||||||
maxSize: TTS_CACHE_MAX_SIZE_BYTES,
|
ttsCacheMaxSizeBytes?: number;
|
||||||
sizeCalculation: (value) => value.byteLength,
|
ttsCacheTtlMs?: number;
|
||||||
ttl: TTS_CACHE_TTL_MS,
|
ttsUpstreamMaxRetries?: number;
|
||||||
});
|
ttsUpstreamTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedTtsUpstreamRuntimeSettings {
|
||||||
|
ttsCacheMaxSizeBytes: number;
|
||||||
|
ttsCacheTtlMs: number;
|
||||||
|
ttsUpstreamMaxRetries: number;
|
||||||
|
ttsUpstreamTimeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPositiveInteger(value: unknown, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTtsUpstreamSettings(
|
||||||
|
settings?: TtsUpstreamRuntimeSettings,
|
||||||
|
): ResolvedTtsUpstreamRuntimeSettings {
|
||||||
|
return {
|
||||||
|
ttsCacheMaxSizeBytes: clampPositiveInteger(
|
||||||
|
settings?.ttsCacheMaxSizeBytes,
|
||||||
|
DEFAULT_TTS_CACHE_MAX_SIZE_BYTES,
|
||||||
|
),
|
||||||
|
ttsCacheTtlMs: clampPositiveInteger(settings?.ttsCacheTtlMs, DEFAULT_TTS_CACHE_TTL_MS),
|
||||||
|
ttsUpstreamMaxRetries: clampPositiveInteger(
|
||||||
|
settings?.ttsUpstreamMaxRetries,
|
||||||
|
DEFAULT_TTS_UPSTREAM_MAX_RETRIES,
|
||||||
|
),
|
||||||
|
ttsUpstreamTimeoutMs: clampPositiveInteger(
|
||||||
|
settings?.ttsUpstreamTimeoutMs,
|
||||||
|
DEFAULT_TTS_UPSTREAM_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTtsAudioCache(maxSize: number, ttlMs: number): LRUCache<string, Buffer> {
|
||||||
|
return new LRUCache<string, Buffer>({
|
||||||
|
maxSize,
|
||||||
|
sizeCalculation: (value) => value.byteLength,
|
||||||
|
ttl: ttlMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeCacheConfig = {
|
||||||
|
maxSize: DEFAULT_TTS_CACHE_MAX_SIZE_BYTES,
|
||||||
|
ttlMs: DEFAULT_TTS_CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ttsAudioCache = createTtsAudioCache(activeCacheConfig.maxSize, activeCacheConfig.ttlMs);
|
||||||
|
|
||||||
|
function ensureTtsAudioCache(settings: ResolvedTtsUpstreamRuntimeSettings): void {
|
||||||
|
if (
|
||||||
|
activeCacheConfig.maxSize === settings.ttsCacheMaxSizeBytes
|
||||||
|
&& activeCacheConfig.ttlMs === settings.ttsCacheTtlMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCacheConfig = {
|
||||||
|
maxSize: settings.ttsCacheMaxSizeBytes,
|
||||||
|
ttlMs: settings.ttsCacheTtlMs,
|
||||||
|
};
|
||||||
|
ttsAudioCache = createTtsAudioCache(activeCacheConfig.maxSize, activeCacheConfig.ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
const inflightRequests = new Map<string, InflightEntry>();
|
const inflightRequests = new Map<string, InflightEntry>();
|
||||||
|
|
||||||
|
|
@ -324,13 +393,11 @@ async function getTestMockTtsBuffer(testNamespace?: string | null): Promise<Buff
|
||||||
async function fetchTTSBufferWithRetry(
|
async function fetchTTSBufferWithRetry(
|
||||||
openai: OpenAI,
|
openai: OpenAI,
|
||||||
createParams: ExtendedSpeechParams,
|
createParams: ExtendedSpeechParams,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
maxRetries: number,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
let delay = OPENAI_RETRY_INITIAL_DELAY_MS;
|
||||||
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
|
||||||
const maxDelay = Number(process.env.TTS_RETRY_MAX_MS ?? 2000);
|
|
||||||
const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2);
|
|
||||||
|
|
||||||
for (; ;) {
|
for (; ;) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -348,8 +415,8 @@ async function fetchTTSBufferWithRetry(
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(Math.min(delay, maxDelay));
|
await sleep(Math.min(delay, OPENAI_RETRY_MAX_DELAY_MS));
|
||||||
delay = Math.min(maxDelay, delay * backoff);
|
delay = Math.min(OPENAI_RETRY_MAX_DELAY_MS, delay * OPENAI_RETRY_BACKOFF);
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -434,14 +501,17 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: AbortSignal): Promise<Buffer> {
|
async function runReplicateRequest(
|
||||||
|
request: ResolvedServerTTSRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
maxRetries: number,
|
||||||
|
): Promise<Buffer> {
|
||||||
const replicate = new Replicate({ auth: request.apiKey });
|
const replicate = new Replicate({ auth: request.apiKey });
|
||||||
const input = await buildReplicateInput(request);
|
const input = await buildReplicateInput(request);
|
||||||
const modelId = request.model as `${string}/${string}`;
|
const modelId = request.model as `${string}/${string}`;
|
||||||
const cooldownScopeKey = getReplicateCooldownScopeKey(request);
|
const cooldownScopeKey = getReplicateCooldownScopeKey(request);
|
||||||
|
|
||||||
return runWithReplicateGate(cooldownScopeKey, signal, async () => {
|
return runWithReplicateGate(cooldownScopeKey, signal, async () => {
|
||||||
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
for (; ;) {
|
for (; ;) {
|
||||||
|
|
@ -493,19 +563,23 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runProviderRequest(request: ResolvedServerTTSRequest, signal: AbortSignal): Promise<Buffer> {
|
async function runProviderRequest(
|
||||||
|
request: ResolvedServerTTSRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
upstreamSettings: ResolvedTtsUpstreamRuntimeSettings,
|
||||||
|
): Promise<Buffer> {
|
||||||
const mockBuffer = await getTestMockTtsBuffer(request.testNamespace);
|
const mockBuffer = await getTestMockTtsBuffer(request.testNamespace);
|
||||||
if (mockBuffer) return mockBuffer;
|
if (mockBuffer) return mockBuffer;
|
||||||
|
|
||||||
if (request.provider === 'replicate') {
|
if (request.provider === 'replicate') {
|
||||||
return runReplicateRequest(request, signal);
|
return runReplicateRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries);
|
||||||
}
|
}
|
||||||
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: request.apiKey,
|
apiKey: request.apiKey,
|
||||||
baseURL: request.baseUrl,
|
baseURL: request.baseUrl,
|
||||||
maxRetries: 0,
|
maxRetries: 0,
|
||||||
timeout: Number(process.env.TTS_UPSTREAM_TIMEOUT_MS ?? 285_000),
|
timeout: upstreamSettings.ttsUpstreamTimeoutMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createParams: ExtendedSpeechParams = {
|
const createParams: ExtendedSpeechParams = {
|
||||||
|
|
@ -520,13 +594,17 @@ async function runProviderRequest(request: ResolvedServerTTSRequest, signal: Abo
|
||||||
createParams.instructions = request.instructions;
|
createParams.instructions = request.instructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchTTSBufferWithRetry(openai, createParams, signal);
|
return fetchTTSBufferWithRetry(openai, createParams, signal, upstreamSettings.ttsUpstreamMaxRetries);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateTTSBuffer(
|
export async function generateTTSBuffer(
|
||||||
request: ServerTTSRequest,
|
request: ServerTTSRequest,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
runtimeSettings?: TtsUpstreamRuntimeSettings,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
|
const upstreamSettings = resolveTtsUpstreamSettings(runtimeSettings);
|
||||||
|
ensureTtsAudioCache(upstreamSettings);
|
||||||
|
|
||||||
const resolved = resolveTTSRequest(request);
|
const resolved = resolveTTSRequest(request);
|
||||||
const cacheKey = makeCacheKey({
|
const cacheKey = makeCacheKey({
|
||||||
provider: resolved.provider,
|
provider: resolved.provider,
|
||||||
|
|
@ -569,7 +647,7 @@ export async function generateTTSBuffer(
|
||||||
consumers: 1,
|
consumers: 1,
|
||||||
promise: (async () => {
|
promise: (async () => {
|
||||||
try {
|
try {
|
||||||
const buffer = await runProviderRequest(resolved, controller.signal);
|
const buffer = await runProviderRequest(resolved, controller.signal, upstreamSettings);
|
||||||
ttsAudioCache.set(cacheKey, buffer);
|
ttsAudioCache.set(cacheKey, buffer);
|
||||||
return buffer;
|
return buffer;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
|
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
|
||||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
|
||||||
|
|
||||||
export type ResolvedSegmentAudio = {
|
export type ResolvedSegmentAudio = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
|
@ -54,6 +53,7 @@ export async function resolveCompletedSegmentAudio(
|
||||||
return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { resolveSegmentDocumentScope } = await import('@/lib/server/tts/segments-auth');
|
||||||
const scope = await resolveSegmentDocumentScope(request, documentId);
|
const scope = await resolveSegmentDocumentScope(request, documentId);
|
||||||
if (scope instanceof Response) return scope;
|
if (scope instanceof Response) return scope;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,13 @@ import type { NextRequest } from 'next/server';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { documents } from '@/db/schema';
|
import { documents } from '@/db/schema';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
import type { ReaderType } from '@/types/user-state';
|
import type { ReaderType } from '@/types/user-state';
|
||||||
|
|
||||||
export type ResolvedSegmentDocumentScope = {
|
export type ResolvedSegmentDocumentScope = {
|
||||||
testNamespace: string | null;
|
testNamespace: string | null;
|
||||||
storageUserId: string;
|
storageUserId: string;
|
||||||
authEnabled: boolean;
|
userId: string;
|
||||||
userId: string | null;
|
|
||||||
isAnonymousUser: boolean;
|
isAnonymousUser: boolean;
|
||||||
documentVersion: number;
|
documentVersion: number;
|
||||||
readerType: ReaderType;
|
readerType: ReaderType;
|
||||||
|
|
@ -28,11 +27,11 @@ export async function resolveSegmentDocumentScope(
|
||||||
): Promise<ResolvedSegmentDocumentScope | Response> {
|
): Promise<ResolvedSegmentDocumentScope | Response> {
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
if (!ctxOrRes.userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const storageUserId = ctxOrRes.userId;
|
||||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
const allowedUserIds = [storageUserId];
|
||||||
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId];
|
|
||||||
|
|
||||||
const rows = (await db
|
const rows = (await db
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -55,7 +54,6 @@ export async function resolveSegmentDocumentScope(
|
||||||
return {
|
return {
|
||||||
testNamespace,
|
testNamespace,
|
||||||
storageUserId: doc.userId,
|
storageUserId: doc.userId,
|
||||||
authEnabled: ctxOrRes.authEnabled,
|
|
||||||
userId: ctxOrRes.userId,
|
userId: ctxOrRes.userId,
|
||||||
isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous),
|
isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous),
|
||||||
documentVersion: Number(doc.lastModified),
|
documentVersion: Number(doc.lastModified),
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import {
|
||||||
} from '../audiobooks/blobstore';
|
} from '../audiobooks/blobstore';
|
||||||
import { isS3Configured } from '../storage/s3';
|
import { isS3Configured } from '../storage/s3';
|
||||||
|
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
|
||||||
|
|
||||||
type AudiobookRow = {
|
type AudiobookRow = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -89,7 +87,7 @@ async function moveAudiobookBlobScope(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) {
|
export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) {
|
||||||
if (!isAuthEnabled() || !userId) {
|
if (!userId) {
|
||||||
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 };
|
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +120,7 @@ export async function transferUserDocuments(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
options?: { db?: any },
|
options?: { db?: any },
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
|
if (!fromUserId || !toUserId) return 0;
|
||||||
if (fromUserId === toUserId) return 0;
|
if (fromUserId === toUserId) return 0;
|
||||||
|
|
||||||
const database = options?.db ?? db;
|
const database = options?.db ?? db;
|
||||||
|
|
@ -150,7 +148,7 @@ export async function transferUserAudiobooks(
|
||||||
toUserId: string,
|
toUserId: string,
|
||||||
namespace: string | null = null,
|
namespace: string | null = null,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
|
if (!fromUserId || !toUserId) return 0;
|
||||||
if (fromUserId === toUserId) return 0;
|
if (fromUserId === toUserId) return 0;
|
||||||
|
|
||||||
const books = (await db
|
const books = (await db
|
||||||
|
|
@ -188,7 +186,7 @@ export async function transferUserAudiobooks(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise<number> {
|
export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise<number> {
|
||||||
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
|
if (!fromUserId || !toUserId) return 0;
|
||||||
if (fromUserId === toUserId) return 0;
|
if (fromUserId === toUserId) return 0;
|
||||||
|
|
||||||
const fromRows = (await db
|
const fromRows = (await db
|
||||||
|
|
@ -226,7 +224,7 @@ export async function transferUserPreferences(fromUserId: string, toUserId: stri
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function transferUserProgress(fromUserId: string, toUserId: string): Promise<number> {
|
export async function transferUserProgress(fromUserId: string, toUserId: string): Promise<number> {
|
||||||
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
|
if (!fromUserId || !toUserId) return 0;
|
||||||
if (fromUserId === toUserId) return 0;
|
if (fromUserId === toUserId) return 0;
|
||||||
|
|
||||||
const fromRows = (await db
|
const fromRows = (await db
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import type { AuthContext } from '@/lib/server/auth/auth';
|
import type { AuthContext } from '@/lib/server/auth/auth';
|
||||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
|
|
||||||
export type ResolvedUserStateScope = {
|
export type ResolvedUserStateScope = {
|
||||||
auth: AuthContext;
|
auth: AuthContext;
|
||||||
namespace: string | null;
|
namespace: string | null;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
unclaimedUserId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function resolveUserStateScope(
|
export async function resolveUserStateScope(
|
||||||
|
|
@ -15,16 +14,14 @@ export async function resolveUserStateScope(
|
||||||
): Promise<ResolvedUserStateScope | Response> {
|
): Promise<ResolvedUserStateScope | Response> {
|
||||||
const auth = await requireAuthContext(req);
|
const auth = await requireAuthContext(req);
|
||||||
if (auth instanceof Response) return auth;
|
if (auth instanceof Response) return auth;
|
||||||
|
if (!auth.userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const namespace = getOpenReaderTestNamespace(req.headers);
|
const namespace = getOpenReaderTestNamespace(req.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace);
|
const ownerUserId = auth.userId;
|
||||||
const ownerUserId = auth.userId ?? unclaimedUserId;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
auth,
|
auth,
|
||||||
namespace,
|
namespace,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
unclaimedUserId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { getRequiredAuthEnv } from '@/lib/server/auth/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Better Auth session cookie name (default prefix + session_token).
|
* Better Auth session cookie name (default prefix + session_token).
|
||||||
|
|
@ -34,12 +35,8 @@ function isPublicPath(pathname: string): boolean {
|
||||||
return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAuthEnabled(): boolean {
|
|
||||||
return !!(process.env.AUTH_SECRET && process.env.BASE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAnonymousAuthEnabled(): boolean {
|
function isAnonymousAuthEnabled(): boolean {
|
||||||
if (!isAuthEnabled()) return false;
|
getRequiredAuthEnv();
|
||||||
const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS;
|
const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS;
|
||||||
return raw?.trim().toLowerCase() === 'true';
|
return raw?.trim().toLowerCase() === 'true';
|
||||||
}
|
}
|
||||||
|
|
@ -88,10 +85,7 @@ export function middleware(request: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When auth is disabled entirely, let everything through.
|
getRequiredAuthEnv();
|
||||||
if (!isAuthEnabled()) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export interface BaseDocument {
|
||||||
type: DocumentType;
|
type: DocumentType;
|
||||||
parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null;
|
parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null;
|
||||||
parsedJsonKey?: string | null;
|
parsedJsonKey?: string | null;
|
||||||
scope?: 'user' | 'unclaimed';
|
scope?: 'user';
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
isConverting?: boolean;
|
isConverting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName, deleteAllLocalDocuments, ensureDocumentsListed } from './helpers';
|
import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName } from './helpers';
|
||||||
|
|
||||||
test.describe('Document deletion flow', () => {
|
test.describe('Document deletion flow', () => {
|
||||||
test.beforeEach(async ({ page }, testInfo) => {
|
test.beforeEach(async ({ page }, testInfo) => {
|
||||||
|
|
@ -23,30 +23,4 @@ test.describe('Document deletion flow', () => {
|
||||||
await expectDocumentListed(page, 'sample.pdf');
|
await expectDocumentListed(page, 'sample.pdf');
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deletes all local documents from Settings modal', async ({ page }) => {
|
|
||||||
// This test only applies when auth is NOT enabled, since with auth
|
|
||||||
// the bulk-delete UI lives in the delete-account flow instead.
|
|
||||||
test.skip(
|
|
||||||
Boolean(process.env.AUTH_SECRET && process.env.BASE_URL),
|
|
||||||
'Bulk document deletion is part of the delete-account flow when auth is enabled',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Upload multiple docs (PDF + EPUB)
|
|
||||||
await uploadFile(page, 'sample.pdf');
|
|
||||||
await uploadFile(page, 'sample.epub');
|
|
||||||
|
|
||||||
// Verify both appear
|
|
||||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']);
|
|
||||||
|
|
||||||
// Delete all local documents via Settings
|
|
||||||
await deleteAllLocalDocuments(page);
|
|
||||||
|
|
||||||
// Assert both documents are removed
|
|
||||||
await expectNoDocumentLink(page, 'sample.pdf');
|
|
||||||
await expectNoDocumentLink(page, 'sample.epub');
|
|
||||||
|
|
||||||
// Uploader should be visible when no docs remain
|
|
||||||
await expect(page.locator('input[type=file]').first()).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ function parseAudiobookScopeFromKey(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function globalTeardown(): Promise<void> {
|
export default async function globalTeardown(): Promise<void> {
|
||||||
// Always clear namespaced no-auth SQL rows from prior runs.
|
// Always clear namespaced unclaimed SQL rows from prior runs.
|
||||||
await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%'));
|
await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%'));
|
||||||
await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%'));
|
await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%'));
|
||||||
await db.delete(documents).where(like(documents.userId, 'unclaimed::%'));
|
await db.delete(documents).where(like(documents.userId, 'unclaimed::%'));
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,6 @@ import { createHash } from 'crypto';
|
||||||
|
|
||||||
const DIR = './tests/files/';
|
const DIR = './tests/files/';
|
||||||
|
|
||||||
function isAuthEnabledForTests() {
|
|
||||||
return Boolean(process.env.AUTH_SECRET && process.env.BASE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small util to safely use filenames inside regex patterns
|
// Small util to safely use filenames inside regex patterns
|
||||||
function escapeRegExp(input: string) {
|
function escapeRegExp(input: string) {
|
||||||
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
@ -250,37 +246,6 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
|
||||||
await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace });
|
await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace });
|
||||||
}
|
}
|
||||||
|
|
||||||
// In no-auth mode, all tests in a worker share the same server-side unclaimed identity.
|
|
||||||
// Clear docs at setup to avoid cross-test collisions on duplicate filenames.
|
|
||||||
if (!isAuthEnabledForTests()) {
|
|
||||||
const headers = namespace ? { 'x-openreader-test-namespace': namespace } : undefined;
|
|
||||||
let cleared = false;
|
|
||||||
let authProtected = false;
|
|
||||||
let attempts = 0;
|
|
||||||
while (!cleared && attempts < 3) {
|
|
||||||
attempts += 1;
|
|
||||||
try {
|
|
||||||
const res = await page.request.delete('/api/documents', { ...(headers ? { headers } : {}) });
|
|
||||||
// If this endpoint requires auth, we're not in no-auth mode for this run.
|
|
||||||
// Skip cleanup rather than hard-failing setup.
|
|
||||||
if (res.status() === 401 || res.status() === 403) {
|
|
||||||
authProtected = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (res.ok()) {
|
|
||||||
cleared = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// retry
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
if (!cleared && !authProtected) {
|
|
||||||
throw new Error('Failed to clear server documents before test setup');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-seed consent to prevent the cookie banner from blocking interactions.
|
// Pre-seed consent to prevent the cookie banner from blocking interactions.
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope';
|
|
||||||
|
|
||||||
describe('audiobook scope selection', () => {
|
|
||||||
test('uses only unclaimed scope when auth is disabled', () => {
|
|
||||||
const result = buildAllowedAudiobookUserIds(false, null, 'unclaimed::ns');
|
|
||||||
expect(result.preferredUserId).toBe('unclaimed::ns');
|
|
||||||
expect(result.allowedUserIds).toEqual(['unclaimed::ns']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('includes both preferred and unclaimed scopes when auth is enabled', () => {
|
|
||||||
const result = buildAllowedAudiobookUserIds(true, 'user-123', 'unclaimed::ns');
|
|
||||||
expect(result.preferredUserId).toBe('user-123');
|
|
||||||
expect(result.allowedUserIds).toEqual(['user-123', 'unclaimed::ns']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deduplicates preferred/unclaimed ids when they are the same', () => {
|
|
||||||
const result = buildAllowedAudiobookUserIds(true, 'unclaimed::ns', 'unclaimed::ns');
|
|
||||||
expect(result.allowedUserIds).toEqual(['unclaimed::ns']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prefers unclaimed owner when both scopes exist', () => {
|
|
||||||
const owner = pickAudiobookOwner(['user-123', 'unclaimed::ns'], 'user-123', 'unclaimed::ns');
|
|
||||||
expect(owner).toBe('unclaimed::ns');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to preferred owner when unclaimed is missing', () => {
|
|
||||||
const owner = pickAudiobookOwner(['user-123'], 'user-123', 'unclaimed::ns');
|
|
||||||
expect(owner).toBe('user-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when no matching owners exist', () => {
|
|
||||||
const owner = pickAudiobookOwner([], 'user-123', 'unclaimed::ns');
|
|
||||||
expect(owner).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,27 +1,19 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config';
|
import { getAuthBaseUrl, getRequiredAuthEnv, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config';
|
||||||
import { withEnv } from './support/env';
|
import { withEnv } from './support/env';
|
||||||
|
|
||||||
describe('auth config contract', () => {
|
describe('auth config contract', () => {
|
||||||
test('auth is enabled only when AUTH_SECRET and BASE_URL are both set', async () => {
|
test('reads required AUTH_SECRET and BASE_URL', async () => {
|
||||||
await withEnv(
|
await withEnv(
|
||||||
{
|
{
|
||||||
AUTH_SECRET: undefined,
|
AUTH_SECRET: 'unit-secret-2',
|
||||||
BASE_URL: undefined,
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
expect(isAuthEnabled()).toBe(false);
|
|
||||||
expect(getAuthBaseUrl()).toBeNull();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await withEnv(
|
|
||||||
{
|
|
||||||
AUTH_SECRET: 'unit-secret',
|
|
||||||
BASE_URL: 'http://localhost:3003',
|
BASE_URL: 'http://localhost:3003',
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
expect(isAuthEnabled()).toBe(true);
|
expect(getRequiredAuthEnv()).toEqual({
|
||||||
|
authSecret: 'unit-secret-2',
|
||||||
|
baseUrl: 'http://localhost:3003',
|
||||||
|
});
|
||||||
expect(getAuthBaseUrl()).toBe('http://localhost:3003');
|
expect(getAuthBaseUrl()).toBe('http://localhost:3003');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -49,12 +41,12 @@ describe('auth config contract', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('anonymous sessions are always disabled when auth is disabled', async () => {
|
test('anonymous session config returns false for non-true values', async () => {
|
||||||
await withEnv(
|
await withEnv(
|
||||||
{
|
{
|
||||||
AUTH_SECRET: undefined,
|
AUTH_SECRET: 'unit-secret',
|
||||||
BASE_URL: undefined,
|
BASE_URL: 'http://localhost:3003',
|
||||||
USE_ANONYMOUS_AUTH_SESSIONS: 'true',
|
USE_ANONYMOUS_AUTH_SESSIONS: '1',
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
|
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
|
||||||
|
|
@ -63,16 +55,6 @@ describe('auth config contract', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{
|
|
||||||
title: 'returns false when auth is disabled',
|
|
||||||
env: {
|
|
||||||
AUTH_SECRET: undefined,
|
|
||||||
BASE_URL: undefined,
|
|
||||||
GITHUB_CLIENT_ID: 'id',
|
|
||||||
GITHUB_CLIENT_SECRET: 'secret',
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'returns false when GitHub client id is missing',
|
title: 'returns false when GitHub client id is missing',
|
||||||
env: {
|
env: {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ describe('changelog check scheduling', () => {
|
||||||
let openCalls = 0;
|
let openCalls = 0;
|
||||||
|
|
||||||
const args = {
|
const args = {
|
||||||
authEnabled: true,
|
|
||||||
isSessionPending: false,
|
isSessionPending: false,
|
||||||
sessionUserId: 'u1',
|
sessionUserId: 'u1',
|
||||||
appVersion: '3.3.0',
|
appVersion: '3.3.0',
|
||||||
|
|
@ -48,13 +47,12 @@ describe('changelog check scheduling', () => {
|
||||||
expect(inFlightRef.current).toBeNull();
|
expect(inFlightRef.current).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not run when auth is enabled and session is pending', async () => {
|
test('does not run while session is pending', async () => {
|
||||||
const completedRef = { current: null as string | null };
|
const completedRef = { current: null as string | null };
|
||||||
const inFlightRef = { current: null as string | null };
|
const inFlightRef = { current: null as string | null };
|
||||||
let apiCalls = 0;
|
let apiCalls = 0;
|
||||||
|
|
||||||
const cleanup = scheduleChangelogCheck({
|
const cleanup = scheduleChangelogCheck({
|
||||||
authEnabled: true,
|
|
||||||
isSessionPending: true,
|
isSessionPending: true,
|
||||||
sessionUserId: null,
|
sessionUserId: null,
|
||||||
appVersion: '3.3.0',
|
appVersion: '3.3.0',
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ describe('onboarding flow resolver', () => {
|
||||||
expect(step).toBe('changelog');
|
expect(step).toBe('changelog');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolves done when no steps are pending (auth and no-auth parity)', () => {
|
test('resolves done when no steps are pending', () => {
|
||||||
const authStep = resolveNextOnboardingStep({
|
const authStep = resolveNextOnboardingStep({
|
||||||
privacyRequired: true,
|
privacyRequired: true,
|
||||||
privacyAccepted: true,
|
privacyAccepted: true,
|
||||||
|
|
@ -64,7 +64,7 @@ describe('onboarding flow resolver', () => {
|
||||||
migrationRequired: false,
|
migrationRequired: false,
|
||||||
changelogPending: false,
|
changelogPending: false,
|
||||||
});
|
});
|
||||||
const noAuthStep = resolveNextOnboardingStep({
|
const minimalStep = resolveNextOnboardingStep({
|
||||||
privacyRequired: false,
|
privacyRequired: false,
|
||||||
privacyAccepted: false,
|
privacyAccepted: false,
|
||||||
claimEligible: false,
|
claimEligible: false,
|
||||||
|
|
@ -74,7 +74,7 @@ describe('onboarding flow resolver', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(authStep).toBe('done');
|
expect(authStep).toBe('done');
|
||||||
expect(noAuthStep).toBe('done');
|
expect(minimalStep).toBe('done');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,7 @@ describe('TTS rate limit runtime config seeds', () => {
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.default).toBe(true);
|
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.default).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses disable seed boolean values', () => {
|
test('daily limit values are runtime defaults', () => {
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('true')).toBe(true);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('false')).toBe(false);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('1')).toBe(true);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('0')).toBe(false);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('on')).toBe(true);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('no')).toBe(false);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('invalid')).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('daily limit values are runtime-only (no env seed vars)', () => {
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.envVar).toBeUndefined();
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.envVar).toBeUndefined();
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAnonymous.envVar).toBeUndefined();
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAuthenticated.envVar).toBeUndefined();
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.default).toBe(50_000);
|
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.default).toBe(50_000);
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.default).toBe(500_000);
|
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.default).toBe(500_000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
424
tests/unit/runtime-seed-json.vitest.spec.ts
Normal file
424
tests/unit/runtime-seed-json.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
import { and, eq, inArray, not } from 'drizzle-orm';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { db } from '../../src/db';
|
||||||
|
import { adminProviders, adminSettings } from '../../src/db/schema';
|
||||||
|
import { RUNTIME_KEYS, seedRuntimeConfigFromValues } from '../../src/lib/server/admin/settings';
|
||||||
|
import { __seedInternals } from '../../src/lib/server/admin/seed';
|
||||||
|
|
||||||
|
type SettingRow = {
|
||||||
|
key: string;
|
||||||
|
valueJson: unknown;
|
||||||
|
source: string;
|
||||||
|
updatedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderRow = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
providerType: string;
|
||||||
|
baseUrl: string | null;
|
||||||
|
apiKeyCiphertext: string;
|
||||||
|
apiKeyIv: string;
|
||||||
|
apiKeyLast4: string | null;
|
||||||
|
defaultModel: string | null;
|
||||||
|
defaultInstructions: string | null;
|
||||||
|
enabled: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function withEnv(
|
||||||
|
values: Record<string, string | undefined>,
|
||||||
|
run: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const original = new Map<string, string | undefined>();
|
||||||
|
for (const key of Object.keys(values)) {
|
||||||
|
original.set(key, process.env[key]);
|
||||||
|
const next = values[key];
|
||||||
|
if (next === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} finally {
|
||||||
|
for (const [key, previous] of original.entries()) {
|
||||||
|
if (previous === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredValue(stored: unknown): unknown {
|
||||||
|
if (typeof stored === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotSettings(keys: string[]): Promise<SettingRow[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
key: adminSettings.key,
|
||||||
|
valueJson: adminSettings.valueJson,
|
||||||
|
source: adminSettings.source,
|
||||||
|
updatedAt: adminSettings.updatedAt,
|
||||||
|
})
|
||||||
|
.from(adminSettings)
|
||||||
|
.where(inArray(adminSettings.key, keys));
|
||||||
|
return rows as SettingRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSettings(keys: string[], snapshot: SettingRow[]): Promise<void> {
|
||||||
|
await db.delete(adminSettings).where(inArray(adminSettings.key, keys));
|
||||||
|
if (snapshot.length === 0) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const row of snapshot) {
|
||||||
|
await db
|
||||||
|
.insert(adminSettings)
|
||||||
|
.values({
|
||||||
|
key: row.key,
|
||||||
|
valueJson: row.valueJson as never,
|
||||||
|
source: row.source,
|
||||||
|
updatedAt: row.updatedAt ?? now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: adminSettings.key,
|
||||||
|
set: {
|
||||||
|
valueJson: row.valueJson as never,
|
||||||
|
source: row.source,
|
||||||
|
updatedAt: row.updatedAt ?? now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotProvidersBySlug(slugs: string[]): Promise<ProviderRow[]> {
|
||||||
|
const rows = await db.select().from(adminProviders).where(inArray(adminProviders.slug, slugs));
|
||||||
|
return rows as ProviderRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreProvidersBySlug(slugs: string[], snapshot: ProviderRow[]): Promise<void> {
|
||||||
|
await db.delete(adminProviders).where(inArray(adminProviders.slug, slugs));
|
||||||
|
if (snapshot.length === 0) return;
|
||||||
|
for (const row of snapshot) {
|
||||||
|
await db
|
||||||
|
.insert(adminProviders)
|
||||||
|
.values(row)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: adminProviders.slug,
|
||||||
|
set: {
|
||||||
|
displayName: row.displayName,
|
||||||
|
providerType: row.providerType,
|
||||||
|
baseUrl: row.baseUrl,
|
||||||
|
apiKeyCiphertext: row.apiKeyCiphertext,
|
||||||
|
apiKeyIv: row.apiKeyIv,
|
||||||
|
apiKeyLast4: row.apiKeyLast4,
|
||||||
|
defaultModel: row.defaultModel,
|
||||||
|
defaultInstructions: row.defaultInstructions,
|
||||||
|
enabled: row.enabled,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasNonTestProviderRows(testSlugs: string[]): Promise<boolean> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ slug: adminProviders.slug })
|
||||||
|
.from(adminProviders)
|
||||||
|
.where(not(inArray(adminProviders.slug, testSlugs)))
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runtime seed JSON parsing', () => {
|
||||||
|
test('rejects malformed JSON input', () => {
|
||||||
|
expect(() => __seedInternals.parseRuntimeSeedDocument('{not json')).toThrow(/invalid/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unknown top-level seed keys', () => {
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
runtimeConfig: { enableUserSignups: true },
|
||||||
|
extra: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => __seedInternals.parseRuntimeSeedDocument(raw)).toThrow(/unknown top-level/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('providers section disables env fallback path', () => {
|
||||||
|
expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true);
|
||||||
|
expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runtime config JSON seeding', () => {
|
||||||
|
test('seeds validated values with json-seed source and does not overwrite', async () => {
|
||||||
|
const keys: string[] = ['enableUserSignups', 'ttsUpstreamMaxRetries'];
|
||||||
|
const snapshot = await snapshotSettings(keys);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(adminSettings).where(inArray(adminSettings.key, keys));
|
||||||
|
|
||||||
|
const first = await seedRuntimeConfigFromValues({
|
||||||
|
enableUserSignups: false,
|
||||||
|
ttsUpstreamMaxRetries: 9,
|
||||||
|
}, 'json-seed');
|
||||||
|
|
||||||
|
expect(first.unknown).toEqual([]);
|
||||||
|
expect(first.invalid).toEqual([]);
|
||||||
|
expect(first.seeded.sort()).toEqual(['enableUserSignups', 'ttsUpstreamMaxRetries']);
|
||||||
|
|
||||||
|
const rowsAfterFirst = await snapshotSettings(keys);
|
||||||
|
const byKeyFirst = new Map(rowsAfterFirst.map((row) => [row.key, row]));
|
||||||
|
|
||||||
|
expect(parseStoredValue(byKeyFirst.get('enableUserSignups')?.valueJson)).toBe(false);
|
||||||
|
expect(parseStoredValue(byKeyFirst.get('ttsUpstreamMaxRetries')?.valueJson)).toBe(9);
|
||||||
|
expect(byKeyFirst.get('enableUserSignups')?.source).toBe('json-seed');
|
||||||
|
expect(byKeyFirst.get('ttsUpstreamMaxRetries')?.source).toBe('json-seed');
|
||||||
|
|
||||||
|
const second = await seedRuntimeConfigFromValues({
|
||||||
|
enableUserSignups: true,
|
||||||
|
ttsUpstreamMaxRetries: 1,
|
||||||
|
}, 'json-seed');
|
||||||
|
|
||||||
|
expect(second.seeded).toEqual([]);
|
||||||
|
const rowsAfterSecond = await snapshotSettings(keys);
|
||||||
|
const byKeySecond = new Map(rowsAfterSecond.map((row) => [row.key, row]));
|
||||||
|
expect(parseStoredValue(byKeySecond.get('enableUserSignups')?.valueJson)).toBe(false);
|
||||||
|
expect(parseStoredValue(byKeySecond.get('ttsUpstreamMaxRetries')?.valueJson)).toBe(9);
|
||||||
|
} finally {
|
||||||
|
await restoreSettings(keys, snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strictly rejects unknown/invalid entries without writes', async () => {
|
||||||
|
const key = 'enableUserSignups';
|
||||||
|
const snapshot = await snapshotSettings([key]);
|
||||||
|
try {
|
||||||
|
await db.delete(adminSettings).where(eq(adminSettings.key, key));
|
||||||
|
const result = await seedRuntimeConfigFromValues({
|
||||||
|
unknownRuntimeSetting: true,
|
||||||
|
enableUserSignups: 'false',
|
||||||
|
}, 'json-seed');
|
||||||
|
|
||||||
|
expect(result.unknown).toEqual(['unknownRuntimeSetting']);
|
||||||
|
expect(result.invalid).toEqual(['enableUserSignups']);
|
||||||
|
expect(result.seeded).toEqual([]);
|
||||||
|
|
||||||
|
const rows = await snapshotSettings([key]);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await restoreSettings([key], snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports full runtime config JSON seeding across all keys', async () => {
|
||||||
|
const keys: string[] = [...RUNTIME_KEYS];
|
||||||
|
const snapshot = await snapshotSettings(keys);
|
||||||
|
const fullPayload: Record<string, unknown> = {
|
||||||
|
defaultTtsProvider: 'seed-shared-provider',
|
||||||
|
changelogFeedUrl: 'https://example.com/changelog/manifest.json',
|
||||||
|
enableUserSignups: false,
|
||||||
|
restrictUserApiKeys: false,
|
||||||
|
enableTtsProvidersTab: false,
|
||||||
|
enableAudiobookExport: false,
|
||||||
|
enableDocxConversion: false,
|
||||||
|
enableDestructiveDeleteActions: false,
|
||||||
|
showAllProviderModels: false,
|
||||||
|
disableTtsRateLimit: false,
|
||||||
|
ttsDailyLimitAnonymous: 12345,
|
||||||
|
ttsDailyLimitAuthenticated: 23456,
|
||||||
|
ttsIpDailyLimitAnonymous: 34567,
|
||||||
|
ttsIpDailyLimitAuthenticated: 45678,
|
||||||
|
ttsCacheMaxSizeBytes: 16 * 1024 * 1024,
|
||||||
|
ttsCacheTtlMs: 600_000,
|
||||||
|
ttsUpstreamMaxRetries: 3,
|
||||||
|
ttsUpstreamTimeoutMs: 120_000,
|
||||||
|
disableComputeRateLimit: false,
|
||||||
|
computeParseBurstMax: 4,
|
||||||
|
computeParseBurstWindowSec: 30,
|
||||||
|
computeParseSustainedMax: 12,
|
||||||
|
computeParseSustainedWindowSec: 300,
|
||||||
|
maxUploadMb: 150,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await db.delete(adminSettings).where(inArray(adminSettings.key, keys));
|
||||||
|
const result = await seedRuntimeConfigFromValues(fullPayload, 'json-seed');
|
||||||
|
expect(result.unknown).toEqual([]);
|
||||||
|
expect(result.invalid).toEqual([]);
|
||||||
|
expect(result.seeded).toHaveLength(RUNTIME_KEYS.length);
|
||||||
|
|
||||||
|
const rows = await snapshotSettings(keys);
|
||||||
|
const byKey = new Map(rows.map((row) => [row.key, row]));
|
||||||
|
expect(parseStoredValue(byKey.get('defaultTtsProvider')?.valueJson)).toBe('seed-shared-provider');
|
||||||
|
expect(parseStoredValue(byKey.get('enableUserSignups')?.valueJson)).toBe(false);
|
||||||
|
expect(parseStoredValue(byKey.get('ttsUpstreamTimeoutMs')?.valueJson)).toBe(120_000);
|
||||||
|
expect(parseStoredValue(byKey.get('maxUploadMb')?.valueJson)).toBe(150);
|
||||||
|
expect(byKey.get('defaultTtsProvider')?.source).toBe('json-seed');
|
||||||
|
} finally {
|
||||||
|
await restoreSettings(keys, snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schema key set is complete for JSON seed support', () => {
|
||||||
|
expect(RUNTIME_KEYS.length).toBeGreaterThan(0);
|
||||||
|
expect(RUNTIME_KEYS).toContain('defaultTtsProvider');
|
||||||
|
expect(RUNTIME_KEYS).toContain('ttsUpstreamTimeoutMs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('provider seeding and fallback precedence', () => {
|
||||||
|
const testSlugs = ['json-seeded-provider', 'default-openai'];
|
||||||
|
|
||||||
|
test('seeds providers from JSON and skips API_BASE/API_KEY fallback', async () => {
|
||||||
|
const providerSnapshot = await snapshotProvidersBySlug(testSlugs);
|
||||||
|
const runtimeSeed = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
slug: 'json-seeded-provider',
|
||||||
|
displayName: 'JSON Seeded Provider',
|
||||||
|
providerType: 'custom-openai',
|
||||||
|
baseUrl: 'http://localhost:5555/v1',
|
||||||
|
apiKey: 'seeded_provider_key_1234',
|
||||||
|
defaultModel: 'kokoro',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs));
|
||||||
|
|
||||||
|
await withEnv({
|
||||||
|
RUNTIME_SEED_JSON: runtimeSeed,
|
||||||
|
RUNTIME_SEED_JSON_PATH: undefined,
|
||||||
|
API_BASE: 'http://localhost:9999/v1',
|
||||||
|
API_KEY: 'fallback_should_not_be_used',
|
||||||
|
AUTH_SECRET: 'seed-test-auth-secret-123',
|
||||||
|
}, async () => {
|
||||||
|
await __seedInternals.runSeed();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
slug: adminProviders.slug,
|
||||||
|
displayName: adminProviders.displayName,
|
||||||
|
providerType: adminProviders.providerType,
|
||||||
|
baseUrl: adminProviders.baseUrl,
|
||||||
|
defaultModel: adminProviders.defaultModel,
|
||||||
|
apiKeyLast4: adminProviders.apiKeyLast4,
|
||||||
|
})
|
||||||
|
.from(adminProviders)
|
||||||
|
.where(inArray(adminProviders.slug, testSlugs));
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
slug: 'json-seeded-provider',
|
||||||
|
displayName: 'JSON Seeded Provider',
|
||||||
|
providerType: 'custom-openai',
|
||||||
|
baseUrl: 'http://localhost:5555/v1',
|
||||||
|
defaultModel: 'kokoro',
|
||||||
|
apiKeyLast4: '1234',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await restoreProvidersBySlug(testSlugs, providerSnapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to API_BASE/API_KEY when JSON providers are absent', async () => {
|
||||||
|
const providerSnapshot = await snapshotProvidersBySlug(testSlugs);
|
||||||
|
const runtimeSeed = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs));
|
||||||
|
const blockedByOtherRows = await hasNonTestProviderRows(testSlugs);
|
||||||
|
if (blockedByOtherRows) return;
|
||||||
|
|
||||||
|
await withEnv({
|
||||||
|
RUNTIME_SEED_JSON: runtimeSeed,
|
||||||
|
RUNTIME_SEED_JSON_PATH: undefined,
|
||||||
|
API_BASE: 'http://localhost:8880/v1',
|
||||||
|
API_KEY: 'fallback_env_api_key_9876',
|
||||||
|
AUTH_SECRET: 'seed-test-auth-secret-456',
|
||||||
|
}, async () => {
|
||||||
|
await __seedInternals.runSeed();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
slug: adminProviders.slug,
|
||||||
|
displayName: adminProviders.displayName,
|
||||||
|
providerType: adminProviders.providerType,
|
||||||
|
baseUrl: adminProviders.baseUrl,
|
||||||
|
defaultModel: adminProviders.defaultModel,
|
||||||
|
apiKeyLast4: adminProviders.apiKeyLast4,
|
||||||
|
})
|
||||||
|
.from(adminProviders);
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
slug: 'default-openai',
|
||||||
|
displayName: 'Default (from env)',
|
||||||
|
providerType: 'custom-openai',
|
||||||
|
baseUrl: 'http://localhost:8880/v1',
|
||||||
|
defaultModel: 'kokoro',
|
||||||
|
apiKeyLast4: '9876',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await restoreProvidersBySlug(testSlugs, providerSnapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => {
|
||||||
|
const providerSnapshot = await snapshotProvidersBySlug(testSlugs);
|
||||||
|
const runtimeSeed = JSON.stringify({ version: 1, providers: [] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs));
|
||||||
|
const blockedByOtherRows = await hasNonTestProviderRows(testSlugs);
|
||||||
|
if (blockedByOtherRows) return;
|
||||||
|
|
||||||
|
await withEnv({
|
||||||
|
RUNTIME_SEED_JSON: runtimeSeed,
|
||||||
|
RUNTIME_SEED_JSON_PATH: undefined,
|
||||||
|
API_BASE: 'http://localhost:7777/v1',
|
||||||
|
API_KEY: 'fallback_should_stay_unused_1111',
|
||||||
|
AUTH_SECRET: 'seed-test-auth-secret-789',
|
||||||
|
}, async () => {
|
||||||
|
await __seedInternals.runSeed();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ slug: adminProviders.slug })
|
||||||
|
.from(adminProviders)
|
||||||
|
.where(eq(adminProviders.slug, 'default-openai')) as Array<{ slug: string }>;
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await restoreProvidersBySlug(testSlugs, providerSnapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on malformed runtime seed JSON during full seed execution', async () => {
|
||||||
|
await withEnv({
|
||||||
|
RUNTIME_SEED_JSON: '{bad json',
|
||||||
|
RUNTIME_SEED_JSON_PATH: undefined,
|
||||||
|
API_BASE: undefined,
|
||||||
|
API_KEY: undefined,
|
||||||
|
AUTH_SECRET: 'seed-test-auth-secret-abc',
|
||||||
|
}, async () => {
|
||||||
|
await expect(__seedInternals.runSeed()).rejects.toThrow(/invalid/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
7
tests/unit/setup-env.ts
Normal file
7
tests/unit/setup-env.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
if (!process.env.AUTH_SECRET?.trim()) {
|
||||||
|
process.env.AUTH_SECRET = 'vitest-auth-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.BASE_URL?.trim()) {
|
||||||
|
process.env.BASE_URL = 'http://localhost:3003';
|
||||||
|
}
|
||||||
|
|
@ -7,16 +7,6 @@ describe('enableUserSignups runtime config', () => {
|
||||||
test('defaults to enabled', () => {
|
test('defaults to enabled', () => {
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.default).toBe(true);
|
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.default).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses first-boot env seed values', () => {
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('true')).toBe(true);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('false')).toBe(false);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('1')).toBe(true);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('0')).toBe(false);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('yes')).toBe(true);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('off')).toBe(false);
|
|
||||||
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('maybe')).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('signup policy enforcement', () => {
|
describe('signup policy enforcement', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
if (!process.env.AUTH_SECRET?.trim()) {
|
||||||
|
process.env.AUTH_SECRET = 'vitest-auth-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//.test(process.env.BASE_URL ?? '')) {
|
||||||
|
process.env.BASE_URL = 'http://localhost:3003';
|
||||||
|
}
|
||||||
|
|
||||||
const srcDir = fileURLToPath(new URL('./src/', import.meta.url));
|
const srcDir = fileURLToPath(new URL('./src/', import.meta.url));
|
||||||
const computeCoreIndex = fileURLToPath(new URL('./compute/core/src/index.ts', import.meta.url));
|
const computeCoreIndex = fileURLToPath(new URL('./compute/core/src/index.ts', import.meta.url));
|
||||||
const computeCoreApiContracts = fileURLToPath(new URL('./compute/core/src/api-contracts/index.ts', import.meta.url));
|
const computeCoreApiContracts = fileURLToPath(new URL('./compute/core/src/api-contracts/index.ts', import.meta.url));
|
||||||
|
|
@ -33,6 +41,7 @@ export default defineConfig({
|
||||||
name: 'openreader',
|
name: 'openreader',
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/unit/**/*.vitest.spec.ts'],
|
include: ['tests/unit/**/*.vitest.spec.ts'],
|
||||||
|
setupFiles: ['tests/unit/setup-env.ts'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue