refactor(tts): transition rate limiting to runtime config and admin panel
- Remove legacy TTS rate limit environment variables and migrate all related configuration to runtime settings managed via the Admin UI - Add runtime config keys for TTS rate limiting enable/disable and per-user/IP daily quotas - Refactor rate limiter and API routes to use runtime config for thresholds and enablement - Update AdminFeaturesPanel to allow editing TTS rate limiting and quota values in the UI - Add shared-provider-selection utility for consistent provider selection logic - Update documentation to reflect new runtime/admin configuration and remove obsolete env var docs - Add unit tests for rate limit runtime settings and provider selection BREAKING CHANGE: TTS rate limiting is now controlled via Admin → Site features; environment variables for TTS rate limiting are no longer supported and will be ignored.
This commit is contained in:
parent
e262f93f90
commit
a0f2d008e0
20 changed files with 744 additions and 227 deletions
62
.env.example
62
.env.example
|
|
@ -15,7 +15,7 @@
|
|||
API_BASE=http://localhost:8880/v1
|
||||
API_KEY=api_key_optional
|
||||
|
||||
# (Optional) TTS request/cache tuning (leave unset to use defaults)
|
||||
# (Optional) TTS request/cache tuning (deaults shown below; leave unset to use defaults)
|
||||
# TTS_CACHE_MAX_SIZE_BYTES=268435456 # 256MB
|
||||
# TTS_CACHE_TTL_MS=1800000 # 30 minutes
|
||||
# TTS_MAX_RETRIES=2
|
||||
|
|
@ -24,27 +24,18 @@ API_KEY=api_key_optional
|
|||
# TTS_RETRY_BACKOFF=2
|
||||
# TTS_UPSTREAM_TIMEOUT_MS=285000 # 285 seconds
|
||||
|
||||
# (Optional) Enable TTS character rate limiting (default is `false`)
|
||||
# TTS_ENABLE_RATE_LIMIT=true
|
||||
# (Optional) TTS per-user daily limits (leave unset to use defaults)
|
||||
# TTS_DAILY_LIMIT_ANONYMOUS=50000
|
||||
# TTS_DAILY_LIMIT_AUTHENTICATED=500000
|
||||
# (Optional) TTS IP backstop daily limits (leave unset to use defaults)
|
||||
# TTS_IP_DAILY_LIMIT_ANONYMOUS=100000
|
||||
# TTS_IP_DAILY_LIMIT_AUTHENTICATED=1000000
|
||||
|
||||
# Auth configuration (recommended for contributors and public instances)
|
||||
# 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)
|
||||
AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32`
|
||||
AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted)
|
||||
# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`)
|
||||
USE_ANONYMOUS_AUTH_SESSIONS=
|
||||
# USE_ANONYMOUS_AUTH_SESSIONS=false
|
||||
# (Optional) Sign in w/ GitHub Configuration
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
# (Optional) Disable Better Auth built-in rate limiting (useful for testing)
|
||||
DISABLE_AUTH_RATE_LIMIT=
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
# (Optional) Disable Better Auth built-in rate limiting (useful for testing; default: `false`)
|
||||
# DISABLE_AUTH_RATE_LIMIT=false
|
||||
|
||||
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
|
||||
# Admins see the "Admin" tab in Settings (TTS shared providers + site features).
|
||||
|
|
@ -54,34 +45,28 @@ ADMIN_EMAILS=
|
|||
|
||||
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables.
|
||||
# Defaults to SQLite at docstore/sqlite3.db when not set.
|
||||
POSTGRES_URL=
|
||||
# POSTGRES_URL=
|
||||
|
||||
# Embedded SeaweedFS weed mini config
|
||||
# (Optional) Enable embedded weed mini for local S3-compatible storage (default: `true`)
|
||||
USE_EMBEDDED_WEED_MINI=
|
||||
WEED_MINI_DIR=
|
||||
WEED_MINI_WAIT_SEC=
|
||||
# (Optional) Enable embedded weed mini for local S3-compatible storage (defaults shown below)
|
||||
# USE_EMBEDDED_WEED_MINI=true
|
||||
# WEED_MINI_DIR=docstore/seaweedfs
|
||||
# WEED_MINI_WAIT_SEC=20
|
||||
|
||||
# S3 storage config (use with embedded weed mini or external S3-compatible storage)
|
||||
# (Optional) For embedded weed mini, set explicit keys if you want stable credentials across restarts.
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
S3_REGION=
|
||||
# S3_REGION=
|
||||
# (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host.
|
||||
S3_ENDPOINT=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
S3_PREFIX=
|
||||
# S3_ENDPOINT=
|
||||
# S3_FORCE_PATH_STYLE=
|
||||
# S3_PREFIX=
|
||||
|
||||
# Migrations configuration
|
||||
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
|
||||
RUN_DRIZZLE_MIGRATIONS=
|
||||
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
|
||||
RUN_FS_MIGRATIONS=
|
||||
|
||||
# (Optional) Server library import roots (uses /docstore/library by default)
|
||||
IMPORT_LIBRARY_DIR=
|
||||
IMPORT_LIBRARY_DIRS=
|
||||
# (Optional) Library import mode directory (uses /docstore/library by default)
|
||||
# IMPORT_LIBRARY_DIR=
|
||||
# IMPORT_LIBRARY_DIRS=
|
||||
|
||||
# Compute
|
||||
# Embedded/local (default): leave COMPUTE_WORKER_URL empty.
|
||||
|
|
@ -118,7 +103,7 @@ IMPORT_LIBRARY_DIRS=
|
|||
# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main
|
||||
|
||||
# (Optional) Override ffmpeg binary path used for audiobook processing
|
||||
FFMPEG_BIN=
|
||||
# FFMPEG_BIN=
|
||||
|
||||
# (Optional) Client feature flags — seeded into the admin-managed runtime
|
||||
# config on first boot, then ignored. Edit values from Settings → Admin →
|
||||
|
|
@ -132,3 +117,10 @@ FFMPEG_BIN=
|
|||
# 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
|
||||
|
||||
# Migrations configuration
|
||||
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
|
||||
# RUN_DRIZZLE_MIGRATIONS=true
|
||||
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
|
||||
# RUN_FS_MIGRATIONS=true
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ This page explains OpenReader's TTS character rate limiting controls.
|
|||
## Overview
|
||||
|
||||
- TTS rate limiting is disabled by default.
|
||||
- To enable it, set `TTS_ENABLE_RATE_LIMIT=true`.
|
||||
- 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.
|
||||
- Enforcement applies only when auth is enabled.
|
||||
|
||||
|
|
@ -28,21 +29,10 @@ 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` does not disable TTS character limits.
|
||||
|
||||
## Environment variables
|
||||
## Runtime config + seed var
|
||||
|
||||
Enable/disable:
|
||||
|
||||
- `TTS_ENABLE_RATE_LIMIT` (default: `false`)
|
||||
|
||||
Per-user daily limits:
|
||||
|
||||
- `TTS_DAILY_LIMIT_ANONYMOUS` (default: `50000`)
|
||||
- `TTS_DAILY_LIMIT_AUTHENTICATED` (default: `500000`)
|
||||
|
||||
IP backstop daily limits:
|
||||
|
||||
- `TTS_IP_DAILY_LIMIT_ANONYMOUS` (default: `100000`)
|
||||
- `TTS_IP_DAILY_LIMIT_AUTHENTICATED` (default: `1000000`)
|
||||
- First-boot seed toggle: `RUNTIME_SEED_DISABLE_TTS_LIMIT` (default: `true`)
|
||||
- Per-user and IP backstop limit values are configured in **Settings → Admin → Site features** and stored in DB runtime settings.
|
||||
|
||||
## Related docs
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,6 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
|
|||
| `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 |
|
||||
| `TTS_ENABLE_RATE_LIMIT` | Rate limiting | `false` | Set `true` to enable TTS per-user/IP daily character limits |
|
||||
| `TTS_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `50000` | Override anonymous per-user daily character limit |
|
||||
| `TTS_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `500000` | Override authenticated per-user daily character limit |
|
||||
| `TTS_IP_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `100000` | Override anonymous IP backstop daily limit |
|
||||
| `TTS_IP_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `1000000` | Override authenticated IP backstop daily limit |
|
||||
| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth |
|
||||
| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth |
|
||||
| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins |
|
||||
|
|
@ -76,6 +71,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
|
|||
| `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 |
|
||||
|
||||
|
||||
|
||||
|
|
@ -158,37 +154,22 @@ Maximum upstream TTS request timeout in milliseconds.
|
|||
- 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_ENABLE_RATE_LIMIT
|
||||
### TTS Daily Rate Limiting (Runtime Settings)
|
||||
|
||||
Controls TTS character rate limiting in the TTS API.
|
||||
TTS character rate limiting is now managed from **Settings → Admin → Site features**.
|
||||
|
||||
- Default: `false` (TTS char limits disabled)
|
||||
- Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*`
|
||||
- For behavior details and examples, see [TTS Rate Limiting](../configure/tts-rate-limiting)
|
||||
- `disableTtsRateLimit` default: `true` (rate limiting disabled)
|
||||
- Daily limit defaults:
|
||||
- Anonymous per-user: `50000`
|
||||
- Authenticated per-user: `500000`
|
||||
- Anonymous IP backstop: `100000`
|
||||
- Authenticated IP backstop: `1000000`
|
||||
|
||||
### TTS_DAILY_LIMIT_ANONYMOUS
|
||||
Optional first-boot seeds:
|
||||
|
||||
Anonymous per-user daily character limit.
|
||||
- `RUNTIME_SEED_DISABLE_TTS_LIMIT`
|
||||
|
||||
- Default: `50000`
|
||||
|
||||
### TTS_DAILY_LIMIT_AUTHENTICATED
|
||||
|
||||
Authenticated per-user daily character limit.
|
||||
|
||||
- Default: `500000`
|
||||
|
||||
### TTS_IP_DAILY_LIMIT_ANONYMOUS
|
||||
|
||||
Anonymous IP backstop daily character limit.
|
||||
|
||||
- Default: `100000`
|
||||
|
||||
### TTS_IP_DAILY_LIMIT_AUTHENTICATED
|
||||
|
||||
Authenticated IP backstop daily character limit.
|
||||
|
||||
- Default: `1000000`
|
||||
After first boot, these values are DB-backed admin runtime settings.
|
||||
|
||||
## Auth and Identity
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { and, eq, inArray } from 'drizzle-orm';
|
|||
import { db } from '@/db';
|
||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
|
|
@ -532,7 +532,15 @@ export async function POST(request: NextRequest) {
|
|||
sharedDefaultInstructions: credResolved.adminRecord?.defaultInstructions,
|
||||
});
|
||||
|
||||
if (authEnabled && userId && isTtsRateLimitEnabled()) {
|
||||
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
|
||||
const limits = resolveRateLimitThresholds({
|
||||
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
|
||||
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
|
||||
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
|
||||
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
|
||||
});
|
||||
|
||||
if (authEnabled && userId && ttsRateLimitEnabled) {
|
||||
const isAnonymous = Boolean(user?.isAnonymous);
|
||||
const charCount = data.text.length;
|
||||
const ip = getClientIp(request);
|
||||
|
|
@ -549,6 +557,10 @@ export async function POST(request: NextRequest) {
|
|||
deviceId: device?.deviceId ?? null,
|
||||
ip,
|
||||
},
|
||||
{
|
||||
enabled: ttsRateLimitEnabled,
|
||||
limits,
|
||||
},
|
||||
);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
|
|
@ -570,7 +582,7 @@ export async function POST(request: NextRequest) {
|
|||
resetTimeMs,
|
||||
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
||||
upgradeHint: isAnonymous
|
||||
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
|
||||
? `Sign up to increase your limit from ${formatLimitForHint(limits.anonymous)} to ${formatLimitForHint(limits.authenticated)} characters per day`
|
||||
: undefined,
|
||||
instance: request.nextUrl.pathname,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/server/auth/auth';
|
||||
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { headers } from 'next/headers';
|
||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
|
||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||
|
||||
|
|
@ -17,7 +18,14 @@ function getUtcResetTimeMs(): number {
|
|||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const ttsRateLimitEnabled = isTtsRateLimitEnabled();
|
||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||
const limits = resolveRateLimitThresholds({
|
||||
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
|
||||
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
|
||||
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
|
||||
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
|
||||
});
|
||||
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
|
||||
|
||||
// If auth is not enabled, return unlimited status
|
||||
if (!isAuthEnabled() || !auth) {
|
||||
|
|
@ -46,8 +54,8 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({
|
||||
allowed: true,
|
||||
currentCount: 0,
|
||||
limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
|
||||
remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
|
||||
limit: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
|
||||
remainingChars: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
|
||||
resetTimeMs,
|
||||
userType: 'unauthenticated',
|
||||
authEnabled: true
|
||||
|
|
@ -57,7 +65,7 @@ export async function GET(req: NextRequest) {
|
|||
const isAnonymous = Boolean((session.user as { isAnonymous?: boolean }).isAnonymous);
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const device = isTtsRateLimitEnabled() ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
|
||||
const device = ttsRateLimitEnabled ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
|
||||
|
||||
const result = await rateLimiter.getCurrentUsage(
|
||||
{
|
||||
|
|
@ -67,7 +75,11 @@ export async function GET(req: NextRequest) {
|
|||
{
|
||||
deviceId: device?.deviceId ?? null,
|
||||
ip,
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: ttsRateLimitEnabled,
|
||||
limits,
|
||||
},
|
||||
);
|
||||
|
||||
const response = NextResponse.json({
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
} from '@/lib/server/tts/segments';
|
||||
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||
|
|
@ -169,6 +169,13 @@ export async function POST(request: NextRequest) {
|
|||
const scope = await resolveSegmentDocumentScope(request, parsed.documentId);
|
||||
if (scope instanceof Response) return scope;
|
||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
|
||||
const limits = resolveRateLimitThresholds({
|
||||
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
|
||||
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
|
||||
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
|
||||
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
|
||||
});
|
||||
const requestCreds = await resolveTtsCredentials({
|
||||
providerHeader: parsed.settings.providerRef,
|
||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
||||
|
|
@ -461,7 +468,7 @@ export async function POST(request: NextRequest) {
|
|||
segmentId: segment.segmentId,
|
||||
});
|
||||
|
||||
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
|
||||
if (scope.authEnabled && scope.userId && ttsRateLimitEnabled) {
|
||||
const charCount = segment.text.length;
|
||||
const ip = getClientIp(request);
|
||||
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
|
||||
|
|
@ -477,6 +484,10 @@ export async function POST(request: NextRequest) {
|
|||
deviceId: device?.deviceId ?? null,
|
||||
ip,
|
||||
},
|
||||
{
|
||||
enabled: ttsRateLimitEnabled,
|
||||
limits,
|
||||
},
|
||||
);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
|
|
@ -484,6 +495,8 @@ export async function POST(request: NextRequest) {
|
|||
rateLimitResult,
|
||||
isAnonymousUser: scope.isAnonymousUser,
|
||||
pathname: request.nextUrl.pathname,
|
||||
anonymousLimit: limits.anonymous,
|
||||
authenticatedLimit: limits.authenticated,
|
||||
});
|
||||
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
|
|||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
import {
|
||||
Badge,
|
||||
EditableRow,
|
||||
Section,
|
||||
ToggleRow,
|
||||
buttonClass,
|
||||
|
|
@ -56,12 +57,14 @@ export function AdminFeaturesPanel() {
|
|||
});
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||
const [dirty, setDirty] = useState<Set<string>>(new Set());
|
||||
const [editingChangelogUrl, setEditingChangelogUrl] = useState(false);
|
||||
const { providers: sharedProviders } = useSharedProviders();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setDraft({ ...data.values });
|
||||
setDirty(new Set());
|
||||
setEditingChangelogUrl(false);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -104,11 +107,19 @@ export function AdminFeaturesPanel() {
|
|||
setDraft((d) => ({ ...d, [key]: value }));
|
||||
setDirty((s) => {
|
||||
const next = new Set(s);
|
||||
next.add(key);
|
||||
const baselineValue = data?.values?.[key];
|
||||
if (Object.is(value, baselineValue)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updatePositiveIntDraft = (key: string, raw: string) => {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
updateDraft(key, Math.max(1, Math.floor(parsed)));
|
||||
};
|
||||
|
||||
const resetField = (key: string) => {
|
||||
if (saving) return;
|
||||
resetMutation.mutate(key);
|
||||
|
|
@ -151,6 +162,7 @@ export function AdminFeaturesPanel() {
|
|||
} as ProviderOption
|
||||
: fallbackShared;
|
||||
const selectedProviderOption = effectiveSelectedProvider;
|
||||
const showTtsDailyLimitInputs = !Boolean(draft.disableTtsRateLimit);
|
||||
|
||||
const handleProviderChange = (opt: ProviderOption) => {
|
||||
updateDraft('defaultTtsProvider', opt.id);
|
||||
|
|
@ -270,6 +282,85 @@ export function AdminFeaturesPanel() {
|
|||
right={renderSource('showAllProviderModels')}
|
||||
variant="flat"
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Disable TTS daily rate limiting"
|
||||
description="When on, per-user/IP daily character quotas are not enforced."
|
||||
checked={Boolean(draft.disableTtsRateLimit)}
|
||||
onChange={(checked) => updateDraft('disableTtsRateLimit', checked)}
|
||||
right={renderSource('disableTtsRateLimit')}
|
||||
variant="flat"
|
||||
/>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={showTtsDailyLimitInputs}
|
||||
enter="transition-all duration-200 ease-out"
|
||||
enterFrom="opacity-0 -translate-y-1 max-h-0"
|
||||
enterTo="opacity-100 translate-y-0 max-h-[420px]"
|
||||
leave="transition-all duration-150 ease-in"
|
||||
leaveFrom="opacity-100 translate-y-0 max-h-[420px]"
|
||||
leaveTo="opacity-0 -translate-y-1 max-h-0"
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<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">Anonymous per-user daily limit</label>
|
||||
{renderSource('ttsDailyLimitAnonymous')}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsDailyLimitAnonymous ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', 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">Authenticated per-user daily limit</label>
|
||||
{renderSource('ttsDailyLimitAuthenticated')}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', 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">Anonymous IP daily backstop</label>
|
||||
{renderSource('ttsIpDailyLimitAnonymous')}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', 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">Authenticated IP daily backstop</label>
|
||||
{renderSource('ttsIpDailyLimitAuthenticated')}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
className={inputClass}
|
||||
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
|
||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
|
|
@ -277,16 +368,15 @@ export function AdminFeaturesPanel() {
|
|||
subtitle="Feature flags for all users."
|
||||
action={<Badge tone="foreground">Feature Flags</Badge>}
|
||||
>
|
||||
<div className="space-y-1.5 pb-2 border-b border-offbase">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Changelog feed URL</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Public URL to the changelog manifest JSON used by Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
|
||||
</div>
|
||||
<EditableRow
|
||||
label="Changelog feed URL"
|
||||
description="Public URL to the changelog manifest JSON used by Settings."
|
||||
value={String(draft.changelogFeedUrl ?? '') || '—'}
|
||||
expanded={editingChangelogUrl}
|
||||
onExpandedChange={setEditingChangelogUrl}
|
||||
right={renderSource('changelogFeedUrl')}
|
||||
editLabel={editingChangelogUrl ? 'Close editor' : 'Edit URL'}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className={inputClass}
|
||||
|
|
@ -294,7 +384,7 @@ export function AdminFeaturesPanel() {
|
|||
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
|
||||
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
|
||||
/>
|
||||
</div>
|
||||
</EditableRow>
|
||||
<ToggleRow
|
||||
label="Allow new account sign-ups"
|
||||
description="When off, new accounts cannot be created. Existing accounts can still sign in."
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
||||
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons';
|
||||
import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
||||
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import {
|
||||
|
|
@ -56,6 +56,35 @@ interface FormState {
|
|||
|
||||
const providerDefaultModel = defaultModelForProviderType;
|
||||
const ADMIN_PROVIDERS_QUERY_KEY = ['admin-providers'] as const;
|
||||
const ADMIN_SETTINGS_QUERY_KEY = ['admin-settings'] as const;
|
||||
|
||||
async function fetchDefaultProviderSlug(): Promise<string> {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { values?: { defaultTtsProvider?: unknown } };
|
||||
return typeof data.values?.defaultTtsProvider === 'string' ? data.values.defaultTtsProvider : '';
|
||||
}
|
||||
|
||||
async function patchDefaultProviderSlug(slug: string): Promise<void> {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates: { defaultTtsProvider: slug } }),
|
||||
});
|
||||
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
async function patchProviderEnabled(input: { id: string; enabled: boolean }): Promise<void> {
|
||||
const res = await fetch(`/api/admin/providers/${input.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: input.enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyForm(): FormState {
|
||||
return {
|
||||
|
|
@ -70,6 +99,11 @@ function createEmptyForm(): FormState {
|
|||
};
|
||||
}
|
||||
|
||||
function truncateModelLabel(value: string, maxLength = 56): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
async function fetchAdminProviders(): Promise<AdminProviderMasked[]> {
|
||||
const res = await fetch('/api/admin/providers');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
|
@ -122,19 +156,34 @@ export function AdminProvidersPanel() {
|
|||
queryKey: ADMIN_PROVIDERS_QUERY_KEY,
|
||||
queryFn: fetchAdminProviders,
|
||||
});
|
||||
const {
|
||||
data: defaultProviderSlug = '',
|
||||
error: defaultProviderError,
|
||||
} = useQuery({
|
||||
queryKey: ADMIN_SETTINGS_QUERY_KEY,
|
||||
queryFn: fetchDefaultProviderSlug,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
console.error('[AdminProvidersPanel] load failed:', error);
|
||||
toast.error('Failed to load admin providers');
|
||||
}, [error]);
|
||||
useEffect(() => {
|
||||
if (!defaultProviderError) return;
|
||||
console.error('[AdminProvidersPanel] default provider load failed:', defaultProviderError);
|
||||
toast.error('Failed to load default provider');
|
||||
}, [defaultProviderError]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: upsertAdminProvider,
|
||||
onSuccess: async (_data, variables) => {
|
||||
toast.success(variables.editingId === '__new' ? 'Provider created' : 'Provider updated');
|
||||
cancelEdit();
|
||||
await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
console.error('[AdminProvidersPanel] save failed:', mutationError);
|
||||
|
|
@ -146,13 +195,41 @@ export function AdminProvidersPanel() {
|
|||
mutationFn: deleteAdminProvider,
|
||||
onSuccess: async () => {
|
||||
toast.success('Provider deleted');
|
||||
await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
console.error('[AdminProvidersPanel] delete failed:', mutationError);
|
||||
toast.error((mutationError as Error).message || 'Delete failed');
|
||||
},
|
||||
});
|
||||
const toggleEnabledMutation = useMutation({
|
||||
mutationFn: patchProviderEnabled,
|
||||
onSuccess: async (_data, vars) => {
|
||||
toast.success(vars.enabled ? 'Provider enabled' : 'Provider disabled');
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
console.error('[AdminProvidersPanel] toggle enabled failed:', mutationError);
|
||||
toast.error((mutationError as Error).message || 'Update failed');
|
||||
},
|
||||
});
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: patchDefaultProviderSlug,
|
||||
onSuccess: async () => {
|
||||
toast.success('Default provider updated');
|
||||
await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
console.error('[AdminProvidersPanel] set default failed:', mutationError);
|
||||
toast.error((mutationError as Error).message || 'Failed to set default');
|
||||
},
|
||||
});
|
||||
|
||||
const startCreate = () => {
|
||||
setForm(createEmptyForm());
|
||||
|
|
@ -191,6 +268,14 @@ export function AdminProvidersPanel() {
|
|||
if (deleteMutation.isPending) return;
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
const toggleEnabled = (provider: AdminProviderMasked) => {
|
||||
if (toggleEnabledMutation.isPending) return;
|
||||
toggleEnabledMutation.mutate({ id: provider.id, enabled: !provider.enabled });
|
||||
};
|
||||
const setDefault = (slug: string) => {
|
||||
if (setDefaultMutation.isPending) return;
|
||||
setDefaultMutation.mutate(slug);
|
||||
};
|
||||
|
||||
const isEditingExisting = editingId !== null && editingId !== '__new';
|
||||
const editingProvider = isEditingExisting
|
||||
|
|
@ -504,38 +589,101 @@ export function AdminProvidersPanel() {
|
|||
<p className="text-xs text-muted py-2">No shared providers configured yet.</p>
|
||||
) : (
|
||||
providers.map((p) => (
|
||||
<div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0 px-0.5 rounded-md">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground truncate">{p.displayName}</span>
|
||||
<Badge tone="muted">{p.slug}</Badge>
|
||||
{defaultProviderSlug === p.slug && <Badge tone="foreground">Default</Badge>}
|
||||
{!p.enabled && <Badge tone="muted">Disabled</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-muted truncate">
|
||||
<div className="text-xs text-muted">
|
||||
{p.providerType}
|
||||
{p.defaultModel ? ` · ${p.defaultModel}` : ''}
|
||||
{p.defaultModel ? (
|
||||
<>
|
||||
{' · '}
|
||||
<span title={p.defaultModel}>{truncateModelLabel(p.defaultModel)}</span>
|
||||
</>
|
||||
) : (
|
||||
' · no default model'
|
||||
)}
|
||||
{p.defaultInstructions ? ' · instructions' : ''}
|
||||
{p.baseUrl ? ` · ${p.baseUrl}` : ''}
|
||||
{' · '}key {p.apiKeyMask}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex gap-1.5">
|
||||
<Button
|
||||
onClick={() => startEdit(p)}
|
||||
className={buttonClass({ variant: 'outline', size: 'xs', className: 'h-7' })}
|
||||
disabled={!!editingId || deleteMutation.isPending}
|
||||
<Menu as="div" className="relative shrink-0">
|
||||
<MenuButton
|
||||
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-offbase bg-base text-muted hover:text-accent hover:bg-offbase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
title="Provider actions"
|
||||
aria-label="Provider actions"
|
||||
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => remove(p.id)}
|
||||
className={buttonClass({ variant: 'danger', size: 'xs', className: 'h-7' })}
|
||||
disabled={!!editingId || deleteMutation.isPending}
|
||||
<DotsHorizontalIcon className="h-3 w-4" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<MenuItems
|
||||
anchor="bottom end"
|
||||
className="z-50 mt-2 min-w-[170px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(p)}
|
||||
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}>
|
||||
{({ active, disabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefault(p.slug)}
|
||||
disabled={disabled}
|
||||
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnabled(p)}
|
||||
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
{p.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(p.id)}
|
||||
className={`${active ? 'bg-offbase' : ''} text-red-500 flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import { Fragment, useId, type ReactNode } from 'react';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { EditIcon } from '@/components/icons/Icons';
|
||||
|
||||
/**
|
||||
* Shared compact form primitives used by settings-like surfaces across
|
||||
|
|
@ -239,6 +241,65 @@ export function ToggleRow({
|
|||
);
|
||||
}
|
||||
|
||||
export function EditableRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
right,
|
||||
children,
|
||||
editLabel = 'Edit',
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
value: ReactNode;
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
editLabel?: string;
|
||||
}) {
|
||||
const rowClass =
|
||||
'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]';
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<span className="block text-sm font-medium leading-5 text-foreground">{label}</span>
|
||||
<span className="block text-xs leading-4 text-muted">{description}</span>
|
||||
<span className="block text-xs leading-5 text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-offbase bg-base text-muted transition-colors hover:bg-offbase hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
title={editLabel}
|
||||
aria-label={editLabel}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<EditIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={expanded}
|
||||
enter="transition-all duration-200 ease-out"
|
||||
enterFrom="opacity-0 -translate-y-1 max-h-0"
|
||||
enterTo="opacity-100 translate-y-0 max-h-48"
|
||||
leave="transition-all duration-150 ease-in"
|
||||
leaveFrom="opacity-100 translate-y-0 max-h-48"
|
||||
leaveTo="opacity-0 -translate-y-1 max-h-0"
|
||||
>
|
||||
<div className="overflow-hidden pt-2">
|
||||
{children}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckItem({
|
||||
label,
|
||||
checked,
|
||||
|
|
|
|||
|
|
@ -193,6 +193,27 @@ export function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
);
|
||||
}
|
||||
|
||||
export function EditIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={props.className}
|
||||
width={props.width || '1.5em'}
|
||||
height={props.height || '1.5em'}
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export interface RuntimeConfig {
|
|||
enableDocxConversion: boolean;
|
||||
enableDestructiveDeleteActions: boolean;
|
||||
showAllProviderModels: boolean;
|
||||
disableTtsRateLimit: boolean;
|
||||
ttsDailyLimitAnonymous: number;
|
||||
ttsDailyLimitAuthenticated: number;
|
||||
ttsIpDailyLimitAnonymous: number;
|
||||
ttsIpDailyLimitAuthenticated: number;
|
||||
computeAvailable: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +41,11 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
|
|||
enableDocxConversion: true,
|
||||
enableDestructiveDeleteActions: true,
|
||||
showAllProviderModels: true,
|
||||
disableTtsRateLimit: true,
|
||||
ttsDailyLimitAnonymous: 50_000,
|
||||
ttsDailyLimitAuthenticated: 500_000,
|
||||
ttsIpDailyLimitAnonymous: 100_000,
|
||||
ttsIpDailyLimitAuthenticated: 1_000_000,
|
||||
computeAvailable: true,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { adminProviders } from '@/db/schema';
|
|||
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
|
||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
|
||||
import { getRuntimeConfig, setRuntimeConfigKey } from '@/lib/server/admin/settings';
|
||||
|
||||
export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
|
||||
'custom-openai',
|
||||
|
|
@ -195,6 +197,19 @@ export async function listAdminProviders(): Promise<AdminProviderRecord[]> {
|
|||
return (rows as Array<Record<string, unknown>>).map(rowToRecord);
|
||||
}
|
||||
|
||||
export async function listEnabledAdminProviders(): Promise<AdminProviderRecord[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(adminProviders)
|
||||
.where(eq(adminProviders.enabled, 1))
|
||||
.orderBy(
|
||||
desc(adminProviders.updatedAt),
|
||||
desc(adminProviders.createdAt),
|
||||
asc(adminProviders.slug),
|
||||
);
|
||||
return (rows as Array<Record<string, unknown>>).map(rowToRecord);
|
||||
}
|
||||
|
||||
export async function getAdminProviderBySlug(slug: string): Promise<AdminProviderRecord | null> {
|
||||
const rows = await db.select().from(adminProviders).where(eq(adminProviders.slug, slug)).limit(1);
|
||||
const arr = rows as Array<Record<string, unknown>>;
|
||||
|
|
@ -258,6 +273,8 @@ export async function updateAdminProvider(
|
|||
): Promise<AdminProviderRecord> {
|
||||
const current = await getAdminProviderById(id);
|
||||
if (!current) throw new AdminProviderError('provider not found', 404);
|
||||
const runtimeConfigBefore = await getRuntimeConfig();
|
||||
const wasDefaultProvider = runtimeConfigBefore.defaultTtsProvider === current.slug;
|
||||
|
||||
const update: Record<string, unknown> = { updatedAt: Date.now() };
|
||||
const nextModel =
|
||||
|
|
@ -309,13 +326,27 @@ export async function updateAdminProvider(
|
|||
await db.update(adminProviders).set(update).where(eq(adminProviders.id, id));
|
||||
const updated = await getAdminProviderById(id);
|
||||
if (!updated) throw new AdminProviderError('failed to load updated provider', 500);
|
||||
if (wasDefaultProvider) {
|
||||
if (updated.enabled && updated.slug !== current.slug) {
|
||||
await setRuntimeConfigKey('defaultTtsProvider', updated.slug);
|
||||
} else if (!updated.enabled) {
|
||||
await swapDefaultSharedProvider(updated.slug);
|
||||
}
|
||||
}
|
||||
await ensureDefaultSharedProviderValidity();
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteAdminProvider(id: string): Promise<void> {
|
||||
const existing = await getAdminProviderById(id);
|
||||
if (!existing) throw new AdminProviderError('provider not found', 404);
|
||||
const runtimeConfigBefore = await getRuntimeConfig();
|
||||
const wasDefaultProvider = runtimeConfigBefore.defaultTtsProvider === existing.slug;
|
||||
await db.delete(adminProviders).where(eq(adminProviders.id, id));
|
||||
if (wasDefaultProvider) {
|
||||
await swapDefaultSharedProvider(existing.slug);
|
||||
}
|
||||
await ensureDefaultSharedProviderValidity();
|
||||
}
|
||||
|
||||
/** Lookup helper used by TTS routes: returns null if not found or disabled. */
|
||||
|
|
@ -333,11 +364,49 @@ export async function getEnabledAdminProviderBySlug(
|
|||
}
|
||||
|
||||
export async function getFirstEnabledAdminProvider(): Promise<AdminProviderRecord | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(adminProviders)
|
||||
.where(eq(adminProviders.enabled, 1))
|
||||
.limit(1);
|
||||
const arr = rows as Array<Record<string, unknown>>;
|
||||
return arr[0] ? rowToRecord(arr[0]) : null;
|
||||
const rows = await listEnabledAdminProviders();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function resolvePreferredEnabledAdminProvider(input: {
|
||||
requestedSlug?: string | null;
|
||||
runtimeDefaultSlug?: string | null;
|
||||
}): Promise<AdminProviderRecord | null> {
|
||||
const providers = await listEnabledAdminProviders();
|
||||
const selectedSlug = resolvePreferredSharedProviderSlug({
|
||||
providers,
|
||||
requestedSlug: input.requestedSlug,
|
||||
runtimeDefaultSlug: input.runtimeDefaultSlug,
|
||||
});
|
||||
if (!selectedSlug) return null;
|
||||
return providers.find((provider) => provider.slug === selectedSlug) ?? null;
|
||||
}
|
||||
|
||||
async function ensureDefaultSharedProviderValidity(): Promise<void> {
|
||||
const runtimeConfig = await getRuntimeConfig();
|
||||
const currentDefaultSlug = runtimeConfig.defaultTtsProvider;
|
||||
if (!currentDefaultSlug || BUILT_IN_PROVIDER_IDS.includes(currentDefaultSlug as TtsProviderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDefaultEnabled = await getEnabledAdminProviderBySlug(currentDefaultSlug);
|
||||
if (currentDefaultEnabled) return;
|
||||
|
||||
const nextProvider = await resolvePreferredEnabledAdminProvider({
|
||||
runtimeDefaultSlug: currentDefaultSlug,
|
||||
});
|
||||
if (!nextProvider) return;
|
||||
|
||||
await setRuntimeConfigKey('defaultTtsProvider', nextProvider.slug);
|
||||
}
|
||||
|
||||
async function swapDefaultSharedProvider(excludedSlug: string): Promise<void> {
|
||||
const providers = await listEnabledAdminProviders();
|
||||
const filtered = providers.filter((provider) => provider.slug !== excludedSlug);
|
||||
const selectedSlug = resolvePreferredSharedProviderSlug({
|
||||
providers: filtered,
|
||||
runtimeDefaultSlug: null,
|
||||
});
|
||||
if (!selectedSlug) return;
|
||||
await setRuntimeConfigKey('defaultTtsProvider', selectedSlug);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
getEnabledAdminProviderBySlug,
|
||||
getFirstEnabledAdminProvider,
|
||||
decryptedKeyFor,
|
||||
resolvePreferredEnabledAdminProvider,
|
||||
type AdminProviderRecord,
|
||||
} from '@/lib/server/admin/providers';
|
||||
|
||||
|
|
@ -46,37 +46,20 @@ export async function resolveTtsCredentials(opts: {
|
|||
if (opts.restrictUserApiKeys) {
|
||||
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
|
||||
const fallback = opts.fallbackProvider || '';
|
||||
const fallbackIsBuiltIn = isBuiltInProviderId(fallback);
|
||||
const requestedSharedSlug = requestedIsBuiltIn ? '' : requestedProvider;
|
||||
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallback;
|
||||
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug;
|
||||
|
||||
if (preferredSharedSlug) {
|
||||
const admin = await getEnabledAdminProviderBySlug(preferredSharedSlug);
|
||||
if (!admin) {
|
||||
return { error: 'provider_unknown', slug: preferredSharedSlug };
|
||||
}
|
||||
const apiKey = await decryptedKeyFor(admin);
|
||||
return {
|
||||
provider: admin.providerType,
|
||||
apiKey,
|
||||
baseUrl: admin.baseUrl || undefined,
|
||||
fromAdmin: true,
|
||||
adminRecord: admin,
|
||||
};
|
||||
}
|
||||
|
||||
const firstShared = await getFirstEnabledAdminProvider();
|
||||
if (!firstShared) {
|
||||
const selected = await resolvePreferredEnabledAdminProvider({
|
||||
requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
|
||||
runtimeDefaultSlug: fallback,
|
||||
});
|
||||
if (!selected) {
|
||||
return { error: 'no_shared_provider_configured', slug: requestedProvider };
|
||||
}
|
||||
const apiKey = await decryptedKeyFor(firstShared);
|
||||
const apiKey = await decryptedKeyFor(selected);
|
||||
return {
|
||||
provider: firstShared.providerType,
|
||||
provider: selected.providerType,
|
||||
apiKey,
|
||||
baseUrl: firstShared.baseUrl || undefined,
|
||||
baseUrl: selected.baseUrl || undefined,
|
||||
fromAdmin: true,
|
||||
adminRecord: firstShared,
|
||||
adminRecord: selected,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,24 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
|
|||
};
|
||||
}
|
||||
|
||||
function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigKeyDef<number> {
|
||||
return {
|
||||
default: defaultValue,
|
||||
envVar,
|
||||
parseEnv(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
||||
return Math.floor(parsed);
|
||||
},
|
||||
validate(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
|
||||
return Math.floor(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const RUNTIME_CONFIG_SCHEMA = {
|
||||
defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'),
|
||||
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'),
|
||||
|
|
@ -88,6 +106,11 @@ export const RUNTIME_CONFIG_SCHEMA = {
|
|||
enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'),
|
||||
enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
|
||||
showAllProviderModels: runtimeBoolean(true),
|
||||
disableTtsRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_TTS_LIMIT'),
|
||||
ttsDailyLimitAnonymous: positiveIntValue(50_000),
|
||||
ttsDailyLimitAuthenticated: positiveIntValue(500_000),
|
||||
ttsIpDailyLimitAnonymous: positiveIntValue(100_000),
|
||||
ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000),
|
||||
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
|
||||
|
||||
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@
|
|||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
||||
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
|
||||
|
||||
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
|
||||
return value === 'mp3' || value === 'm4b';
|
||||
|
|
@ -101,13 +102,12 @@ function resolveRestrictedProviderRef(
|
|||
fallbackProviderRef: string,
|
||||
sharedProviders: SharedProviderPolicyEntry[],
|
||||
): string {
|
||||
const requestedIsBuiltIn = isBuiltInTtsProviderId(providerRef);
|
||||
const fallbackIsBuiltIn = isBuiltInTtsProviderId(fallbackProviderRef);
|
||||
const requestedSharedSlug = requestedIsBuiltIn ? '' : providerRef;
|
||||
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallbackProviderRef;
|
||||
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug;
|
||||
if (preferredSharedSlug) return preferredSharedSlug;
|
||||
return sharedProviders[0]?.slug || providerRef;
|
||||
const preferred = resolvePreferredSharedProviderSlug({
|
||||
providers: sharedProviders,
|
||||
requestedSlug: isBuiltInTtsProviderId(providerRef) ? null : providerRef,
|
||||
runtimeDefaultSlug: isBuiltInTtsProviderId(fallbackProviderRef) ? null : fallbackProviderRef,
|
||||
});
|
||||
return preferred || providerRef;
|
||||
}
|
||||
|
||||
export function canonicalizeAudiobookSettingsForRuntime(input: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { RATE_LIMITS, type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter';
|
||||
import { type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter';
|
||||
|
||||
function formatLimitForHint(limit: number): string {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
|
||||
|
|
@ -15,8 +15,10 @@ export function buildDailyQuotaExceededResponse(input: {
|
|||
rateLimitResult: RateLimitResult;
|
||||
isAnonymousUser: boolean;
|
||||
pathname: string;
|
||||
anonymousLimit: number;
|
||||
authenticatedLimit: number;
|
||||
}): NextResponse {
|
||||
const { rateLimitResult, isAnonymousUser, pathname } = input;
|
||||
const { rateLimitResult, isAnonymousUser, pathname, anonymousLimit, authenticatedLimit } = input;
|
||||
const resetTimeMs = rateLimitResult.resetTimeMs;
|
||||
const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000));
|
||||
|
||||
|
|
@ -32,7 +34,7 @@ export function buildDailyQuotaExceededResponse(input: {
|
|||
resetTimeMs,
|
||||
userType: isAnonymousUser ? 'anonymous' : 'authenticated',
|
||||
upgradeHint: isAnonymousUser
|
||||
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
|
||||
? `Sign up to increase your limit from ${formatLimitForHint(anonymousLimit)} to ${formatLimitForHint(authenticatedLimit)} characters per day`
|
||||
: undefined,
|
||||
instance: pathname,
|
||||
}), {
|
||||
|
|
|
|||
|
|
@ -3,53 +3,40 @@ import { userTtsChars } from '@/db/schema';
|
|||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||
import { eq, and, lt, sql } from 'drizzle-orm';
|
||||
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||
import { serverLogger } from '@/lib/server/logger';
|
||||
import { logDegraded } from '@/lib/server/errors/logging';
|
||||
|
||||
function readPositiveIntEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw || raw.trim() === '') return fallback;
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
logDegraded(serverLogger, {
|
||||
event: 'rate_limit.config.invalid_env',
|
||||
msg: 'Invalid rate limiter env value; using default',
|
||||
step: 'read_limit_env',
|
||||
context: {
|
||||
envVar: name,
|
||||
envValue: raw,
|
||||
fallbackValue: fallback,
|
||||
},
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.floor(parsed);
|
||||
export interface RateLimitThresholds {
|
||||
anonymous: number;
|
||||
authenticated: number;
|
||||
ipAnonymous: number;
|
||||
ipAuthenticated: number;
|
||||
}
|
||||
|
||||
function readBooleanEnv(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (!raw || raw.trim() === '') return fallback;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true;
|
||||
if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false;
|
||||
return fallback;
|
||||
export interface RateLimitRuntimeOptions {
|
||||
enabled: boolean;
|
||||
limits: RateLimitThresholds;
|
||||
}
|
||||
|
||||
export function isTtsRateLimitEnabled(): boolean {
|
||||
return readBooleanEnv('TTS_ENABLE_RATE_LIMIT', false);
|
||||
}
|
||||
export const DEFAULT_RATE_LIMITS: RateLimitThresholds = {
|
||||
anonymous: 50_000,
|
||||
authenticated: 500_000,
|
||||
ipAnonymous: 100_000,
|
||||
ipAuthenticated: 1_000_000,
|
||||
};
|
||||
|
||||
// Rate limits configuration - character counts per day
|
||||
export const RATE_LIMITS = {
|
||||
ANONYMOUS: readPositiveIntEnv('TTS_DAILY_LIMIT_ANONYMOUS', 50_000),
|
||||
AUTHENTICATED: readPositiveIntEnv('TTS_DAILY_LIMIT_AUTHENTICATED', 500_000),
|
||||
// IP-based backstop limits to make it harder to reset limits by creating new accounts
|
||||
// or clearing storage/cookies
|
||||
IP_ANONYMOUS: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_ANONYMOUS', 100_000),
|
||||
IP_AUTHENTICATED: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_AUTHENTICATED', 1_000_000),
|
||||
} as const;
|
||||
export function resolveRateLimitThresholds(input?: Partial<RateLimitThresholds>): RateLimitThresholds {
|
||||
const source = input ?? {};
|
||||
const normalize = (value: unknown, fallback: number): number => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.floor(value);
|
||||
};
|
||||
|
||||
return {
|
||||
anonymous: normalize(source.anonymous, DEFAULT_RATE_LIMITS.anonymous),
|
||||
authenticated: normalize(source.authenticated, DEFAULT_RATE_LIMITS.authenticated),
|
||||
ipAnonymous: normalize(source.ipAnonymous, DEFAULT_RATE_LIMITS.ipAnonymous),
|
||||
ipAuthenticated: normalize(source.ipAuthenticated, DEFAULT_RATE_LIMITS.ipAuthenticated),
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to ensure DB is strictly typed when we know it exists
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -163,8 +150,15 @@ export class RateLimiter {
|
|||
/**
|
||||
* Check if a user can use TTS and increment their char count if allowed
|
||||
*/
|
||||
async checkAndIncrementLimit(user: UserInfo, charCount: number, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
|
||||
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) {
|
||||
async checkAndIncrementLimit(
|
||||
user: UserInfo,
|
||||
charCount: number,
|
||||
backstops?: RateLimitBackstops,
|
||||
options?: RateLimitRuntimeOptions,
|
||||
): Promise<RateLimitResult> {
|
||||
const limits = resolveRateLimitThresholds(options?.limits);
|
||||
const enabled = options?.enabled ?? true;
|
||||
if (!isAuthEnabled() || !enabled) {
|
||||
return {
|
||||
allowed: true,
|
||||
currentCount: 0,
|
||||
|
|
@ -176,7 +170,7 @@ export class RateLimiter {
|
|||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const dateValue = today as unknown as UserTtsCharsDateValue;
|
||||
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
|
||||
const userLimit = user.isAnonymous ? limits.anonymous : limits.authenticated;
|
||||
|
||||
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
|
||||
|
||||
|
|
@ -184,13 +178,13 @@ export class RateLimiter {
|
|||
const ip = backstops?.ip?.toString() || null;
|
||||
|
||||
if (user.isAnonymous && deviceId) {
|
||||
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
|
||||
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: limits.anonymous });
|
||||
}
|
||||
|
||||
if (ip) {
|
||||
buckets.push({
|
||||
key: normalizeBackstopKey('ip', ip),
|
||||
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
|
||||
limit: user.isAnonymous ? limits.ipAnonymous : limits.ipAuthenticated,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +248,7 @@ export class RateLimiter {
|
|||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RateLimitExceeded) {
|
||||
const current = await this.getCurrentUsage(user, backstops);
|
||||
const current = await this.getCurrentUsage(user, backstops, options);
|
||||
return { ...current, allowed: false };
|
||||
}
|
||||
throw error;
|
||||
|
|
@ -264,8 +258,14 @@ export class RateLimiter {
|
|||
/**
|
||||
* Get current usage for a user without incrementing
|
||||
*/
|
||||
async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
|
||||
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) {
|
||||
async getCurrentUsage(
|
||||
user: UserInfo,
|
||||
backstops?: RateLimitBackstops,
|
||||
options?: RateLimitRuntimeOptions,
|
||||
): Promise<RateLimitResult> {
|
||||
const limits = resolveRateLimitThresholds(options?.limits);
|
||||
const enabled = options?.enabled ?? true;
|
||||
if (!isAuthEnabled() || !enabled) {
|
||||
return {
|
||||
allowed: true,
|
||||
currentCount: 0,
|
||||
|
|
@ -276,7 +276,7 @@ export class RateLimiter {
|
|||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
|
||||
const userLimit = user.isAnonymous ? limits.anonymous : limits.authenticated;
|
||||
|
||||
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
|
||||
|
||||
|
|
@ -284,13 +284,13 @@ export class RateLimiter {
|
|||
const ip = backstops?.ip?.toString() || null;
|
||||
|
||||
if (user.isAnonymous && deviceId) {
|
||||
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
|
||||
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: limits.anonymous });
|
||||
}
|
||||
|
||||
if (ip) {
|
||||
buckets.push({
|
||||
key: normalizeBackstopKey('ip', ip),
|
||||
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
|
||||
limit: user.isAnonymous ? limits.ipAnonymous : limits.ipAuthenticated,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ export class RateLimiter {
|
|||
* Transfer char counts when anonymous user creates an account
|
||||
*/
|
||||
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
|
||||
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) return;
|
||||
if (!isAuthEnabled()) return;
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const dateValue = today as unknown as UserTtsCharsDateValue;
|
||||
|
|
|
|||
34
src/lib/shared/shared-provider-selection.ts
Normal file
34
src/lib/shared/shared-provider-selection.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
|
||||
export interface SharedProviderSlugEntry {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
function normalizeSharedSlug(value: string | null | undefined): string {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
if (!trimmed) return '';
|
||||
return isBuiltInTtsProviderId(trimmed) ? '' : trimmed;
|
||||
}
|
||||
|
||||
export function resolvePreferredSharedProviderSlug<T extends SharedProviderSlugEntry>(input: {
|
||||
providers: readonly T[];
|
||||
requestedSlug?: string | null;
|
||||
runtimeDefaultSlug?: string | null;
|
||||
}): string | null {
|
||||
const providers = input.providers;
|
||||
if (providers.length === 0) return null;
|
||||
|
||||
const bySlug = new Map<string, T>();
|
||||
for (const provider of providers) {
|
||||
bySlug.set(provider.slug, provider);
|
||||
}
|
||||
|
||||
const requested = normalizeSharedSlug(input.requestedSlug);
|
||||
if (requested && bySlug.has(requested)) return requested;
|
||||
|
||||
const runtimeDefault = normalizeSharedSlug(input.runtimeDefaultSlug);
|
||||
if (runtimeDefault && bySlug.has(runtimeDefault)) return runtimeDefault;
|
||||
|
||||
if (bySlug.has('default-openai')) return 'default-openai';
|
||||
return providers[0]?.slug ?? null;
|
||||
}
|
||||
25
tests/unit/rate-limit-runtime-settings.spec.ts
Normal file
25
tests/unit/rate-limit-runtime-settings.spec.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { RUNTIME_CONFIG_SCHEMA } from '../../src/lib/server/admin/settings';
|
||||
|
||||
test.describe('TTS rate limit runtime config seeds', () => {
|
||||
test('defaults disable TTS daily rate limiting', () => {
|
||||
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.default).toBe(true);
|
||||
});
|
||||
|
||||
test('parses disable seed boolean values', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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.ttsDailyLimitAuthenticated.default).toBe(500_000);
|
||||
});
|
||||
});
|
||||
51
tests/unit/shared-provider-selection.spec.ts
Normal file
51
tests/unit/shared-provider-selection.spec.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { resolvePreferredSharedProviderSlug } from '../../src/lib/shared/shared-provider-selection';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ slug: 'shared-a' },
|
||||
{ slug: 'default-openai' },
|
||||
{ slug: 'shared-b' },
|
||||
] as const;
|
||||
|
||||
test.describe('resolvePreferredSharedProviderSlug', () => {
|
||||
test('prefers requested shared slug when present', () => {
|
||||
expect(resolvePreferredSharedProviderSlug({
|
||||
providers: PROVIDERS,
|
||||
requestedSlug: 'shared-b',
|
||||
runtimeDefaultSlug: 'shared-a',
|
||||
})).toBe('shared-b');
|
||||
});
|
||||
|
||||
test('falls back to runtime default shared slug when requested is missing', () => {
|
||||
expect(resolvePreferredSharedProviderSlug({
|
||||
providers: PROVIDERS,
|
||||
requestedSlug: 'missing-slug',
|
||||
runtimeDefaultSlug: 'shared-a',
|
||||
})).toBe('shared-a');
|
||||
});
|
||||
|
||||
test('falls back to default-openai before first provider', () => {
|
||||
expect(resolvePreferredSharedProviderSlug({
|
||||
providers: PROVIDERS,
|
||||
requestedSlug: null,
|
||||
runtimeDefaultSlug: null,
|
||||
})).toBe('default-openai');
|
||||
});
|
||||
|
||||
test('ignores built-in provider ids for requested/runtime defaults', () => {
|
||||
expect(resolvePreferredSharedProviderSlug({
|
||||
providers: PROVIDERS,
|
||||
requestedSlug: 'openai',
|
||||
runtimeDefaultSlug: 'custom-openai',
|
||||
})).toBe('default-openai');
|
||||
});
|
||||
|
||||
test('falls back to first enabled provider when default-openai is missing', () => {
|
||||
expect(resolvePreferredSharedProviderSlug({
|
||||
providers: [{ slug: 'shared-z' }, { slug: 'shared-y' }],
|
||||
requestedSlug: null,
|
||||
runtimeDefaultSlug: null,
|
||||
})).toBe('shared-z');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue