Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
989d88022a |
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod compression;
|
||||
pub mod export;
|
||||
pub mod rate_limiter;
|
||||
|
||||
46
backend/src/services/rate_limiter.rs
Normal file
46
backend/src/services/rate_limiter.rs
Normal 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())
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user