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