# Fase 1 — Backend Ares Next — Plano de Implementação > **Para workers agenticos:** SKILL OBRIGATÓRIO: use superpowers:subagent-driven-development (recomendado) ou superpowers:executing-plans para implementar task a task. Passos usam checkbox (`- [ ]`). **Goal:** Backend Rust+axum rodando isolado na VPS Hokma sob `ares.hokmatech.com`, com busca FTS5 (SQLite), anúncio validado, tracker WebSocket e deploy via Coolify — sem afetar outros projetos. **Architecture:** Monobinary axum (health, search, announce, peers, stats) + SQLite FTS5 embutido + tracker WS em memória (peers por file_id). O núcleo P2P (libp2p) fica na Fase 2. **Tech Stack:** Rust 1.95, axum 0.8, tokio, rusqlite (bundled, FTS5), serde, tower-http, tracing, clap, Docker multi-stage, Coolify API via túnel SSH. --- ## Estrutura de arquivos ``` server/ ├── Cargo.toml ├── Dockerfile ├── .dockerignore ├── src/ │ ├── main.rs # bootstrap: config -> app -> serve │ ├── lib.rs # pub fn app(cfg) -> Router (usado por testes) │ ├── config.rs # Config (env) │ ├── error.rs # AppError + IntoResponse │ ├── models.rs # FileRecord, AnnounceRequest, SearchQuery │ ├── state.rs # AppState: Arc> + config │ ├── store.rs # open(), migrate(), insert_file, get_file, search_files │ ├── search.rs # fts_query() sanitizer │ ├── validate.rs # validação de AnnounceRequest │ ├── api/ │ │ ├── mod.rs # router() │ │ ├── health.rs │ │ ├── search.rs │ │ ├── files.rs │ │ ├── announce.rs │ │ ├── peers.rs │ │ └── stats.rs │ └── ws/ │ ├── mod.rs # ws handler (axum) │ └── tracker.rs # PeerTracker (HashMap) └── tests/ ├── health.rs ├── announce.rs ├── search.rs └── tracker_ws.rs ``` **Decisões de design** - SQLite arquivo único `aresnext.db` em `/data/` (volume Docker). Acesso via `Arc>` — suficiente para a Fase 1; evolui para `r2d2`/`deadpool` se necessário. - Hash inteiro: 64 chars hex (BLAKE3). O servidor valida formato/tamanho/coerência, não armazena bytes. - FTS5 com triggers de sincronização; sanitização de query com escape. - Rate limit simples em memória por peer_id (janela fixa). - Tracker WS: `HashMap>`. --- ### Task 1: Scaffold do workspace + health endpoint **Files:** - Create: `server/Cargo.toml` - Create: `server/src/lib.rs` - Create: `server/src/main.rs` - Create: `server/src/config.rs` - Create: `server/src/error.rs` - Create: `server/src/api/mod.rs` - Create: `server/src/api/health.rs` - Create: `server/tests/health.rs` - [ ] **Step 1: escrever `server/Cargo.toml`** ```toml [package] name = "ares-server" version = "0.1.0" edition = "2021" publish = false [dependencies] axum = "0.8" tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower-http = { version = "0.6", features = ["cors", "trace"] } rusqlite = { version = "0.32", features = ["bundled"] } thiserror = "2" [dev-dependencies] axum-test = "16" tempfile = "3" ``` - [ ] **Step 2: escrever `server/src/config.rs`** ```rust use std::env; #[derive(Clone, Debug)] pub struct Config { pub host: String, pub port: u16, pub db_path: String, pub max_body_mb: usize, pub max_results: i64, } impl Default for Config { fn default() -> Self { Self { host: "127.0.0.1".into(), port: 3000, db_path: "data/aresnext.db".into(), max_body_mb: 2, max_results: 50, } } } impl Config { pub fn from_env() -> Self { let def = Self::default(); Self { host: env::var("ARS_NEXT_HOST").unwrap_or(def.host), port: env::var("ARS_NEXT_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(def.port), db_path: env::var("ARS_NEXT_DB").unwrap_or(def.db_path), max_body_mb: env::var("ARS_NEXT_MAX_BODY_MB").ok().and_then(|s| s.parse().ok()).unwrap_or(def.max_body_mb), max_results: env::var("ARS_NEXT_MAX_RESULTS").ok().and_then(|s| s.parse().ok()).unwrap_or(def.max_results), } } pub fn max_body_bytes(&self) -> usize { self.max_body_mb * 1024 * 1024 } } ``` - [ ] **Step 3: escrever `server/src/error.rs`** ```rust use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; use serde_json::json; #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("bad request: {0}")] BadRequest(String), #[error("not found: {0}")] NotFound(String), #[error("database error: {0}")] Db(#[from] rusqlite::Error), #[error("internal: {0}")] Internal(String), } impl AppError { fn status(&self) -> StatusCode { match self { Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::NotFound(_) => StatusCode::NOT_FOUND, Self::Db(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } impl IntoResponse for AppError { fn into_response(self) -> Response { let body = json!({ "error": self.to_string() }); (self.status(), Json(body)).into_response() } } pub type AppResult = Result; ``` - [ ] **Step 4: escrever `server/src/state.rs`** ```rust use std::sync::{Arc, Mutex}; use rusqlite::Connection; use crate::config::Config; use crate::ws::tracker::PeerTracker; pub struct AppState { pub config: Config, pub db: Mutex, pub tracker: PeerTracker, } impl AppState { pub fn new(config: Config, db: Connection) -> Arc { Arc::new(Self { config, db: Mutex::new(db), tracker: PeerTracker::default(), }) } } pub type SharedState = Arc; ``` - [ ] **Step 5: escrever `server/src/lib.rs`** ```rust pub mod config; pub mod error; pub mod models; pub mod state; pub mod store; pub mod search; pub mod validate; pub mod api; pub mod ws; use axum::Router; use crate::config::Config; use crate::state::SharedState; pub async fn build_app(cfg: Config) -> SharedState { let conn = store::open(&cfg).expect("open database"); let state = state::AppState::new(cfg, conn); state } pub fn router(state: SharedState) -> Router { api::router(state) } ``` - [ ] **Step 6: escrever `server/src/main.rs`** ```rust use ares_server::{build_app, router}; use ares_server::config::Config; #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let cfg = Config::from_env(); let addr = format!("{}:{}", cfg.host, cfg.port); let state = build_app(cfg).await; let app = router(state); let listener = tokio::net::TcpListener::bind(&addr).await .expect("bind listener"); tracing::info!("AresNext listening on http://{addr}"); axum::serve(listener, app).await.expect("server"); } ``` - [ ] **Step 7: escrever `server/src/api/mod.rs` (v1)** ```rust pub mod health; pub mod search; pub mod files; pub mod announce; pub mod peers; pub mod stats; pub mod ws; use axum::{routing::get, 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("/search", get(search::search)) .route("/files/{file_id}", get(files::get_file)) .route("/announce", axum::routing::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); Router::new() .nest("/api/v1", api) .layer(CorsLayer::permissive()) } ``` - [ ] **Step 8: escrever `server/src/api/health.rs`** ```rust use axum::Json; use serde_json::{json, Value}; pub async fn health() -> Json { Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION"), "service": "ares-next", })) } ``` - [ ] **Step 9: escrever `server/tests/health.rs`** ```rust use axum_test::TestServer; use ares_server::config::Config; use ares_server::{build_app, router}; use tempfile::tempdir; #[tokio::test] async fn health_returns_ok() { let dir = tempdir().unwrap(); let cfg = Config { db_path: dir.path().join("t.db").to_string_lossy().into(), ..Default::default() }; let state = build_app(cfg).await; let server = TestServer::new(router(state)).await; let res = server.get("/api/v1/health").await; assert_eq!(res.status_code(), 200); assert_eq!(res.json::()["status"], "ok"); } ``` - [ ] **Step 10: rodar testes e compilar** Run: `cargo test --manifest-path server/Cargo.toml` Expected: health passa; scaffold compila. - [ ] **Step 11: commit** ```bash git add server git commit -m "chore(server): scaffold axum com health endpoint e testes" ``` --- ### Task 2: SQLite store com FTS5 + migrações **Files:** - Create: `server/src/store.rs` - Modify: `server/src/lib.rs` (adicionar module se faltar) - Test: `server/tests/store.rs` - [ ] **Step 1: escrever `server/src/store.rs`** ```rust use rusqlite::Connection; use crate::config::Config; use crate::error::AppResult; pub fn open(cfg: &Config) -> AppResult { if let Some(parent) = std::path::Path::new(&cfg.db_path).parent() { std::fs::create_dir_all(parent).ok(); } let db = Connection::open(&cfg.db_path)?; db.pragma_update(None, "journal_mode", "WAL")?; db.pragma_update(None, "synchronous", "NORMAL")?; migrate(&db)?; Ok(db) } pub fn migrate(db: &Connection) -> rusqlite::Result<()> { db.execute_batch( r#" CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, peer_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', ftype TEXT NOT NULL, extension TEXT NOT NULL DEFAULT '', size_bytes INTEGER NOT NULL, hash TEXT NOT NULL UNIQUE, license TEXT NOT NULL DEFAULT 'authored', tags TEXT NOT NULL DEFAULT '[]', created_at INTEGER NOT NULL ); CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5( name, description, tags, content='files', content_rowid='rowid', tokenize='unicode61' ); CREATE TRIGGER IF NOT EXISTS files_ai AFTER INSERT ON files BEGIN INSERT INTO files_fts(rowid, name, description, tags) VALUES (new.rowid, new.name, new.description, new.tags); END; CREATE TRIGGER IF NOT EXISTS files_ad AFTER DELETE ON files BEGIN INSERT INTO files_fts(files_fts, rowid, name, description, tags) VALUES ('delete', old.rowid, old.name, old.description, old.tags); END; "#, ) } ``` > Nota: triggers de update ficam na Fase 3 (edição de metadados ainda não existe). - [ ] **Step 2: escrever `server/tests/store.rs`** ```rust use ares_server::config::Config; use ares_server::store; use tempfile::tempdir; #[test] fn migrate_creates_fts_table() { let dir = tempdir().unwrap(); let cfg = Config { db_path: dir.path().join("t.db").to_string_lossy().into(), ..Default::default() }; let db = store::open(&cfg).expect("open db"); let has_fts: i64 = db .query_row("SELECT count(*) FROM sqlite_master WHERE name='files_fts'", [], |r| r.get(0)) .unwrap(); assert_eq!(has_fts, 1); } ``` - [ ] **Step 3: rodar testes** Run: `cargo test --manifest-path server/Cargo.toml` Expected: store.rs passa. - [ ] **Step 4: commit** ```bash git add server/src/store.rs server/tests/store.rs git commit -m "feat(server): SQLite store com migracoes e FTS5" ``` --- ### Task 3: Modelos + validação de announce + persistência **Files:** - Create: `server/src/models.rs` - Create: `server/src/validate.rs` - Modify: `server/src/store.rs` (insert_file, get_file) - Create: `server/src/api/announce.rs` - Create: `server/src/api/files.rs` - Test: `server/tests/announce.rs` - [ ] **Step 1: `server/src/models.rs`** ```rust 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, } ``` - [ ] **Step 2: `server/src/validate.rs`** ```rust use crate::error::AppError; use crate::models::{AnnounceRequest, FILE_TYPES}; const BANNED_EXT: [&str; 12] = [ "exe", "bat", "cmd", "msi", "scr", "ps1", "vbs", "js", "jar", "apk", "dll", "sh", ]; pub fn validate_announce(req: &AnnounceRequest) -> Result<(), AppError> { if req.name.trim().is_empty() || req.name.len() > 255 { 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() > 128 { return Err(AppError::BadRequest("invalid peer_id".into())); } if req.tags.len() > 20 { return Err(AppError::BadRequest("too many tags".into())); } for t in &req.tags { if t.len() > 32 { 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()) } ``` - [ ] **Step 3: adicionar `insert_file`/`get_file` em `server/src/store.rs`** ```rust use crate::models::FileRecord; use rusqlite::params; pub fn insert_file(db: &Connection, req: &crate::models::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) } ``` > Precisamos de `chrono` — adicionar em Cargo.toml: `chrono = { version = "0.4", default-features = false, features = ["clock"] }`. (Adicionar no Step 0 deste task.) - [ ] **Step 4: `server/src/api/announce.rs`** ```rust use axum::{extract::State, Json}; use crate::error::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)?; Ok(Json(serde_json::json!({ "file_id": id, "ok": true }))) } ``` - [ ] **Step 5: `server/src/api/files.rs`** ```rust use axum::extract::{Path, State}; 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(); let mut stmt = db.prepare( "SELECT id, peer_id, name, description, ftype, extension, size_bytes, hash, license, tags, created_at FROM files WHERE id = ?1", )?; let row = stmt.query_row([&file_id], row_to_record) .map_err(|_| AppError::NotFound("file not found".into()))?; Ok(Json(row)) } fn row_to_record(r: &rusqlite::Row) -> rusqlite::Result { let tags: String = r.get(9)?; Ok(FileRecord { 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)?, }) } ``` - [ ] **Step 6: `server/tests/announce.rs`** ```rust use axum_test::TestServer; use ares_server::config::Config; use ares_server::{build_app, router}; use serde_json::json; use tempfile::tempdir; fn test_server() -> TestServer { let dir = tempdir().unwrap(); std::mem::forget(dir); // keep dir alive during test let cfg = Config { db_path: std::env::temp_dir().join(format!("ares_test_{}.db", std::process::id())), ..Default::default() }; let state = ares_server::state::AppState::new(cfg.clone(), ares_server::store::open(&cfg).unwrap()); TestServer::new(router(state)).await } ``` > Nota: o helper acima é provisório para testes; nos próximos tasks refinamos para uma função única de setup (`tests/common.rs`). - [ ] **Step 7: testes do handler** ```rust #[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_banned_ext_rejected() { let server = test_server(); let res = server.post("/api/v1/announce") .json(&json!({ "peer_id": "peer123", "name": "malware.exe", "description": "", "type": "software", "extension": "exe", "size_bytes": 10, "hash": "ab".repeat(32), "license": "authored" })) .await; assert_eq!(res.status_code(), 400); } #[tokio::test] async fn get_file_roundtrip() { let server = test_server(); server.post("/api/v1/announce") .json(&json!({ "peer_id": "peer1", "name": "doc.pdf", "description": "", "type": "document", "extension": "pdf", "size_bytes": 100, "hash": "cd".repeat(32), "license": "authored" })).await; let file_id = format!("peer1::{}", "cd".repeat(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"); } ``` - [ ] **Step 8: rodar testes** Run: `cargo test --manifest-path server/Cargo.toml` Expected: todos os novos testes passam. - [ ] **Step 9: commit** ```bash git add server git commit -m "feat(server): announce com validacao e persistencia + files por id" ``` --- ### Task 4: Busca FTS5 com filtros e ordenação **Files:** - Create: `server/src/search.rs` - Create: `server/src/api/search.rs` - Test: `server/tests/search.rs` - [ ] **Step 1: `server/src/search.rs` — sanitizer FTS** ```rust pub fn fts_query(q: &str) -> String { 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() { "*".to_string() } else { terms.join(" AND ") } } ``` - [ ] **Step 2: `server/src/store.rs` — search_files** ```rust pub fn search_files( db: &Connection, q: &str, ftype: Option<&str>, limit: i64, offset: i64, ) -> rusqlite::Result> { let query = crate::search::fts_query(q); 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 JOIN files_fts ON files_fts.rowid = f.rowid WHERE files_fts MATCH ?1", ); let mut params: Vec> = vec![Box::new(query)]; if let Some(ft) = ftype { sql.push_str(" AND f.ftype = ?2"); params.push(Box::new(ft.to_string())); } sql.push_str(" ORDER BY f.created_at DESC LIMIT ?3 OFFSET ?4"); params.push(Box::new(limit)); params.push(Box::new(offset)); let mut stmt = db.prepare(&sql)?; let rows = stmt.query_map(rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())), crate::api::files::row_to_record)?; rows.collect() } ``` - [ ] **Step 3: `server/src/api/search.rs`** ```rust 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)) } ``` > `row_to_record` está em `api/files.rs` mas usado em `store.rs` — mudei para `crate::models::row_to_record` (função pública em models.rs). Detalhe: mover `row_to_record` para `models.rs` e tornar `pub(crate)`. Ver passo 4. - [ ] **Step 4: mover `row_to_record` para `models.rs`** ```rust impl FileRecord { pub(crate) fn from_row(r: &rusqlite::Row) -> rusqlite::Result { ... } } ``` e em `api/files.rs` e `store.rs` usar `FileRecord::from_row`. - [ ] **Step 5: `server/tests/search.rs`** ```rust use axum_test::TestServer; use ares_server::config::Config; use ares_server::{build_app, router}; use serde_json::json; use tempfile::tempdir; // helper setup — reutilizar o mesmo padrão dos outros testes async fn seed(server: &TestServer) { 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)), ] { server.post("/api/v1/announce").json(&json!({ "peer_id": "peer9", "name": name, "description": "", "type": ty, "extension": name.rsplit('.').next().unwrap(), "size_bytes": 100, "hash": hash, "license": "authored" })).await; } } #[tokio::test] async fn search_by_name_filters() { let server = test_server(); seed(&server).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(); seed(&server).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(); seed(&server).await; let res = server.get("/api/v1/search?q=&limit=1&offset=0").await; let items = res.json::>(); assert_eq!(items.len(), 1); } ``` - [ ] **Step 6: rodar testes** Run: `cargo test --manifest-path server/Cargo.toml` Expected: 3 novos testes passam. - [ ] **Step 7: commit** ```bash git add server git commit -m "feat(server): busca FTS5 com filtros e paginacao" ``` --- ### Task 5: Tracker WebSocket em tempo real **Files:** - Create: `server/src/ws/mod.rs` - Create: `server/src/ws/tracker.rs` - Create: `server/src/api/ws.rs` - Test: `server/tests/tracker_ws.rs` - [ ] **Step 1: `server/src/ws/tracker.rs`** ```rust use std::collections::HashMap; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; use axum::extract::ws::Message; pub type WsSender = mpsc::UnboundedSender; #[derive(Default)] pub struct PeerTracker { pub by_file: HashMap>, } impl PeerTracker { pub fn register(&mut self, file_id: String, peer_id: String, tx: WsSender) { self.by_file.entry(file_id).or_default().insert(peer_id, tx); } pub fn unregister(&mut self, file_id: &str, peer_id: &str) { if let Some(peers) = self.by_file.get_mut(file_id) { peers.remove(peer_id); if peers.is_empty() { self.by_file.remove(file_id); } } } pub fn peers_of(&self, file_id: &str) -> Vec { self.by_file .get(file_id) .map(|peers| peers.keys().cloned().collect()) .unwrap_or_default() } pub fn broadcast(&self, file_id: &str, msg: &str) { if let Some(peers) = self.by_file.get(file_id) { for tx in peers.values() { let _ = tx.send(Message::Text(msg.to_string())); } } } } ``` > Nota: `PeerTracker` em `AppState` está como `pub tracker: PeerTracker` sem Mutex — precisa de `Mutex` para acesso compartilhado. Ajuste: `pub tracker: std::sync::Mutex`. - [ ] **Step 2: `server/src/ws/mod.rs`** ```rust pub mod tracker; ``` - [ ] **Step 3: `server/src/api/ws.rs`** ```rust use axum::extract::{State, WebSocketUpgrade}; use axum::response::Response; use axum::extract::ws::{Message, WebSocket}; use futures_util::{SinkExt, StreamExt}; use crate::state::SharedState; pub async fn ws_handler( ws: WebSocketUpgrade, State(state): State, ) -> Response { ws.on_upgrade(move |socket| handle_socket(socket, state)) } async fn handle_socket(socket: WebSocket, state: SharedState) { let (mut sender, mut receiver) = socket.split(); let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); let mut recv_stream = UnboundedReceiverStream::new(rx); let mut peer_id = String::new(); let mut file_id = String::new(); // loop de leitura: primeiro frame registra peer/file loop { tokio::select! { msg = receiver.next() => { match msg { Some(Ok(Message::Text(text))) => { if let Ok(parsed) = serde_json::from_str::(&text) { if let (Some(p), Some(f)) = (parsed.get("peer_id"), parsed.get("file_id")) { peer_id = p.as_str().unwrap_or_default().to_string(); file_id = f.as_str().unwrap_or_default().to_string(); let mut t = state.tracker.lock().unwrap(); t.register(file_id.clone(), peer_id.clone(), tx.clone()); } } } Some(Ok(Message::Close(_))) | None => break, _ => {} } } msg = recv_stream.next() => { if let Some(m) = msg { if sender.send(m).await.is_err() { break; } } } } } let mut t = state.tracker.lock().unwrap(); t.unregister(&file_id, &peer_id); } ``` > Precisamos de `futures-util` e `tokio-stream` no Cargo.toml (ver Step 0). - [ ] **Step 4: adicionar deps** Em `server/Cargo.toml`: ```toml futures-util = "0.3" tokio-stream = { version = "0.1", features = ["sync"] } ``` - [ ] **Step 5: `server/src/api/peers.rs`** ```rust use axum::extract::{Path, State}; use axum::Json; use crate::error::AppResult; use crate::state::SharedState; pub async fn peers(State(state): State, Path(file_id): Path) -> AppResult> { let t = state.tracker.lock().unwrap(); let peers = t.peers_of(&file_id); Ok(Json(serde_json::json!({ "file_id": file_id, "peers": peers }))) } ``` - [ ] **Step 6: `server/tests/tracker_ws.rs`** ```rust use axum_test::{TestServer, WebSocket}; use ares_server::config::Config; use ares_server::{build_app, router}; use tempfile::tempdir; #[tokio::test] async fn ws_registers_and_lists_peers() { let dir = tempdir().unwrap(); let cfg = Config { db_path: dir.path().join("t.db").to_string_lossy().into(), ..Default::default() }; let state = build_app(cfg).await; let server = TestServer::new(router(state.clone())).await; let mut ws = server.get_ws("/api/v1/tracker/ws").await.unwrap(); ws.send_text(r#"{"peer_id":"peerA","file_id":"abc123"}"#).await.unwrap(); // espera registro tokio::time::sleep(std::time::Duration::from_millis(100)).await; let res = server.get("/api/v1/peers/abc123").await; let body = res.json::(); assert_eq!(body["peers"][0], "peerA"); } ``` > Nota: API real do axum-test pode diferir em versões; se `get_ws` não existir, usar `tungstenite` direto (implementação alternativa documentada no comentário do teste). - [ ] **Step 7: rodar testes** Run: `cargo test --manifest-path server/Cargo.toml` Expected: teste WS passa (ou adapta a API do axum-test). - [ ] **Step 8: commit** ```bash git add server git commit -m "feat(server): tracker ws em tempo real com registro de peers" ``` --- ### Task 6: Stats + rate limit + segurança de rota **Files:** - Create: `server/src/api/stats.rs` - Modify: `server/src/api/mod.rs` (add rota stats) - Create: `server/src/rate.rs` - [ ] **Step 1: `server/src/rate.rs`** ```rust 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 } } ``` - [ ] **Step 2: `server/src/api/stats.rs`** ```rust 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 peers = state.tracker.lock().unwrap(); let unique_peers: usize = peers.by_file.values().flat_map(|m| m.keys()).collect::>().len(); Ok(Json(serde_json::json!({ "files": files, "total_bytes": bytes, "online_peers": unique_peers, "version": env!("CARGO_PKG_VERSION"), }))) } ``` - [ ] **Step 3: rota stats + rate limit no `api/mod.rs`** Em `api/mod.rs`, adicionar no state `rate: Mutex` (ou aplicar via middleware). Para Fase 1: rate limit no handler de announce usando `state.rate`. - [ ] **Step 4: `server/tests/stats.rs`** ```rust #[tokio::test] async fn stats_reflects_files() { let server = test_server(); seed(&server).await; // 3 arquivos let res = server.get("/api/v1/stats").await; let body = res.json::(); assert_eq!(body["files"], 3); } ``` - [ ] **Step 5: rodar testes** Run: `cargo test --manifest-path server/Cargo.toml` - [ ] **Step 6: commit** ```bash git add server git commit -m "feat(server): stats globais e rate limit por peer" ``` --- ### Task 7: Dockerfile + deploy no Coolify (isolado) **Files:** - Create: `server/Dockerfile` - Create: `server/.dockerignore` - Modify: `README.md` (seção deploy) - [ ] **Step 1: `server/Dockerfile`** ```dockerfile FROM rust:1.85-alpine AS builder WORKDIR /build RUN apk add --no-cache musl-dev build-base COPY Cargo.toml ./ COPY src ./src RUN cargo build --release FROM alpine:3.20 RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --from=builder /build/target/release/ares-server /app/ares-server ENV ARS_NEXT_HOST=0.0.0.0 ENV ARS_NEXT_PORT=3000 EXPOSE 3000 VOLUME ["/data"] CMD ["/app/ares-server"] ``` - [ ] **Step 2: `server/.dockerignore`** ``` target/ data/ *.db ``` - [ ] **Step 3: compilar release local** Run: `cargo build --release --manifest-path server/Cargo.toml` Expected: binário em `server/target/release/ares-server`. - [ ] **Step 4: push para Gitea (git.hokmatech.com)** ```bash git remote add origin git@gitea.hokmatech.com:ares-next/ares-next.git git push -u origin main ``` > Ajuste URL conforme nome de repo criado no Gitea. Se o repositório ainda não existe, criá-lo primeiro (via Gitea API ou UI). - [ ] **Step 5: criar projeto no Coolify via API (túnel SSH)** ```powershell ssh -i id_ed25519 -L 8000:127.0.0.1:8000 hokma@13.140.174.24 # manter aberto ``` Usar API Coolify (docs: https://coolify.io/docs/api-reference): - Criar projeto `ares-next` - Criar aplicação apontando para o repo (Dockerfile) - Configurar domínio `ares.hokmatech.com`, volume `/data` → `/data/aresnext` - HTTPS via Let's Encrypt (ou Cloudflare origin cert) - [ ] **Step 6: DNS no Cloudflare** Registro A: `ares` → `13.140.174.24` (proxy on, pois Coolify/Cloudflare já em uso). - [ ] **Step 7: deploy + smoke test** Run: `curl -s https://ares.hokmatech.com/api/v1/health` Expected: `{"status":"ok","version":"0.1.0","service":"ares-next"}` - [ ] **Step 8: commit docs** ```bash git add README.md git commit -m "docs: instrucoes de deploy no Coolify" ``` --- ## Self-review (a fazer durante execução) 1. **Consistência de tipos:** `AppState.tracker` precisa `Mutex` — ajustado no Task 5 Step 1 nota. 2. **`row_to_record` movido para `FileRecord::from_row`** (models.rs) — atualizar todos os callers (files.rs, store.rs). 3. **Deps adicionais:** chrono (Task 3), futures-util + tokio-stream (Task 5). 4. **Cobertura da spec:** health ✓, search+filter+sort ✓, files ✓, announce+validation ✓, peers ✓, tracker WS ✓, stats ✓, deploy isolado ✓. Fica para Fase 2: relay libp2p, assinatura Ed25519 completa (aceita-se hash apenas na Fase 1; signature será exigida na Fase 2 com crate `identity`). 5. **Pendência conhecida:** verificação de assinatura Ed25519 dos metadados — adiada para Fase 2 (crate `identity`). ## Execução **Opção 1 (recomendada):** subagent-driven — subagent por task + review entre tasks. **Opção 2:** execução inline com checkpoints.