diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index d41b7c0..64bd12b 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -3,10 +3,11 @@ import type { TTSRequestPayload } from '@/types/client'; import type { TTSError } from '@/types/tts'; import { headers } from 'next/headers'; import { auth } from '@/lib/server/auth/auth'; -import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; +import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; import { isAuthEnabled } from '@/lib/server/auth/config'; import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; +import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { buildTTSCacheKey, @@ -26,7 +27,6 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d } const PROBLEM_TYPES = { - dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded', upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', } as const; @@ -40,16 +40,6 @@ type ProblemDetails = { [key: string]: unknown; }; -function formatLimitForHint(limit: number): string { - if (!Number.isFinite(limit) || limit <= 0) return String(limit); - if (limit >= 1_000_000) { - const m = limit / 1_000_000; - return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`; - } - if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`; - return String(limit); -} - export async function POST(req: NextRequest) { let providerForError: string | null = null; let didCreateDeviceIdCookie = false; @@ -99,35 +89,10 @@ export async function POST(req: NextRequest) { ); if (!rateLimitResult.allowed) { - const resetTimeMs = rateLimitResult.resetTimeMs; - const retryAfterSeconds = Math.max( - 0, - Math.ceil((resetTimeMs - Date.now()) / 1000) - ); - - const problem: ProblemDetails = { - type: PROBLEM_TYPES.dailyQuotaExceeded, - title: 'Daily quota exceeded', - status: 429, - detail: 'Daily character limit exceeded', - code: 'USER_DAILY_QUOTA_EXCEEDED', - currentCount: rateLimitResult.currentCount, - limit: rateLimitResult.limit, - remainingChars: rateLimitResult.remainingChars, - resetTimeMs, - userType: isAnonymous ? 'anonymous' : 'authenticated', - upgradeHint: isAnonymous - ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` - : undefined, - instance: req.nextUrl.pathname, - }; - - const response = new NextResponse(JSON.stringify(problem), { - status: 429, - headers: { - 'Content-Type': 'application/problem+json', - 'Retry-After': String(retryAfterSeconds), - }, + const response = buildDailyQuotaExceededResponse({ + rateLimitResult, + isAnonymousUser: isAnonymous, + pathname: req.nextUrl.pathname, }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 00642cc..495ece3 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -22,9 +22,10 @@ import { probeAudioDurationMsFromBuffer, } from '@/lib/server/tts/segments'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; -import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; +import { rateLimiter, isTtsRateLimitEnabled } 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 { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; import { alignAudioWithText } from '@/lib/server/whisper/alignment'; import type { TTSSegmentInput, @@ -42,16 +43,6 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d } } -function formatLimitForHint(limit: number): string { - if (!Number.isFinite(limit) || limit <= 0) return String(limit); - if (limit >= 1_000_000) { - const m = limit / 1_000_000; - return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`; - } - if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`; - return String(limit); -} - function parseSettings(value: unknown): TTSSegmentSettings | null { if (!value || typeof value !== 'object') return null; const rec = value as Record; @@ -246,6 +237,44 @@ export async function POST(request: NextRequest) { const existingById = new Map(rows.map((row) => [row.segmentId, row])); const manifest: TTSSegmentManifestItem[] = []; + const upsertSegmentEntry = async (input: { + segmentEntryId: string; + segmentIndex: number; + segmentKey: string | null; + locatorProjection: ReturnType; + textHash: string; + textLength: number; + }): Promise => { + await db + .insert(ttsSegmentEntries) + .values({ + segmentEntryId: input.segmentEntryId, + userId: scope.storageUserId, + documentId: parsed.documentId, + readerType: scope.readerType, + documentVersion: scope.documentVersion, + segmentIndex: input.segmentIndex, + segmentKey: input.segmentKey, + ...input.locatorProjection, + textHash: input.textHash, + textLength: input.textLength, + updatedAt: nowMs, + }) + .onConflictDoUpdate({ + target: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId], + set: { + documentId: parsed.documentId, + readerType: scope.readerType, + documentVersion: scope.documentVersion, + segmentIndex: input.segmentIndex, + segmentKey: input.segmentKey, + ...input.locatorProjection, + textHash: input.textHash, + textLength: input.textLength, + updatedAt: nowMs, + }, + }); + }; for (const segment of normalized) { const locatorProjection = projectSegmentLocator(segment.locator); @@ -261,42 +290,21 @@ export async function POST(request: NextRequest) { textHash: segment.textHash, }); - await db - .insert(ttsSegmentEntries) - .values({ - segmentEntryId, - userId: scope.storageUserId, - documentId: parsed.documentId, - readerType: scope.readerType, - documentVersion: scope.documentVersion, - segmentIndex: segment.original.segmentIndex, - segmentKey: segmentKeyForRow, - ...locatorProjection, - textHash: segment.textHash, - textLength: segment.text.length, - updatedAt: nowMs, - }) - .onConflictDoUpdate({ - target: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId], - set: { - documentId: parsed.documentId, - readerType: scope.readerType, - documentVersion: scope.documentVersion, - segmentIndex: segment.original.segmentIndex, - segmentKey: segmentKeyForRow, - ...locatorProjection, - textHash: segment.textHash, - textLength: segment.text.length, - updatedAt: nowMs, - }, - }); - const existing = existingById.get(segment.segmentId); const movedFromEntryId = existing && existing.segmentEntryId !== segmentEntryId ? existing.segmentEntryId : null; if (existing?.status === 'completed' && existing.audioKey) { + await upsertSegmentEntry({ + segmentEntryId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + locatorProjection, + textHash: segment.textHash, + textLength: segment.text.length, + }); + if (movedFromEntryId) { await db .update(ttsSegmentVariants) @@ -375,6 +383,44 @@ export async function POST(request: NextRequest) { segmentId: segment.segmentId, }); + if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) { + const charCount = segment.text.length; + const ip = getClientIp(request); + const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null; + if (device?.didCreate) { + didCreateDeviceIdCookie = true; + deviceIdToSet = device.deviceId; + } + + const rateLimitResult = await rateLimiter.checkAndIncrementLimit( + { id: scope.userId, isAnonymous: scope.isAnonymousUser }, + charCount, + { + deviceId: device?.deviceId ?? null, + ip, + }, + ); + + if (!rateLimitResult.allowed) { + const response = buildDailyQuotaExceededResponse({ + rateLimitResult, + isAnonymousUser: scope.isAnonymousUser, + pathname: request.nextUrl.pathname, + }); + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); + return response; + } + } + + await upsertSegmentEntry({ + segmentEntryId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + locatorProjection, + textHash: segment.textHash, + textLength: segment.text.length, + }); + await db .insert(ttsSegmentVariants) .values({ @@ -408,54 +454,6 @@ export async function POST(request: NextRequest) { } try { - if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) { - const charCount = segment.text.length; - const ip = getClientIp(request); - const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null; - if (device?.didCreate) { - didCreateDeviceIdCookie = true; - deviceIdToSet = device.deviceId; - } - - const rateLimitResult = await rateLimiter.checkAndIncrementLimit( - { id: scope.userId, isAnonymous: scope.isAnonymousUser }, - charCount, - { - deviceId: device?.deviceId ?? null, - ip, - }, - ); - - if (!rateLimitResult.allowed) { - const resetTimeMs = rateLimitResult.resetTimeMs; - const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000)); - const response = new NextResponse(JSON.stringify({ - type: 'https://openreader.app/problems/daily-quota-exceeded', - title: 'Daily quota exceeded', - status: 429, - detail: 'Daily character limit exceeded', - code: 'USER_DAILY_QUOTA_EXCEEDED', - currentCount: rateLimitResult.currentCount, - limit: rateLimitResult.limit, - remainingChars: rateLimitResult.remainingChars, - resetTimeMs, - userType: scope.isAnonymousUser ? 'anonymous' : 'authenticated', - upgradeHint: scope.isAnonymousUser - ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` - : undefined, - instance: request.nextUrl.pathname, - }), { - status: 429, - headers: { - 'Content-Type': 'application/problem+json', - 'Retry-After': String(retryAfterSeconds), - }, - }); - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } - } - const ttsBuffer = await generateTTSBuffer({ text: segment.text, voice: parsed.settings.voice, diff --git a/src/lib/server/rate-limit/problem-response.ts b/src/lib/server/rate-limit/problem-response.ts new file mode 100644 index 0000000..9f7e2ad --- /dev/null +++ b/src/lib/server/rate-limit/problem-response.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; +import { RATE_LIMITS, type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter'; + +function formatLimitForHint(limit: number): string { + if (!Number.isFinite(limit) || limit <= 0) return String(limit); + if (limit >= 1_000_000) { + const m = limit / 1_000_000; + return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`; + } + if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`; + return String(limit); +} + +export function buildDailyQuotaExceededResponse(input: { + rateLimitResult: RateLimitResult; + isAnonymousUser: boolean; + pathname: string; +}): NextResponse { + const { rateLimitResult, isAnonymousUser, pathname } = input; + const resetTimeMs = rateLimitResult.resetTimeMs; + const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000)); + + return new NextResponse(JSON.stringify({ + type: 'https://openreader.app/problems/daily-quota-exceeded', + title: 'Daily quota exceeded', + status: 429, + detail: 'Daily character limit exceeded', + code: 'USER_DAILY_QUOTA_EXCEEDED', + currentCount: rateLimitResult.currentCount, + limit: rateLimitResult.limit, + remainingChars: rateLimitResult.remainingChars, + resetTimeMs, + userType: isAnonymousUser ? 'anonymous' : 'authenticated', + upgradeHint: isAnonymousUser + ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` + : undefined, + instance: pathname, + }), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + 'Retry-After': String(retryAfterSeconds), + }, + }); +} diff --git a/tests/unit/epub-word-highlight.spec.ts b/tests/unit/epub-word-highlight.spec.ts index 719a58c..dc9720a 100644 --- a/tests/unit/epub-word-highlight.spec.ts +++ b/tests/unit/epub-word-highlight.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { buildMonotonicWordToTokenMap, tokenizeCanonicalSegment, -} from '../../src/lib/shared/epub-word-highlight'; +} from '../../src/lib/client/epub/epub-word-highlight'; import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan'; import type { TTSSentenceAlignment } from '../../src/types/tts'; diff --git a/tests/unit/tts-epub-handoff.spec.ts b/tests/unit/tts-epub-handoff.spec.ts index c80ebd2..143e6e1 100644 --- a/tests/unit/tts-epub-handoff.spec.ts +++ b/tests/unit/tts-epub-handoff.spec.ts @@ -5,7 +5,7 @@ import { resolveEpubBoundaryHandoffStartIndex, resolveEpubReplaySuppressionAction, shouldSuppressCompletedEpubBoundaryReplay, -} from '../../src/lib/shared/tts-epub-handoff'; +} from '../../src/lib/client/epub/tts-epub-handoff'; import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan'; const segment = ( diff --git a/tests/unit/tts-epub-preload.spec.ts b/tests/unit/tts-epub-preload.spec.ts index c6186be..ea24f88 100644 --- a/tests/unit/tts-epub-preload.spec.ts +++ b/tests/unit/tts-epub-preload.spec.ts @@ -4,7 +4,7 @@ import type { CanonicalTtsSourceUnit } from '../../src/lib/shared/tts-segment-pl import { buildWalkerPlanningSourceUnits, selectUpcomingWalkerItems, -} from '../../src/lib/shared/tts-epub-preload'; +} from '../../src/lib/client/epub/tts-epub-preload'; test.describe('EPUB walker preload helpers', () => { test('selectUpcomingWalkerItems honors depth as current + (depth-1) upcoming', () => {