feat: Replace NEXT_PUBLIC_NODE_ENV with explicit feature flags for granular control over DOCX conversion, destructive actions, TTS defaults, and DeepInfra model visibility.

This commit is contained in:
Richard R 2026-02-17 18:26:03 -07:00
parent eeeceb8e54
commit 8f5e87ed4a
7 changed files with 110 additions and 79 deletions

View file

@ -1,7 +1,3 @@
# (Optional) Client feature flags (does not work in Docker containers due to being build-time only)
NEXT_PUBLIC_NODE_ENV=development
NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=
NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=
# Local / OpenAI TTS API Configuration (default) # Local / OpenAI TTS API Configuration (default)
# Suggest using https://github.com/remsky/Kokoro-FastAPI # Suggest using https://github.com/remsky/Kokoro-FastAPI
@ -74,3 +70,12 @@ WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
# (Optional) Override ffmpeg binary path used for audiobook processing # (Optional) Override ffmpeg binary path used for audiobook processing
FFMPEG_BIN= FFMPEG_BIN=
# (Optional) Client feature flags (does not work in Docker containers due to being build-time only)
# NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true
# NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true
# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai
# NEXT_PUBLIC_DEFAULT_TTS_MODEL=kokoro
# NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=true
# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false

View file

@ -18,6 +18,8 @@ This guide covers deploying OpenReader WebUI to Vercel with external Postgres an
Recommended production setup (auth enabled): Recommended production setup (auth enabled):
```bash ```bash
API_BASE=https://api.deepinfra.com/v1/openai
API_KEY=your_deepinfra_key
POSTGRES_URL=postgres://... POSTGRES_URL=postgres://...
USE_EMBEDDED_WEED_MINI=false USE_EMBEDDED_WEED_MINI=false
S3_ACCESS_KEY_ID=... S3_ACCESS_KEY_ID=...
@ -27,34 +29,29 @@ S3_REGION=us-east-1
S3_PREFIX=openreader S3_PREFIX=openreader
BASE_URL=https://your-app.vercel.app BASE_URL=https://your-app.vercel.app
AUTH_SECRET=... AUTH_SECRET=...
NEXT_PUBLIC_NODE_ENV=production # Optional client/runtime feature defaults:
# Optional client/runtime feature overrides: NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false
# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=false NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra
NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M
NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false
NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true
NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false
# Optional (non-AWS S3-compatible providers): # Optional (non-AWS S3-compatible providers):
# S3_ENDPOINT=https://... # S3_ENDPOINT=https://...
# S3_FORCE_PATH_STYLE=true # S3_FORCE_PATH_STYLE=true
``` ```
:::info `NEXT_PUBLIC_*` feature flags :::info Production Configuration & Feature Flags
- `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=false`: hides audiobook export UI entry points. We recommend setting these defaults for a production-like environment:
- `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true`: enables word-highlight UI and timestamp alignment requests.
:::
:::warning `NEXT_PUBLIC_NODE_ENV` behavior - `NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false`: Disables DOCX upload (requires external tools anyway)
Use `NEXT_PUBLIC_NODE_ENV=production` on Vercel unless you explicitly want dev-oriented client behavior. - `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false`: Hides destructive "Delete All" actions
- `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra`: Points default TTS to a scalable provider
With `production`: - `NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M`: Uses a high-quality default model
- Footer is shown in the app shell - `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false`: Restricts usage to free models if no key is provided
- DOCX upload/conversion option is hidden - `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true`: (Optional) Controls audiobook export UI
- Default provider/model behavior is production-oriented - `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false`: (Optional) Controls word highlighting UI (requires timestamp backend)
- DeepInfra model picker is restricted without an API key
- Privacy modal shows hosted-service/operator wording
- Dev-only destructive document actions are hidden
With unset/non-`production`, the inverse dev behavior applies.
Full details: [Environment Variables](../reference/environment-variables#next_public_node_env).
::: :::
:::warning Auth recommendation :::warning Auth recommendation

View file

@ -9,7 +9,11 @@ This is the single reference page for OpenReader WebUI environment variables.
| Variable | Area | Default | When to set | | Variable | Area | Default | When to set |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `NEXT_PUBLIC_NODE_ENV` | Runtime mode | treated as `development` unless `production` | Set `production` for production client behavior | | `NEXT_PUBLIC_ENABLE_DOCX_CONVERSION` | Client feature flags | `true` unless set to `false` | Set `false` to hide DOCX support |
| `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS` | Client feature flags | `true` unless set to `false` | Set `false` to hide destructive actions |
| `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER` | Client feature flags | `custom-openai` | Override default TTS provider |
| `NEXT_PUBLIC_DEFAULT_TTS_MODEL` | Client feature flags | `kokoro` | Override default TTS model |
| `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS` | Client feature flags | `true` unless set to `false` | Set `false` to restrict DeepInfra models |
| `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT` | Client feature flags | `true` unless set to `false` | Set `false` to hide audiobook export UI | | `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT` | Client feature flags | `true` unless set to `false` | Set `false` to hide audiobook export UI |
| `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT` | Client feature flags | `false` unless set to `true` | Set `true` to enable word highlight + alignment | | `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT` | Client feature flags | `false` unless set to `true` | Set `true` to enable word highlight + alignment |
| `API_BASE` | TTS provider | none | Point to your OpenAI-compatible TTS base URL | | `API_BASE` | TTS provider | none | Point to your OpenAI-compatible TTS base URL |
@ -50,41 +54,7 @@ This is the single reference page for OpenReader WebUI environment variables.
| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | | `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps |
| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
## Client Runtime and Feature Flags
### NEXT_PUBLIC_NODE_ENV
Controls development vs production behavior in client/server code paths.
- Typical values: `development`, `production`
- OpenReader `isDev` checks rely on this variable directly
- If this is not `production`, OpenReader treats the client as development mode
- In deployed environments, set `NEXT_PUBLIC_NODE_ENV=production` explicitly for predictable production behavior
- Affects:
- Footer visibility in the app shell
- DOCX upload/conversion availability in upload UI
- Default provider/model behavior for first-run TTS config
- DeepInfra model picker restrictions when no API key is set
- Dev-only destructive document actions in settings
### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT
Controls whether audiobook export UI/actions are shown in the client.
- Default behavior: enabled unless explicitly set to `false`
- Applies in both development and production
- Affects export entry points in PDF/EPUB pages and document settings UI
### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT
Controls word-by-word highlighting UI and timestamp-alignment behavior.
- Default behavior: disabled unless set to `true`
- Applies in both development and production
- Requires working timestamp generation (for example `WHISPER_CPP_BIN`)
- Affects:
- Word-highlight toggles in document settings
- Alignment requests during TTS playback
## TTS Provider and Request Behavior ## TTS Provider and Request Behavior
@ -367,3 +337,59 @@ Absolute path or executable name for the ffmpeg binary used by audiobook/process
- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static` - Resolution order: `FFMPEG_BIN` -> `ffmpeg-static`
- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg` - Example: `/var/task/node_modules/ffmpeg-static/ffmpeg`
## Client Runtime and Feature Flags
### NEXT_PUBLIC_ENABLE_DOCX_CONVERSION
Controls whether the experimental DOCX-to-PDF conversion and upload feature is enabled.
- Default: `true` (enabled)
- Set `false` to hide DOCX support in the upload UI
### NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS
Controls whether the "Delete all user docs" and other bulk-delete buttons are shown in Settings.
- Default: `true` (enabled)
- Set `false` to hide destructive actions (recommended for production)
### NEXT_PUBLIC_DEFAULT_TTS_PROVIDER
Sets the default TTS provider for new users.
- Default: `custom-openai`
- Example values: `deepinfra`, `openai`, `custom-openai`
### NEXT_PUBLIC_DEFAULT_TTS_MODEL
Sets the default TTS model for new users.
- Default: `kokoro`
- Example values: `hexgrad/Kokoro-82M`, `tts-1`
### NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS
Controls whether the DeepInfra model list shows all models or just the free tier when no API key is set.
- Default: `true` (show all)
- Set `false` to restrict to free tier models when no API key is provided
### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT
Controls whether audiobook export UI/actions are shown in the client.
- Default behavior: enabled unless explicitly set to `false`
- Applies in both development and production
- Affects export entry points in PDF/EPUB pages and document settings UI
### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT
Controls word-by-word highlighting UI and timestamp-alignment behavior.
- Default behavior: disabled unless set to `true`
- Applies in both development and production
- Requires working timestamp generation (for example `WHISPER_CPP_BIN`)
- Affects:
- Word-highlight toggles in document settings
- Alignment requests during TTS playback

View file

@ -6,7 +6,8 @@ import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import { uploadDocxAsPdf } from '@/lib/client-documents'; import { uploadDocxAsPdf } from '@/lib/client-documents';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const enableDocx = process.env.NEXT_PUBLIC_ENABLE_DOCX_CONVERSION !== 'false';
interface DocumentUploaderProps { interface DocumentUploaderProps {
className?: string; className?: string;
@ -38,7 +39,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
await addEPUB(file); await addEPUB(file);
} else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) { } else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) {
await addHTML(file); await addHTML(file);
} else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { } else if (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
// Preserve prior UX: show "Converting DOCX..." state rather than generic uploading. // Preserve prior UX: show "Converting DOCX..." state rather than generic uploading.
setIsUploading(false); setIsUploading(false);
setIsConverting(true); setIsConverting(true);
@ -65,7 +66,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
'application/epub+zip': ['.epub'], 'application/epub+zip': ['.epub'],
'text/plain': ['.txt'], 'text/plain': ['.txt'],
'text/markdown': ['.md'], 'text/markdown': ['.md'],
...(isDev ? { ...(enableDocx ? {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'] 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
} : {}) } : {})
}, },
@ -111,7 +112,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'} {isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
</p> </p>
<p className="text-xs sm:text-sm text-muted"> <p className="text-xs sm:text-sm text-muted">
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'} {enableDocx ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
</p> </p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>} {error && <p className="mt-2 text-sm text-red-500">{error}</p>}
</> </>

View file

@ -40,7 +40,9 @@ import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client-d
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/document-cache'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/document-cache';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/document-preview-cache'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/document-preview-cache';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false';
const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false';
const themes = THEMES.map(id => ({ const themes = THEMES.map(id => ({
id, id,
@ -108,7 +110,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
]; ];
case 'deepinfra': case 'deepinfra':
// In production without an API key, limit to free tier model // In production without an API key, limit to free tier model
if (!isDev && !localApiKey) { if (!showAllDeepInfra && !localApiKey) {
return [ return [
{ id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' } { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' }
]; ];
@ -561,7 +563,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
type="password" type="password"
value={localApiKey} value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)} onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"} placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
@ -782,7 +784,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
> >
Clear cache Clear cache
</Button> </Button>
{isDev && ( {enableDestructiveDelete && (
<div className="flex w-full gap-2"> <div className="flex w-full gap-2">
<Button <Button
onClick={() => { onClick={() => {

View file

@ -76,7 +76,7 @@ type OpenReaderDB = Dexie & {
export const db = new Dexie(DB_NAME) as OpenReaderDB; export const db = new Dexie(DB_NAME) as OpenReaderDB;
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
type DexieOpenStatus = 'opening' | 'opened' | 'blocked' | 'stalled' | 'error'; type DexieOpenStatus = 'opening' | 'opened' | 'blocked' | 'stalled' | 'error';
@ -113,8 +113,8 @@ function inferProviderAndBaseUrl(raw: RawConfigMap): { provider: string; baseUrl
const cachedBaseUrl = raw.baseUrl; const cachedBaseUrl = raw.baseUrl;
let inferredProvider = raw.ttsProvider || ''; let inferredProvider = raw.ttsProvider || '';
if (!isDev && !raw.ttsProvider) { if (!raw.ttsProvider) {
inferredProvider = 'deepinfra'; inferredProvider = process.env.NEXT_PUBLIC_DEFAULT_TTS_PROVIDER || 'custom-openai';
} else if (!inferredProvider) { } else if (!inferredProvider) {
if (cachedBaseUrl) { if (cachedBaseUrl) {
const baseUrlLower = cachedBaseUrl.toLowerCase(); const baseUrlLower = cachedBaseUrl.toLowerCase();
@ -194,8 +194,8 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
(provider === 'openai' (provider === 'openai'
? 'tts-1' ? 'tts-1'
: provider === 'deepinfra' : provider === 'deepinfra'
? 'hexgrad/Kokoro-82M' ? 'hexgrad/Kokoro-82M'
: APP_CONFIG_DEFAULTS.ttsModel), : APP_CONFIG_DEFAULTS.ttsModel),
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions, ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
savedVoices, savedVoices,
pdfHighlightEnabled: pdfHighlightEnabled:

View file

@ -1,6 +1,6 @@
import type { DocumentListState } from '@/types/documents'; import type { DocumentListState } from '@/types/documents';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
const wordHighlightEnabledByDefault = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true'; const wordHighlightEnabledByDefault = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true';
export type ViewType = 'single' | 'dual' | 'scroll'; export type ViewType = 'single' | 'dual' | 'scroll';
@ -48,8 +48,8 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
footerMargin: 0, footerMargin: 0,
leftMargin: 0, leftMargin: 0,
rightMargin: 0, rightMargin: 0,
ttsProvider: isDev ? 'custom-openai' : 'deepinfra', ttsProvider: process.env.NEXT_PUBLIC_DEFAULT_TTS_PROVIDER || 'custom-openai',
ttsModel: isDev ? 'kokoro' : 'hexgrad/Kokoro-82M', ttsModel: process.env.NEXT_PUBLIC_DEFAULT_TTS_MODEL || 'kokoro',
ttsInstructions: '', ttsInstructions: '',
savedVoices: {}, savedVoices: {},
smartSentenceSplitting: true, smartSentenceSplitting: true,