diff --git a/server/src/api/announce.rs b/server/src/api/announce.rs new file mode 100644 index 0000000..feef20c --- /dev/null +++ b/server/src/api/announce.rs @@ -0,0 +1,21 @@ +use axum::{extract::State, Json}; +use crate::error::{AppError, AppResult}; +use crate::models::AnnounceRequest; +use crate::state::SharedState; +use crate::validate; + +pub async fn announce( + State(state): State, + Json(req): Json, +) -> AppResult> { + validate::validate_announce(&req)?; + 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), + })?; + Ok(Json(serde_json::json!({ "file_id": id, "ok": true }))) +} \ No newline at end of file diff --git a/server/src/api/files.rs b/server/src/api/files.rs new file mode 100644 index 0000000..fe8b8a7 --- /dev/null +++ b/server/src/api/files.rs @@ -0,0 +1,15 @@ +use axum::extract::{Path, State}; +use axum::Json; +use crate::error::{AppError, AppResult}; +use crate::models::FileRecord; +use crate::state::SharedState; + +pub async fn get_file( + State(state): State, + Path(file_id): Path, +) -> AppResult> { + let db = state.db.lock().unwrap(); + crate::store::get_file(&db, &file_id) + .map(Json) + .map_err(|_| AppError::NotFound("file not found".into())) +} \ No newline at end of file diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 69c5401..e1db23f 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -1,12 +1,16 @@ +pub mod announce; +pub mod files; pub mod health; -use axum::{routing::get, Router}; +use axum::{routing::{get, post}, Router}; use tower_http::cors::CorsLayer; use crate::state::SharedState; pub fn router(state: SharedState) -> Router { let api = Router::new() .route("/health", get(health::health)) + .route("/files/{file_id}", get(files::get_file)) + .route("/announce", post(announce::announce)) .with_state(state); Router::new() diff --git a/server/src/lib.rs b/server/src/lib.rs index 5f0bdc8..8b90735 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,8 +1,10 @@ pub mod api; pub mod config; pub mod error; +pub mod models; pub mod state; pub mod store; +pub mod validate; use axum::Router; use crate::config::Config; diff --git a/server/src/models.rs b/server/src/models.rs new file mode 100644 index 0000000..695cb27 --- /dev/null +++ b/server/src/models.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +pub const FILE_TYPES: [&str; 5] = ["audio", "video", "document", "software", "other"]; + +#[derive(Serialize, Clone, Debug)] +pub struct FileRecord { + pub id: String, + pub peer_id: String, + pub name: String, + pub description: String, + pub ftype: String, + pub extension: String, + pub size_bytes: i64, + pub hash: String, + pub license: String, + pub tags: Vec, + pub created_at: i64, +} + +#[derive(Deserialize, Debug)] +pub struct AnnounceRequest { + pub peer_id: String, + pub name: String, + pub description: String, + #[serde(rename = "type")] + pub ftype: String, + pub extension: String, + pub size_bytes: i64, + pub hash: String, + pub license: String, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct SearchQuery { + pub q: Option, + pub ftype: Option, + pub sort: Option, + pub limit: Option, + pub offset: Option, +} + +impl FileRecord { + pub(crate) fn from_row(r: &rusqlite::Row) -> rusqlite::Result { + let tags: String = r.get(9)?; + Ok(Self { + id: r.get(0)?, + peer_id: r.get(1)?, + name: r.get(2)?, + description: r.get(3)?, + ftype: r.get(4)?, + extension: r.get(5)?, + size_bytes: r.get(6)?, + hash: r.get(7)?, + license: r.get(8)?, + tags: serde_json::from_str(&tags).unwrap_or_default(), + created_at: r.get(10)?, + }) + } +} \ No newline at end of file diff --git a/server/src/store.rs b/server/src/store.rs index 9c2ebe9..4324767 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -1,6 +1,7 @@ -use rusqlite::Connection; +use rusqlite::{params, Connection}; use crate::config::Config; use crate::error::AppResult; +use crate::models::{AnnounceRequest, FileRecord}; pub fn open(cfg: &Config) -> AppResult { if let Some(parent) = std::path::Path::new(&cfg.db_path).parent() { @@ -47,4 +48,28 @@ pub fn migrate(db: &Connection) -> rusqlite::Result<()> { END; "#, ) +} + +pub fn insert_file(db: &Connection, req: &AnnounceRequest) -> rusqlite::Result { + let id = format!("{}::{}", req.peer_id, &req.hash[..12]); + let tags_json = serde_json::to_string(&req.tags).unwrap_or_else(|_| "[]".into()); + db.execute( + "INSERT INTO files (id, peer_id, name, description, ftype, extension, size_bytes, hash, license, tags, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + id, req.peer_id, req.name, req.description, req.ftype, req.extension, + req.size_bytes, req.hash, req.license, tags_json, + chrono::Utc::now().timestamp(), + ], + )?; + Ok(id) +} + +pub fn get_file(db: &Connection, file_id: &str) -> rusqlite::Result { + db.query_row( + "SELECT id, peer_id, name, description, ftype, extension, size_bytes, hash, license, tags, created_at + FROM files WHERE id = ?1", + [file_id], + FileRecord::from_row, + ) } \ No newline at end of file diff --git a/server/src/validate.rs b/server/src/validate.rs new file mode 100644 index 0000000..bd5649c --- /dev/null +++ b/server/src/validate.rs @@ -0,0 +1,48 @@ +use crate::error::AppError; +use crate::models::{AnnounceRequest, FILE_TYPES}; + +pub const BANNED_EXT: [&str; 12] = [ + "exe", "bat", "cmd", "msi", "scr", "ps1", "vbs", "js", "jar", "apk", "dll", "sh", +]; + +const MAX_NAME: usize = 255; +const MAX_TAGS: usize = 20; +const MAX_TAG_LEN: usize = 32; +const MAX_PEER_ID: usize = 128; + +pub fn validate_announce(req: &AnnounceRequest) -> Result<(), AppError> { + if req.name.trim().is_empty() || req.name.len() > MAX_NAME { + return Err(AppError::BadRequest("invalid name".into())); + } + if !FILE_TYPES.contains(&req.ftype.as_str()) { + return Err(AppError::BadRequest(format!("unknown type '{}'", req.ftype))); + } + if !req.extension.is_empty() { + let ext = req.extension.to_lowercase().trim_start_matches('.').to_string(); + if BANNED_EXT.contains(&ext.as_str()) { + return Err(AppError::BadRequest(format!("extension '{ext}' not allowed"))); + } + } + if req.size_bytes < 0 { + return Err(AppError::BadRequest("invalid size".into())); + } + if !is_64_hex(&req.hash) { + return Err(AppError::BadRequest("hash must be 64 hex chars".into())); + } + if req.peer_id.trim().is_empty() || req.peer_id.len() > MAX_PEER_ID { + return Err(AppError::BadRequest("invalid peer_id".into())); + } + if req.tags.len() > MAX_TAGS { + return Err(AppError::BadRequest("too many tags".into())); + } + for t in &req.tags { + if t.len() > MAX_TAG_LEN { + return Err(AppError::BadRequest("tag too long".into())); + } + } + Ok(()) +} + +pub fn is_64_hex(s: &str) -> bool { + s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) +} \ No newline at end of file diff --git a/server/tests/announce.rs b/server/tests/announce.rs new file mode 100644 index 0000000..c4d3a9b --- /dev/null +++ b/server/tests/announce.rs @@ -0,0 +1,107 @@ +mod common; + +use common::test_server; +use serde_json::json; + +#[tokio::test] +async fn announce_valid_ok() { + let server = test_server(); + let res = server.post("/api/v1/announce") + .json(&json!({ + "peer_id": "peer123", + "name": "minha-musica.ogg", + "description": "minha composicao", + "type": "audio", + "extension": "ogg", + "size_bytes": 1024, + "hash": "ab".repeat(32), + "license": "authored", + "tags": ["musica"] + })) + .await; + assert_eq!(res.status_code(), 200); + assert_eq!(res.json::()["ok"], true); +} + +#[tokio::test] +async fn announce_invalid_name_rejected() { + let server = test_server(); + let res = server.post("/api/v1/announce") + .json(&json!({ + "peer_id": "peer123", + "name": "", + "description": "", + "type": "audio", + "extension": "ogg", + "size_bytes": 10, + "hash": "ab".repeat(32), + "license": "authored" + })) + .await; + assert_eq!(res.status_code(), 400); +} + +#[tokio::test] +async fn announce_bad_hash_rejected() { + let server = test_server(); + let res = server.post("/api/v1/announce") + .json(&json!({ + "peer_id": "peer123", + "name": "arquivo.ogg", + "description": "", + "type": "audio", + "extension": "ogg", + "size_bytes": 10, + "hash": "ab", + "license": "authored" + })) + .await; + assert_eq!(res.status_code(), 400); +} + +#[tokio::test] +async fn announce_duplicate_hash_rejected() { + let server = test_server(); + let body = json!({ + "peer_id": "peer123", + "name": "musica.ogg", + "description": "", + "type": "audio", + "extension": "ogg", + "size_bytes": 100, + "hash": "ab".repeat(32), + "license": "authored" + }); + server.post("/api/v1/announce").json(&body).await; + let res = server.post("/api/v1/announce").json(&body).await; + assert_eq!(res.status_code(), 400); +} + +#[tokio::test] +async fn get_file_roundtrip() { + let server = test_server(); + let hash = "cd".repeat(32); + server.post("/api/v1/announce") + .json(&json!({ + "peer_id": "peer1", + "name": "doc.pdf", + "description": "", + "type": "document", + "extension": "pdf", + "size_bytes": 100, + "hash": hash, + "license": "authored" + })) + .await; + let file_id = format!("peer1::{}", &hash[..12]); + let res = server.get(&format!("/api/v1/files/{file_id}")).await; + assert_eq!(res.status_code(), 200); + assert_eq!(res.json::()["name"], "doc.pdf"); +} + +#[tokio::test] +async fn get_file_missing_returns_404() { + let server = test_server(); + let res = server.get("/api/v1/files/nao-existe").await; + assert_eq!(res.status_code(), 404); +} \ No newline at end of file diff --git a/server/tests/common.rs b/server/tests/common.rs new file mode 100644 index 0000000..ecbcfae --- /dev/null +++ b/server/tests/common.rs @@ -0,0 +1,34 @@ +use axum_test::TestServer; +use ares_server::config::Config; +use ares_server::state::AppState; +use ares_server::store; +use ares_server::router; + +static TEST_DB_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +pub fn test_server() -> TestServer { + let n = TEST_DB_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let db_path = std::env::temp_dir() + .join(format!("ares_test_{}_{}.db", std::process::id(), n)) + .to_string_lossy() + .into_owned(); + let cfg = Config { db_path, ..Default::default() }; + let conn = store::open(&cfg).unwrap(); + let state = AppState::new(cfg, conn); + TestServer::new(router(state)) +} + +pub async fn announce_test_file(server: &TestServer, name: &str, ftype: &str, hash: &str) { + server.post("/api/v1/announce") + .json(&serde_json::json!({ + "peer_id": "peer9", + "name": name, + "description": "", + "type": ftype, + "extension": name.rsplit('.').next().unwrap_or("bin"), + "size_bytes": 100, + "hash": hash, + "license": "authored" + })) + .await; +} \ No newline at end of file