Files
AstralRinth/apps/labrinth/src/util/ratelimit.rs
Josiah Glosson cf190d86d5 Update Rust dependencies (#4139)
* 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
2025-08-08 22:50:44 +00:00

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()))
}
}