use crate::rate_limit::{ActionType, BucketConfig, input::LemmyInput};
use actix_extensible_rate_limit::backend::{
Backend,
Decision,
SimpleOutput,
memory::DEFAULT_GC_INTERVAL_SECONDS,
};
use actix_web::rt::{task::JoinHandle, time::Instant};
use dashmap::DashMap;
use enum_map::EnumMap;
use std::{
convert::Infallible,
sync::{Arc, RwLock},
time::Duration,
};
#[derive(Clone)]
pub struct LemmyBackend {
map: Arc<DashMap<LemmyInput, Value>>,
gc_handle: Option<Arc<JoinHandle<()>>>,
pub(super) configs: Arc<RwLock<EnumMap<ActionType, BucketConfig>>>,
}
struct Value {
ttl: Instant,
count: u64,
}
impl LemmyBackend {
pub(crate) fn new(configs: EnumMap<ActionType, BucketConfig>, enable_gc: bool) -> Self {
let map = Arc::new(DashMap::<LemmyInput, Value>::new());
let gc_handle = enable_gc.then(|| {
Arc::new(LemmyBackend::garbage_collector(
map.clone(),
Duration::from_secs(DEFAULT_GC_INTERVAL_SECONDS),
))
});
LemmyBackend {
map,
gc_handle,
configs: Arc::new(RwLock::new(configs)),
}
}
fn garbage_collector(map: Arc<DashMap<LemmyInput, Value>>, interval: Duration) -> JoinHandle<()> {
assert!(
interval.as_secs_f64() > 0f64,
"GC interval must be non-zero"
);
tokio::spawn(async move {
loop {
let now = Instant::now();
map.retain(|_k, v| v.ttl > now);
tokio::time::sleep_until(now + interval).await;
}
})
}
}
impl Backend<LemmyInput> for LemmyBackend {
type Output = SimpleOutput;
type RollbackToken = LemmyInput;
type Error = Infallible;
#[expect(clippy::expect_used)]
async fn request(
&self,
input: LemmyInput,
) -> Result<(Decision, Self::Output, Self::RollbackToken), Self::Error> {
#[expect(clippy::expect_used)]
let config = self.configs.read().expect("read rwlock")[input.1];
let max_requests: u64 = config.max_requests.into();
let interval = Duration::from_secs(config.interval.into());
let now = Instant::now();
let mut count = 1;
let mut expiry = now
.checked_add(interval)
.expect("Interval unexpectedly large");
self
.map
.entry(input)
.and_modify(|v| {
if v.ttl > now {
v.count += 1;
count = v.count;
expiry = v.ttl;
} else {
v.ttl = expiry;
v.count = count;
}
})
.or_insert_with(|| Value {
ttl: expiry,
count,
});
let allow = count <= max_requests;
let output = SimpleOutput {
limit: max_requests,
remaining: max_requests.saturating_sub(count),
reset: expiry,
};
Ok((Decision::from_allowed(allow), output, input))
}
async fn rollback(&self, token: Self::RollbackToken) -> Result<(), Self::Error> {
self.map.entry(token).and_modify(|v| {
v.count = v.count.saturating_sub(1);
});
Ok(())
}
}
impl Drop for LemmyBackend {
fn drop(&mut self) {
if let Some(handle) = &self.gc_handle {
handle.abort();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
error::LemmyResult,
rate_limit::{ActionType, input::raw_ip_key},
};
use enum_map::enum_map;
const MINUTE_SECS: u32 = 60;
const MINUTE: Duration = Duration::from_secs(60);
fn test_config(interval: u32, max_requests: u32) -> EnumMap<ActionType, BucketConfig> {
enum_map! {
ActionType::Message => BucketConfig {
max_requests,
interval
},
ActionType::Post => BucketConfig {
max_requests: 1,
interval: 120,
},
ActionType::Register => BucketConfig {
max_requests: 0,
interval: 0,
},
ActionType::Image => BucketConfig {
max_requests: 0,
interval: 0,
},
ActionType::Comment => BucketConfig {
max_requests: 0,
interval: 0,
},
ActionType::Search => BucketConfig {
max_requests: 0,
interval: 0,
},
ActionType::ImportUserSettings => BucketConfig {
max_requests: 0,
interval: 0,
},
}
}
#[actix_web::test]
async fn test_allow_deny() -> LemmyResult<()> {
tokio::time::pause();
let backend = LemmyBackend::new(test_config(MINUTE_SECS, 5), true);
let key = raw_ip_key(Some("127.0.0.2"));
let input = LemmyInput(key, ActionType::Message);
for _ in 0..5 {
let (allow, _, _) = backend.request(input).await?;
assert!(allow.is_allowed());
}
let (allow, _, _) = backend.request(input).await?;
assert!(!allow.is_allowed());
Ok(())
}
#[actix_web::test]
async fn test_reset() -> LemmyResult<()> {
tokio::time::pause();
let backend = LemmyBackend::new(test_config(MINUTE_SECS, 1), false);
let input = LemmyInput(raw_ip_key(Some("127.0.0.3")), ActionType::Message);
let (decision, _, _) = backend.request(input).await?;
assert!(decision.is_allowed());
let (decision, _, _) = backend.request(input).await?;
assert!(decision.is_denied());
tokio::time::advance(MINUTE).await;
assert!(backend.map.contains_key(&input));
let (decision, _, _) = backend.request(input).await?;
assert!(decision.is_allowed());
Ok(())
}
#[actix_web::test]
async fn test_garbage_collection() -> LemmyResult<()> {
tokio::time::pause();
let backend = LemmyBackend::new(test_config(MINUTE_SECS, 1), true);
let key1 = LemmyInput(raw_ip_key(Some("127.0.0.4")), ActionType::Message);
let key2 = LemmyInput(raw_ip_key(Some("127.0.0.5")), ActionType::Post);
backend.request(key1).await?;
backend.request(key2).await?;
assert!(backend.map.contains_key(&key1));
assert!(backend.map.contains_key(&key2));
tokio::time::advance(MINUTE).await;
assert!(!backend.map.contains_key(&key1));
assert!(backend.map.contains_key(&key2));
Ok(())
}
#[actix_web::test]
async fn test_output() -> LemmyResult<()> {
tokio::time::pause();
let backend = LemmyBackend::new(test_config(MINUTE_SECS, 2), true);
let key = raw_ip_key(Some("127.0.0.6"));
let input = LemmyInput(key, ActionType::Message);
let (decision, output, _) = backend.request(input).await?;
assert!(decision.is_allowed());
assert_eq!(output.remaining, 1);
assert_eq!(output.limit, 2);
assert_eq!(output.reset, Instant::now() + MINUTE);
let (decision, output, _) = backend.request(input).await?;
assert!(decision.is_allowed());
assert_eq!(output.remaining, 0);
assert_eq!(output.limit, 2);
assert_eq!(output.reset, Instant::now() + MINUTE);
let (decision, output, _) = backend.request(input).await?;
assert!(decision.is_denied());
assert_eq!(output.remaining, 0);
assert_eq!(output.limit, 2);
assert_eq!(output.reset, Instant::now() + MINUTE);
Ok(())
}
#[actix_web::test]
async fn test_rollback() -> LemmyResult<()> {
tokio::time::pause();
let backend = LemmyBackend::new(test_config(MINUTE_SECS, 5), true);
let key = raw_ip_key(Some("127.0.0.7"));
let input = LemmyInput(key, ActionType::Message);
let (_, output, rollback) = backend.request(input).await?;
assert_eq!(output.remaining, 4);
backend.rollback(rollback).await?;
let (_, output, _) = backend.request(input).await?;
assert_eq!(output.remaining, 4);
Ok(())
}
}