diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index e1db23f..a0b86d0 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -1,6 +1,7 @@ pub mod announce; pub mod files; pub mod health; +pub mod search; use axum::{routing::{get, post}, Router}; use tower_http::cors::CorsLayer; @@ -9,6 +10,7 @@ use crate::state::SharedState; pub fn router(state: SharedState) -> Router { let api = Router::new() .route("/health", get(health::health)) + .route("/search", get(search::search)) .route("/files/{file_id}", get(files::get_file)) .route("/announce", post(announce::announce)) .with_state(state); diff --git a/server/src/api/search.rs b/server/src/api/search.rs new file mode 100644 index 0000000..551717f --- /dev/null +++ b/server/src/api/search.rs @@ -0,0 +1,22 @@ +use axum::extract::{Query, State}; +use axum::Json; +use crate::error::AppResult; +use crate::models::{FileRecord, SearchQuery}; +use crate::state::SharedState; + +pub async fn search( + State(state): State, + Query(q): Query, +) -> AppResult>> { + let db = state.db.lock().unwrap(); + let limit = q.limit.unwrap_or(state.config.max_results).clamp(1, 100); + let offset = q.offset.unwrap_or(0).max(0); + let rows = crate::store::search_files( + &db, + q.q.as_deref().unwrap_or(""), + q.ftype.as_deref(), + limit, + offset, + )?; + Ok(Json(rows)) +} \ No newline at end of file diff --git a/server/src/lib.rs b/server/src/lib.rs index 8b90735..67c8ae0 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 search; pub mod state; pub mod store; pub mod validate; diff --git a/server/src/search.rs b/server/src/search.rs new file mode 100644 index 0000000..93f3915 --- /dev/null +++ b/server/src/search.rs @@ -0,0 +1,13 @@ +pub fn fts_query(q: &str) -> Option { + let terms: Vec = q + .split_whitespace() + .map(|t| t.trim_matches(['"', '\'', '*', '(', ')', ':', '[', ']'])) + .filter(|t| !t.is_empty()) + .map(|t| format!("\"{}\"", t)) + .collect(); + if terms.is_empty() { + None + } else { + Some(terms.join(" AND ")) + } +} \ No newline at end of file diff --git a/server/src/store.rs b/server/src/store.rs index 4324767..772d1cf 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -72,4 +72,47 @@ pub fn get_file(db: &Connection, file_id: &str) -> rusqlite::Result [file_id], FileRecord::from_row, ) +} + +pub fn search_files( + db: &Connection, + q: &str, + ftype: Option<&str>, + limit: i64, + offset: i64, +) -> rusqlite::Result> { + let mut sql = String::from( + "SELECT f.id, f.peer_id, f.name, f.description, f.ftype, f.extension, + f.size_bytes, f.hash, f.license, f.tags, f.created_at + FROM files f", + ); + let mut params: Vec> = Vec::new(); + let mut where_added = false; + + 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"); + 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())); + } + params.push(Box::new(limit)); + sql.push_str(&format!(" ORDER BY f.created_at DESC LIMIT ?{}", params.len())); + params.push(Box::new(offset)); + sql.push_str(&format!(" OFFSET ?{}", params.len())); + + let mut stmt = db.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())), + FileRecord::from_row, + )?; + rows.collect() } \ No newline at end of file diff --git a/server/tests/search.rs b/server/tests/search.rs new file mode 100644 index 0000000..3240595 --- /dev/null +++ b/server/tests/search.rs @@ -0,0 +1,60 @@ +mod common; + +use common::{announce_test_file, test_server}; +use serde_json::json; + +#[tokio::test] +async fn search_by_name_filters() { + let server = test_server(); + for (name, ty, hash) in [ + ("musica-bossa.ogg", "audio", "a1".repeat(32)), + ("musica-rock.mp3", "audio", "a2".repeat(32)), + ("ebook-p2p.pdf", "document", "a3".repeat(32)), + ] { + announce_test_file(&server, name, ty, &hash).await; + } + let res = server.get("/api/v1/search?q=musica").await; + assert_eq!(res.status_code(), 200); + let items = res.json::>(); + assert_eq!(items.len(), 2); +} + +#[tokio::test] +async fn search_filter_by_type() { + let server = test_server(); + for (name, ty, hash) in [ + ("musica-bossa.ogg", "audio", "a1".repeat(32)), + ("musica-rock.mp3", "audio", "a2".repeat(32)), + ("ebook-p2p.pdf", "document", "a3".repeat(32)), + ] { + announce_test_file(&server, name, ty, &hash).await; + } + let res = server.get("/api/v1/search?q=musica&ftype=audio").await; + let items = res.json::>(); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["ftype"], "audio"); +} + +#[tokio::test] +async fn search_pagination() { + let server = test_server(); + for (name, ty, hash) in [ + ("musica-bossa.ogg", "audio", "a1".repeat(32)), + ("musica-rock.mp3", "audio", "a2".repeat(32)), + ("ebook-p2p.pdf", "document", "a3".repeat(32)), + ] { + announce_test_file(&server, name, ty, &hash).await; + } + let res = server.get("/api/v1/search?q=&limit=1&offset=0").await; + let items = res.json::>(); + assert_eq!(items.len(), 1); +} + +#[tokio::test] +async fn search_empty_results() { + let server = test_server(); + let res = server.get("/api/v1/search?q=inexistente").await; + assert_eq!(res.status_code(), 200); + let items = res.json::>(); + assert_eq!(items.len(), 0); +} \ No newline at end of file diff --git a/server/tests/search_fts.rs b/server/tests/search_fts.rs new file mode 100644 index 0000000..5515d17 --- /dev/null +++ b/server/tests/search_fts.rs @@ -0,0 +1,28 @@ +use ares_server::search::fts_query; + +#[test] +fn empty_query_returns_none() { + assert_eq!(fts_query(""), None); + assert_eq!(fts_query(" "), None); +} + +#[test] +fn single_term_wrapped_in_quotes() { + assert_eq!(fts_query("musica").unwrap(), "\"musica\""); +} + +#[test] +fn multiple_terms_are_and() { + assert_eq!(fts_query("bossa nova").unwrap(), "\"bossa\" AND \"nova\""); +} + +#[test] +fn non_whitespace_tokens_split_kept() { + // tokens are split by whitespace; edge chars (like *) are stripped + assert_eq!(fts_query("*ideal* fluido*").unwrap(), "\"ideal\" AND \"fluido\""); +} + +#[test] +fn fts_special_single_char_stripped_at_edges() { + assert_eq!(fts_query("album' rock").unwrap(), "\"album\" AND \"rock\""); +} \ No newline at end of file