feat(server): announce com validacao e persistencia + files por id
This commit is contained in:
@@ -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<SharedState>,
|
||||||
|
Json(req): Json<AnnounceRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
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 })))
|
||||||
|
}
|
||||||
@@ -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<SharedState>,
|
||||||
|
Path(file_id): Path<String>,
|
||||||
|
) -> AppResult<Json<FileRecord>> {
|
||||||
|
let db = state.db.lock().unwrap();
|
||||||
|
crate::store::get_file(&db, &file_id)
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|_| AppError::NotFound("file not found".into()))
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
pub mod announce;
|
||||||
|
pub mod files;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::{get, post}, Router};
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
|
||||||
pub fn router(state: SharedState) -> Router {
|
pub fn router(state: SharedState) -> Router {
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
.route("/health", get(health::health))
|
.route("/health", get(health::health))
|
||||||
|
.route("/files/{file_id}", get(files::get_file))
|
||||||
|
.route("/announce", post(announce::announce))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod models;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
pub mod validate;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub ftype: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileRecord {
|
||||||
|
pub(crate) fn from_row(r: &rusqlite::Row) -> rusqlite::Result<Self> {
|
||||||
|
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)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-1
@@ -1,6 +1,7 @@
|
|||||||
use rusqlite::Connection;
|
use rusqlite::{params, Connection};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::error::AppResult;
|
use crate::error::AppResult;
|
||||||
|
use crate::models::{AnnounceRequest, FileRecord};
|
||||||
|
|
||||||
pub fn open(cfg: &Config) -> AppResult<Connection> {
|
pub fn open(cfg: &Config) -> AppResult<Connection> {
|
||||||
if let Some(parent) = std::path::Path::new(&cfg.db_path).parent() {
|
if let Some(parent) = std::path::Path::new(&cfg.db_path).parent() {
|
||||||
@@ -47,4 +48,28 @@ pub fn migrate(db: &Connection) -> rusqlite::Result<()> {
|
|||||||
END;
|
END;
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_file(db: &Connection, req: &AnnounceRequest) -> rusqlite::Result<String> {
|
||||||
|
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<FileRecord> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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::<serde_json::Value>()["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::<serde_json::Value>()["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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user