mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
115 lines
3.4 KiB
115 lines
3.4 KiB
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
|
|
import {
|
|
CanActivate,
|
|
ExecutionContext,
|
|
HttpException,
|
|
HttpStatus,
|
|
Injectable,
|
|
Logger
|
|
} from '@nestjs/common';
|
|
|
|
@Injectable()
|
|
export class AgentRateLimitGuard implements CanActivate {
|
|
private readonly logger = new Logger(AgentRateLimitGuard.name);
|
|
private readonly maxRequests: number;
|
|
private readonly windowMs: number;
|
|
|
|
// In-memory fallback when Redis is unavailable
|
|
private readonly fallbackCounts = new Map<
|
|
string,
|
|
{ count: number; windowStart: number }
|
|
>();
|
|
|
|
public constructor(
|
|
private readonly configurationService: ConfigurationService,
|
|
private readonly redisCacheService: RedisCacheService
|
|
) {
|
|
this.maxRequests = this.configurationService.get('AGENT_RATE_LIMIT_MAX');
|
|
this.windowMs =
|
|
this.configurationService.get('AGENT_RATE_LIMIT_WINDOW_SECONDS') * 1000;
|
|
}
|
|
|
|
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
|
const userId = request.user.id;
|
|
const windowBucket = Math.floor(Date.now() / this.windowMs);
|
|
const key = `agent:ratelimit:${userId}:${windowBucket}`;
|
|
|
|
try {
|
|
// Atomic increment — avoids race condition on concurrent requests
|
|
const count = await this.redisCacheService.increment(key, this.windowMs);
|
|
|
|
if (count > this.maxRequests) {
|
|
const retryAfterSeconds = Math.ceil(
|
|
(this.windowMs - (Date.now() % this.windowMs)) / 1000
|
|
);
|
|
this.logger.warn('Agent rate limit exceeded', { userId, count });
|
|
|
|
throw new HttpException(
|
|
{
|
|
message:
|
|
'Rate limit exceeded. Please wait before sending another query.',
|
|
retryAfterSeconds
|
|
},
|
|
HttpStatus.TOO_MANY_REQUESTS
|
|
);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof HttpException) {
|
|
throw error;
|
|
}
|
|
|
|
// Redis unavailable — use in-memory sliding window fallback
|
|
this.logger.error('Rate limit Redis check failed, using fallback', error);
|
|
return this.checkFallbackLimit(userId, windowBucket);
|
|
}
|
|
}
|
|
|
|
private checkFallbackLimit(userId: string, windowBucket: number): boolean {
|
|
const entry = this.fallbackCounts.get(userId);
|
|
const now = Date.now();
|
|
|
|
if (!entry || entry.windowStart !== windowBucket) {
|
|
this.fallbackCounts.set(userId, { count: 1, windowStart: windowBucket });
|
|
|
|
// Prune stale entries to prevent memory leak
|
|
if (this.fallbackCounts.size > 10_000) {
|
|
for (const [key, val] of this.fallbackCounts) {
|
|
if (val.windowStart < windowBucket) {
|
|
this.fallbackCounts.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
entry.count++;
|
|
|
|
if (entry.count > this.maxRequests) {
|
|
const retryAfterSeconds = Math.ceil(
|
|
(this.windowMs - (now % this.windowMs)) / 1000
|
|
);
|
|
this.logger.warn('Agent rate limit exceeded (fallback)', {
|
|
userId,
|
|
count: entry.count
|
|
});
|
|
|
|
throw new HttpException(
|
|
{
|
|
message:
|
|
'Rate limit exceeded. Please wait before sending another query.',
|
|
retryAfterSeconds
|
|
},
|
|
HttpStatus.TOO_MANY_REQUESTS
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|