36 lines
1.5 KiB
TypeScript
36 lines
1.5 KiB
TypeScript
|
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||
|
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||
|
|
import { AppException, ErrorCode } from '../../common/errors';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Rate limits voice extraction per user rather than per IP.
|
||
|
|
*
|
||
|
|
* The default tracker keys on `req.ip`, which behind nginx means the whole deployment
|
||
|
|
* shares one bucket unless `trust proxy` is set — and an abuser rotating IPs would bypass
|
||
|
|
* it entirely. Since v1 ships with no plan gate, this is the only control on metered
|
||
|
|
* vendor spend, so it has to key on something the client cannot change.
|
||
|
|
*
|
||
|
|
* Guard order matters: the controller's JwtAuthGuard runs before this method-level guard,
|
||
|
|
* so `req.user` is populated by the time `getTracker` is called.
|
||
|
|
*/
|
||
|
|
@Injectable()
|
||
|
|
export class VoiceThrottlerGuard extends ThrottlerGuard {
|
||
|
|
protected getTracker(req: Record<string, unknown>): Promise<string> {
|
||
|
|
const user = req?.user as { id?: unknown } | undefined;
|
||
|
|
if (typeof user?.id === 'string' && user.id) {
|
||
|
|
return Promise.resolve(`voice:user:${user.id}`);
|
||
|
|
}
|
||
|
|
// Unauthenticated requests never reach here, but fall back rather than share a bucket.
|
||
|
|
const ip = typeof req?.ip === 'string' ? req.ip : 'unknown';
|
||
|
|
return Promise.resolve(`voice:ip:${ip}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Without this, ThrottlerException surfaces as INTERNAL_ERROR — there is no 429 fallback. */
|
||
|
|
protected throwThrottlingException(): Promise<void> {
|
||
|
|
throw new AppException(
|
||
|
|
ErrorCode.VOICE_RATE_LIMITED,
|
||
|
|
HttpStatus.TOO_MANY_REQUESTS,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|