You've already forked AstralRinth
forked from didirus/AstralRinth
* Update Rust version * Update async-compression 0.4.25 -> 0.4.27 * Update async-tungstenite 0.29.1 -> 0.30.0 * Update bytemuck 1.23.0 -> 1.23.1 * Update clap 4.5.40 -> 4.5.43 * Update deadpool-redis 0.21.1 -> 0.22.0 and redis 0.31.0 -> 0.32.4 * Update enumset 1.1.6 -> 1.1.7 * Update hyper-util 0.1.14 -> 0.1.16 * Update indexmap 2.9.0 -> 2.10.0 * Update indicatif 0.17.11 -> 0.18.0 * Update jemalloc_pprof 0.7.0 -> 0.8.1 * Update lettre 0.11.17 -> 0.11.18 * Update meilisearch-sdk 0.28.0 -> 0.29.1 * Update notify 8.0.0 -> 8.2.0 and notify-debouncer-mini 0.6.0 -> 0.7.0 * Update quick-xml 0.37.5 -> 0.38.1 * Fix theseus lint * Update reqwest 0.12.20 -> 0.12.22 * Cargo fmt in theseus * Update rgb 0.8.50 -> 0.8.52 * Update sentry 0.41.0 -> 0.42.0 and sentry-actix 0.41.0 -> 0.42.0 * Update serde_json 1.0.140 -> 1.0.142 * Update serde_with 3.13.0 -> 3.14.0 * Update spdx 0.10.8 -> 0.10.9 * Update sysinfo 0.35.2 -> 0.36.1 * Update tauri suite * Fix build by updating mappings * Update tokio 1.45.1 -> 1.47.1 and tokio-util 0.7.15 -> 0.7.16 * Update tracing-actix-web 0.7.18 -> 0.7.19 * Update zip 4.2.0 -> 4.3.0 * Misc Cargo.lock updates * Update Dockerfiles
236 lines
7.1 KiB
Rust
236 lines
7.1 KiB
Rust
use crate::database::redis::RedisPool;
|
|
use crate::routes::ApiError;
|
|
use crate::util::env::parse_var;
|
|
use actix_web::{
|
|
Error, ResponseError,
|
|
body::{EitherBody, MessageBody},
|
|
dev::{ServiceRequest, ServiceResponse},
|
|
middleware::Next,
|
|
web,
|
|
};
|
|
use chrono::Utc;
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
|
|
const RATE_LIMIT_NAMESPACE: &str = "rate_limit";
|
|
const RATE_LIMIT_EXPIRY: i64 = 300; // 5 minutes
|
|
const MINUTE_IN_NANOS: i64 = 60_000_000_000;
|
|
|
|
pub struct GCRAParameters {
|
|
emission_interval: i64,
|
|
burst_size: u32,
|
|
}
|
|
|
|
impl GCRAParameters {
|
|
pub(crate) fn new(requests_per_minute: u32, burst_size: u32) -> Self {
|
|
// Calculate emission interval in nanoseconds
|
|
let emission_interval = MINUTE_IN_NANOS / requests_per_minute as i64;
|
|
|
|
Self {
|
|
emission_interval,
|
|
burst_size,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct RateLimitDecision {
|
|
pub allowed: bool,
|
|
pub limit: u32,
|
|
pub remaining: u32,
|
|
pub reset_after_ms: i64,
|
|
pub retry_after_ms: Option<i64>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AsyncRateLimiter {
|
|
redis_pool: RedisPool,
|
|
params: Arc<GCRAParameters>,
|
|
}
|
|
|
|
impl AsyncRateLimiter {
|
|
pub fn new(redis_pool: RedisPool, params: GCRAParameters) -> Self {
|
|
Self {
|
|
redis_pool,
|
|
params: Arc::new(params),
|
|
}
|
|
}
|
|
|
|
pub async fn check_rate_limit(&self, key: &str) -> RateLimitDecision {
|
|
let Ok(mut conn) = self.redis_pool.connect().await else {
|
|
// If Redis is unavailable, allow the request but with reduced limit
|
|
return RateLimitDecision {
|
|
allowed: true,
|
|
limit: self.params.burst_size,
|
|
remaining: 1,
|
|
reset_after_ms: 60_000, // 1 minute
|
|
retry_after_ms: None,
|
|
};
|
|
};
|
|
|
|
// Get current time in nanoseconds since UNIX epoch
|
|
let now = Utc::now().timestamp_nanos_opt().unwrap_or(0);
|
|
|
|
// Get the current TAT from Redis (if it exists)
|
|
let tat_str = conn.get(RATE_LIMIT_NAMESPACE, key).await.ok().flatten();
|
|
|
|
// Parse the TAT or use current time if not found
|
|
let current_tat = match tat_str {
|
|
Some(tat_str) => tat_str.parse::<i64>().unwrap_or(now),
|
|
None => now,
|
|
};
|
|
|
|
// Calculate the new TAT using GCRA
|
|
let increment = self.params.emission_interval;
|
|
let max_tat_delta = increment * self.params.burst_size as i64;
|
|
|
|
// Calculate allowance: how much time has passed since the TAT
|
|
let allowance = now - current_tat;
|
|
|
|
if allowance < -max_tat_delta {
|
|
// Too many requests, rate limit exceeded
|
|
// Calculate when the client can retry
|
|
let retry_after_ms = (-allowance - max_tat_delta) / 1_000_000;
|
|
|
|
return RateLimitDecision {
|
|
allowed: false,
|
|
limit: self.params.burst_size,
|
|
remaining: 0,
|
|
reset_after_ms: -allowance / 1_000_000,
|
|
retry_after_ms: Some(retry_after_ms.max(0)),
|
|
};
|
|
}
|
|
|
|
let new_tat = std::cmp::max(current_tat + increment, now);
|
|
|
|
let _ = conn
|
|
.set(
|
|
RATE_LIMIT_NAMESPACE,
|
|
key,
|
|
&new_tat.to_string(),
|
|
Some(RATE_LIMIT_EXPIRY),
|
|
)
|
|
.await;
|
|
|
|
let remaining_capacity =
|
|
((max_tat_delta - (new_tat - now)) / increment).max(0) as u32;
|
|
|
|
RateLimitDecision {
|
|
allowed: true,
|
|
limit: self.params.burst_size,
|
|
remaining: remaining_capacity,
|
|
reset_after_ms: (new_tat - now) / 1_000_000,
|
|
retry_after_ms: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn rate_limit_middleware(
|
|
req: ServiceRequest,
|
|
next: Next<impl MessageBody>,
|
|
) -> Result<ServiceResponse<EitherBody<impl MessageBody>>, Error> {
|
|
let rate_limiter = req
|
|
.app_data::<web::Data<AsyncRateLimiter>>()
|
|
.expect("Rate limiter not configured properly")
|
|
.clone();
|
|
|
|
if let Some(key) = req.headers().get("x-ratelimit-key")
|
|
&& key.to_str().ok()
|
|
== dotenvy::var("RATE_LIMIT_IGNORE_KEY").ok().as_deref()
|
|
{
|
|
return Ok(next.call(req).await?.map_into_left_body());
|
|
}
|
|
|
|
let conn_info = req.connection_info().clone();
|
|
let ip = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
|
|
if let Some(header) = req.headers().get("CF-Connecting-IP") {
|
|
header.to_str().ok()
|
|
} else {
|
|
conn_info.peer_addr()
|
|
}
|
|
} else {
|
|
conn_info.peer_addr()
|
|
};
|
|
|
|
if let Some(ip) = ip {
|
|
let decision = rate_limiter.check_rate_limit(ip).await;
|
|
|
|
if decision.allowed {
|
|
let mut service_response = next.call(req).await?;
|
|
|
|
// Add rate limit headers
|
|
let headers = service_response.headers_mut();
|
|
headers.insert(
|
|
actix_web::http::header::HeaderName::from_str(
|
|
"x-ratelimit-limit",
|
|
)
|
|
.unwrap(),
|
|
decision.limit.into(),
|
|
);
|
|
headers.insert(
|
|
actix_web::http::header::HeaderName::from_str(
|
|
"x-ratelimit-remaining",
|
|
)
|
|
.unwrap(),
|
|
decision.remaining.into(),
|
|
);
|
|
headers.insert(
|
|
actix_web::http::header::HeaderName::from_str(
|
|
"x-ratelimit-reset",
|
|
)
|
|
.unwrap(),
|
|
(decision.reset_after_ms / 1000).into(),
|
|
);
|
|
|
|
Ok(service_response.map_into_left_body())
|
|
} else {
|
|
let mut response = ApiError::RateLimitError(
|
|
decision.retry_after_ms.unwrap_or(0) as u128,
|
|
decision.limit,
|
|
)
|
|
.error_response();
|
|
|
|
// Add rate limit headers
|
|
let headers = response.headers_mut();
|
|
headers.insert(
|
|
actix_web::http::header::HeaderName::from_str(
|
|
"x-ratelimit-limit",
|
|
)
|
|
.unwrap(),
|
|
decision.limit.into(),
|
|
);
|
|
headers.insert(
|
|
actix_web::http::header::HeaderName::from_str(
|
|
"x-ratelimit-remaining",
|
|
)
|
|
.unwrap(),
|
|
0.into(),
|
|
);
|
|
headers.insert(
|
|
actix_web::http::header::HeaderName::from_str(
|
|
"x-ratelimit-reset",
|
|
)
|
|
.unwrap(),
|
|
(decision.reset_after_ms / 1000).into(),
|
|
);
|
|
|
|
// TODO: Centralize CORS in the CORS util.
|
|
headers.insert(
|
|
actix_web::http::header::HeaderName::from_str(
|
|
"Access-Control-Allow-Origin",
|
|
)
|
|
.unwrap(),
|
|
"*".parse().unwrap(),
|
|
);
|
|
|
|
Ok(req.into_response(response.map_into_right_body()))
|
|
}
|
|
} else {
|
|
let response = ApiError::CustomAuthentication(
|
|
"Unable to obtain user IP address!".to_string(),
|
|
)
|
|
.error_response();
|
|
|
|
Ok(req.into_response(response.map_into_right_body()))
|
|
}
|
|
}
|