diff --git a/server/src/api/announce.rs b/server/src/api/announce.rs index feef20c..428d3c1 100644 --- a/server/src/api/announce.rs +++ b/server/src/api/announce.rs @@ -9,13 +9,19 @@ pub async fn announce( Json(req): Json, ) -> AppResult> { validate::validate_announce(&req)?; + + if !state.rate.lock().unwrap().check(&req.peer_id) { + return Err(AppError::BadRequest("rate limit exceeded".into())); + } + let db = state.db.lock().unwrap(); - let id = crate::store::insert_file(&db, &req) - .map_err(|e| match e { - rusqlite::Error::SqliteFailure(err, _) if err.code == rusqlite::ErrorCode::ConstraintViolation => { - AppError::BadRequest("duplicate file (hash already announced)".into()) - } - other => AppError::Db(other), - })?; + let id = crate::store::insert_file(&db, &req).map_err(|e| match e { + rusqlite::Error::SqliteFailure(err, _) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + AppError::BadRequest("duplicate file (hash already announced)".into()) + } + other => AppError::Db(other), + })?; Ok(Json(serde_json::json!({ "file_id": id, "ok": true }))) } \ No newline at end of file diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 9358330..e2864df 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod files; pub mod health; pub mod peers; pub mod search; +pub mod stats; pub mod ws; use axum::{routing::{get, post}, Router}; @@ -16,6 +17,7 @@ pub fn router(state: SharedState) -> Router { .route("/files/{file_id}", get(files::get_file)) .route("/announce", post(announce::announce)) .route("/peers/{file_id}", get(peers::peers)) + .route("/stats", get(stats::stats)) .route("/tracker/ws", get(ws::ws_handler)) .with_state(state); diff --git a/server/src/api/stats.rs b/server/src/api/stats.rs new file mode 100644 index 0000000..c2131a0 --- /dev/null +++ b/server/src/api/stats.rs @@ -0,0 +1,18 @@ +use axum::extract::State; +use axum::Json; +use crate::error::AppResult; +use crate::state::SharedState; + +pub async fn stats(State(state): State) -> AppResult> { + let db = state.db.lock().unwrap(); + let files: i64 = db.query_row("SELECT count(*) FROM files", [], |r| r.get(0))?; + let bytes: i64 = db.query_row("SELECT COALESCE(SUM(size_bytes),0) FROM files", [], |r| r.get(0))?; + let t = state.tracker.lock().unwrap(); + let online_peers = t.online_total(); + Ok(Json(serde_json::json!({ + "files": files, + "total_bytes": bytes, + "online_peers": online_peers, + "version": env!("CARGO_PKG_VERSION"), + }))) +} \ No newline at end of file diff --git a/server/src/lib.rs b/server/src/lib.rs index 5da36b9..c5b971a 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod config; pub mod error; pub mod models; +pub mod rate; pub mod search; pub mod state; pub mod store; diff --git a/server/src/rate.rs b/server/src/rate.rs new file mode 100644 index 0000000..0d3adca --- /dev/null +++ b/server/src/rate.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +pub struct RateLimiter { + window: Duration, + max: u32, + hits: HashMap, +} + +impl RateLimiter { + pub fn new(per_second: u32) -> Self { + Self { + window: Duration::from_secs(1), + max: per_second, + hits: HashMap::new(), + } + } + + pub fn check(&mut self, key: &str) -> bool { + let now = Instant::now(); + let entry = self.hits.entry(key.to_string()).or_insert((now, 0)); + if now.duration_since(entry.0) > self.window { + *entry = (now, 0); + } + entry.1 += 1; + entry.1 <= self.max + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_until_limit() { + let mut rl = RateLimiter::new(2); + assert!(rl.check("k")); + assert!(rl.check("k")); + assert!(!rl.check("k")); + } + + #[test] + fn keys_are_isolated() { + let mut rl = RateLimiter::new(1); + assert!(rl.check("a")); + assert!(rl.check("b")); + assert!(!rl.check("a")); + } + + #[test] + fn resets_after_window() { + let mut rl = RateLimiter::new(1); + assert!(rl.check("k")); + assert!(!rl.check("k")); + rl.hits.get_mut("k").unwrap().0 = Instant::now() - Duration::from_secs(2); + assert!(rl.check("k")); + } +} \ No newline at end of file diff --git a/server/src/state.rs b/server/src/state.rs index 8c197f5..6f6372a 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,6 +1,7 @@ use std::sync::{Arc, Mutex}; use rusqlite::Connection; use crate::config::Config; +use crate::rate::RateLimiter; use crate::ws::tracker::PeerTracker; pub type DbConn = Mutex; @@ -9,6 +10,7 @@ pub struct AppState { pub config: Config, pub db: DbConn, pub tracker: Mutex, + pub rate: Mutex, } impl AppState { @@ -17,6 +19,7 @@ impl AppState { config, db: Mutex::new(db), tracker: Mutex::new(PeerTracker::default()), + rate: Mutex::new(RateLimiter::new(10)), }) } } diff --git a/server/src/store.rs b/server/src/store.rs index 772d1cf..fdeabea 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -87,22 +87,20 @@ pub fn search_files( FROM files f", ); let mut params: Vec> = Vec::new(); - let mut where_added = false; + let mut clauses: Vec = Vec::new(); if let Some(query) = crate::search::fts_query(q) { - sql.push_str(" JOIN files_fts ON files_fts.rowid = f.rowid WHERE files_fts MATCH ?1"); + sql.push_str(" JOIN files_fts ON files_fts.rowid = f.rowid"); + clauses.push("files_fts MATCH ?".to_string()); params.push(Box::new(query)); - where_added = true; } if let Some(ft) = ftype { - if !where_added { - sql.push_str(" WHERE"); - where_added = true; - } else { - sql.push_str(" AND"); - } params.push(Box::new(ft.to_string())); - sql.push_str(&format!(" f.ftype = ?{}", params.len())); + clauses.push(format!("f.ftype = ?{}", params.len())); + } + if !clauses.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&clauses.join(" AND ")); } params.push(Box::new(limit)); sql.push_str(&format!(" ORDER BY f.created_at DESC LIMIT ?{}", params.len())); diff --git a/server/tests/stats.rs b/server/tests/stats.rs new file mode 100644 index 0000000..c12ed59 --- /dev/null +++ b/server/tests/stats.rs @@ -0,0 +1,39 @@ +mod common; + +use common::{announce_test_file, test_server}; + +#[tokio::test] +async fn stats_reflects_files() { + let server = test_server(); + announce_test_file(&server, "a.ogg", "audio", &"a1".repeat(32)).await; + announce_test_file(&server, "b.ogg", "audio", &"a2".repeat(32)).await; + announce_test_file(&server, "c.pdf", "document", &"a3".repeat(32)).await; + let res = server.get("/api/v1/stats").await; + assert_eq!(res.status_code(), 200); + let body = res.json::(); + assert_eq!(body["files"], 3); + assert_eq!(body["total_bytes"], 300); +} + +#[tokio::test] +async fn announce_rate_limited() { + let server = test_server(); + // 10 por segundo no RateLimiter padrĂ£o; dispara 12 + let mut last_status = axum::http::StatusCode::OK; + for i in 0..12 { + let res = server.post("/api/v1/announce") + .json(&serde_json::json!({ + "peer_id": "ral", + "name": format!("f{i}.ogg"), + "description": "", + "type": "audio", + "extension": "ogg", + "size_bytes": 10, + "hash": format!("{:02x}", i).repeat(32), + "license": "authored" + })) + .await; + last_status = res.status_code(); + } + assert_eq!(last_status, axum::http::StatusCode::BAD_REQUEST); +} \ No newline at end of file