diff --git a/docs/superpowers/plans/2026-08-05-ares-backend-phase1.md b/docs/superpowers/plans/2026-08-05-ares-backend-phase1.md new file mode 100644 index 0000000..92586d7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-ares-backend-phase1.md @@ -0,0 +1,1225 @@ +# 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. \ No newline at end of file