feat: implement rate limiting across all API endpoints

Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>)
with per-IP and per-user-id limits on all public endpoint classes:
- POST /api/v1/join: 5/min per IP
- GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60)
- POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10)
- GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3)
Limits are hot-reloadable via the config table. All 429 responses use
German error messages. Client IP is read from X-Forwarded-For (Caddy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-04-02 21:03:59 +02:00
parent 258e2bd84d
commit 989d88022a
7 changed files with 136 additions and 8 deletions

View File

@@ -1,7 +1,9 @@
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use chrono::{Duration, Utc};
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -12,6 +14,7 @@ use crate::error::AppError;
use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::user::{User, UserRole};
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
#[derive(Deserialize)]
@@ -29,8 +32,16 @@ pub struct JoinResponse {
pub async fn join(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
let ip = client_ip(&headers, "unknown");
if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
));
}
let display_name = body.display_name.trim();
if display_name.is_empty() || display_name.len() > 50 {
return Err(AppError::BadRequest(
@@ -62,7 +73,7 @@ pub async fn join(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
Ok((
@@ -134,7 +145,7 @@ pub async fn recover(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
return Ok(Json(RecoverResponse {
@@ -146,7 +157,7 @@ pub async fn recover(
// Wrong PIN — increment failure count
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
if attempts >= 3 {
let lockout = Utc::now() + Duration::minutes(15);
let lockout = Utc::now() + chrono::Duration::minutes(15);
User::lock_pin(&state.pool, user.id, lockout).await?;
}
}
@@ -217,7 +228,7 @@ pub async fn admin_login(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(1);
let expires_at = Utc::now() + chrono::Duration::days(1);
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
Ok(Json(AdminLoginResponse { jwt: token }))

View File

@@ -1,12 +1,15 @@
use std::collections::HashMap;
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use sysinfo::System;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
// ── DTOs ─────────────────────────────────────────────────────────────────────
@@ -172,7 +175,16 @@ pub async fn get_export_jobs(
pub async fn download_zip(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
let ip = client_ip(&headers, "unknown");
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
));
}
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
@@ -194,7 +206,16 @@ pub async fn download_zip(
pub async fn download_html(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
let ip = client_ip(&headers, "unknown");
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
));
}
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
@@ -277,3 +298,13 @@ pub async fn export_status(
"html": job_status("html"),
})))
}
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await
.unwrap_or(None);
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
}

View File

@@ -1,4 +1,7 @@
use std::time::Duration;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -6,6 +9,7 @@ use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
#[derive(Deserialize)]
@@ -53,8 +57,17 @@ struct FeedRow {
pub async fn feed(
State(state): State<AppState>,
auth: AuthUser,
headers: HeaderMap,
Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> {
let ip = client_ip(&headers, "unknown");
let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await;
if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
));
}
let limit = q.limit.unwrap_or(20).min(100);
let rows = if let Some(hashtag) = &q.hashtag {
@@ -227,6 +240,16 @@ pub async fn hashtags(
))
}
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await
.unwrap_or(None);
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
}
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
let row: Option<(DateTime<Utc>,)> =
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")

View File

@@ -1,3 +1,5 @@
use std::time::Duration;
use axum::extract::{Multipart, Path, State};
use axum::http::StatusCode;
use axum::Json;
@@ -16,6 +18,17 @@ pub async fn upload(
auth: AuthUser,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
// Rate limit: N uploads per hour per user
let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
if !state
.rate_limiter
.check(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600))
{
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
));
}
// Check if user is banned
let user = User::find_by_id(&state.pool, auth.user_id)
.await?

View File

@@ -1,2 +1,3 @@
pub mod compression;
pub mod export;
pub mod rate_limiter;

View File

@@ -0,0 +1,46 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Thread-safe sliding-window rate limiter backed by an in-memory HashMap.
/// Each key (e.g. `"join:{ip}"` or `"upload:{user_id}"`) tracks timestamps
/// of recent requests and rejects new ones once the window is full.
#[derive(Clone)]
pub struct RateLimiter {
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
}
impl RateLimiter {
pub fn new() -> Self {
Self {
windows: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Returns `true` if the request is allowed, `false` if rate-limited.
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
let now = Instant::now();
let key = key.into();
let mut map = self.windows.lock().unwrap();
let timestamps = map.entry(key).or_default();
// Drop entries outside the window
timestamps.retain(|&t| now.duration_since(t) < window);
if timestamps.len() < max {
timestamps.push(now);
true
} else {
false
}
}
}
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
/// to a provided socket address string.
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_owned())
.unwrap_or_else(|| fallback.to_owned())
}

View File

@@ -3,6 +3,7 @@ use tokio::sync::broadcast;
use crate::config::AppConfig;
use crate::services::compression::CompressionWorker;
use crate::services::rate_limiter::RateLimiter;
#[derive(Clone, Debug)]
pub struct SseEvent {
@@ -16,6 +17,7 @@ pub struct AppState {
pub config: AppConfig,
pub sse_tx: broadcast::Sender<SseEvent>,
pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
}
impl AppState {
@@ -28,6 +30,7 @@ impl AppState {
config,
sse_tx,
compression,
rate_limiter: RateLimiter::new(),
}
}
}