openreader/src/lib/server/rate-limit/rate-limiter.ts

366 lines
12 KiB
TypeScript

import { db } from '@/db';
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';
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) {
console.warn(`[rate-limiter] Invalid ${name}=${raw}; using default ${fallback}`);
return fallback;
}
return Math.floor(parsed);
}
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 function isTtsRateLimitEnabled(): boolean {
return readBooleanEnv('TTS_ENABLE_RATE_LIMIT', false);
}
// 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;
// Helper to ensure DB is strictly typed when we know it exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeDb = () => db as any;
type UserTtsCharsInsert = typeof userTtsChars.$inferInsert;
type UserTtsCharsDateValue = UserTtsCharsInsert['date'];
type UserTtsCharsUpdatedAtValue = UserTtsCharsInsert['updatedAt'];
export interface RateLimitResult {
allowed: boolean;
currentCount: number;
limit: number;
resetTimeMs: number;
remainingChars: number;
}
export interface UserInfo {
id: string;
isAnonymous?: boolean;
isPro?: boolean;
}
export interface RateLimitBackstops {
/** Stable device identifier cookie value (server-issued). */
deviceId?: string | null;
/** Best-effort client IP (from proxy headers). */
ip?: string | null;
}
type Bucket = {
key: string;
limit: number;
};
function normalizeBackstopKey(prefix: string, value: string): string {
const trimmed = value.trim();
const safe = trimmed.length > 128 ? trimmed.slice(0, 128) : trimmed;
return `${prefix}:${safe}`;
}
function pickEffectiveResult(results: Array<{ currentCount: number; limit: number }>): {
currentCount: number;
limit: number;
remainingChars: number;
allowed: boolean;
} {
if (results.length === 0) {
return {
allowed: true,
currentCount: 0,
limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
};
}
let binding = results[0];
let bindingRemaining = Math.max(0, binding.limit - binding.currentCount);
for (const r of results) {
const remaining = Math.max(0, r.limit - r.currentCount);
if (remaining < bindingRemaining) {
binding = r;
bindingRemaining = remaining;
}
}
return {
allowed: results.every(r => r.currentCount < r.limit),
currentCount: binding.currentCount,
limit: binding.limit,
remainingChars: bindingRemaining,
};
}
class RateLimitExceeded extends Error {
name = 'RateLimitExceeded' as const;
}
function getRowsAffected(result: unknown): number {
if (typeof result !== 'object' || result === null) return 0;
const rec = result as Record<string, unknown>;
if (typeof rec.rowCount === 'number') return rec.rowCount;
if (typeof rec.changes === 'number') return rec.changes;
return 0;
}
export class RateLimiter {
constructor() { }
private isPostgres(): boolean {
return Boolean(process.env.POSTGRES_URL);
}
private getUpdatedAtValue(): number {
return nowTimestampMs();
}
// Use a transaction only when running with Postgres.
// better-sqlite3 transactions require sync callbacks and cannot be awaited.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async runMutation<T>(fn: (conn: any) => Promise<T>): Promise<T> {
if (this.isPostgres()) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return safeDb().transaction(async (tx: any) => fn(tx));
}
return fn(safeDb());
}
/**
* 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()) {
return {
allowed: true,
currentCount: 0,
limit: Number.MAX_SAFE_INTEGER,
resetTimeMs: this.getResetTimeMs(),
remainingChars: Number.MAX_SAFE_INTEGER
};
}
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 buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
const deviceId = backstops?.deviceId?.toString() || null;
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
});
}
try {
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
return await this.runMutation(async (conn) => {
// Ensure records exist for each bucket
for (const bucket of buckets) {
await conn.insert(userTtsChars)
.values({
userId: bucket.key,
date: dateValue,
charCount: 0,
})
.onConflictDoUpdate({
target: [userTtsChars.userId, userTtsChars.date],
set: { updatedAt },
});
}
// Attempt to increment each bucket. The `lt(..., limit)` guard blocks requests
// that start after the bucket is already exhausted, while still allowing a
// request to push the count over the limit.
for (const bucket of buckets) {
const updateResult = await conn.update(userTtsChars)
.set({
charCount: sql`${userTtsChars.charCount} + ${charCount}`,
updatedAt,
})
.where(and(
eq(userTtsChars.userId, bucket.key),
eq(userTtsChars.date, dateValue),
lt(userTtsChars.charCount, bucket.limit)
));
if (getRowsAffected(updateResult) <= 0) {
throw new RateLimitExceeded();
}
}
// Fetch current counts
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
for (const bucket of buckets) {
const result = await conn.select({ currentCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, dateValue)));
const currentCount = result[0]?.currentCount ? Number(result[0].currentCount) : 0;
bucketResults.push({ currentCount, limit: bucket.limit });
}
const effective = pickEffectiveResult(bucketResults);
return {
allowed: true,
currentCount: effective.currentCount,
limit: effective.limit,
resetTimeMs: this.getResetTimeMs(),
remainingChars: effective.remainingChars,
};
});
} catch (error) {
if (error instanceof RateLimitExceeded) {
const current = await this.getCurrentUsage(user, backstops);
return { ...current, allowed: false };
}
throw error;
}
}
/**
* Get current usage for a user without incrementing
*/
async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) {
return {
allowed: true,
currentCount: 0,
limit: Number.MAX_SAFE_INTEGER,
resetTimeMs: this.getResetTimeMs(),
remainingChars: Number.MAX_SAFE_INTEGER
};
}
const today = new Date().toISOString().split('T')[0];
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
const deviceId = backstops?.deviceId?.toString() || null;
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
});
}
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
for (const bucket of buckets) {
const result = await safeDb().select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today)));
const currentCount = result[0]?.charCount ? Number(result[0].charCount) : 0;
bucketResults.push({ currentCount, limit: bucket.limit });
}
const effective = pickEffectiveResult(bucketResults);
return {
allowed: effective.allowed,
currentCount: effective.currentCount,
limit: effective.limit,
resetTimeMs: this.getResetTimeMs(),
remainingChars: effective.remainingChars,
};
}
/**
* Transfer char counts when anonymous user creates an account
*/
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) return;
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
const anonymousResult = await safeDb().select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue)));
if (anonymousResult.length === 0) return;
const anonymousCount = Number(anonymousResult[0].charCount);
const existingAuth = await safeDb().select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue)));
if (existingAuth.length === 0) {
await safeDb().insert(userTtsChars)
.values({ userId: authenticatedUserId, date: dateValue, charCount: anonymousCount });
} else {
const existingCount = Number(existingAuth[0].charCount);
if (anonymousCount > existingCount) {
await safeDb().update(userTtsChars)
.set({ charCount: anonymousCount, updatedAt })
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue)));
}
}
await safeDb().delete(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue)));
}
/**
* Clean up old records (optional maintenance)
*/
async cleanupOldRecords(daysToKeep: number = 30): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
const cutoffDateValue = cutoffDateStr as unknown as UserTtsCharsDateValue;
// Assuming string comparison works for YYYY-MM-DD
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue));
}
private getResetTimeMs(): number {
return nextUtcMidnightTimestampMs();
}
}
// Export singleton instance
export const rateLimiter = new RateLimiter();