Files
ares-next/docs/superpowers/plans/2026-08-05-ares-backend-phase1.md

1229 lines
37 KiB
Markdown

# 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<Mutex<Connection>> + 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<Mutex<Connection>>` — 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<file_id, HashMap<peer_id, WsSender>>`.
---
### 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<T> = Result<T, AppError>;
```
- [ ] **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<Connection>,
pub tracker: PeerTracker,
}
impl AppState {
pub fn new(config: Config, db: Connection) -> Arc<Self> {
Arc::new(Self {
config,
db: Mutex::new(db),
tracker: PeerTracker::default(),
})
}
}
pub type SharedState = Arc<AppState>;
```
- [ ] **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<Value> {
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::<serde_json::Value>()["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<Connection> {
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<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>,
}
```
- [ ] **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<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)
}
```
> 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<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)?;
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<SharedState>, Path(file_id): Path<String>) -> AppResult<Json<FileRecord>> {
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<FileRecord> {
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::<serde_json::Value>()["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::<serde_json::Value>()["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<String> = 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<Vec<FileRecord>> {
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<Box<dyn rusqlite::ToSql>> = 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<SharedState>,
Query(q): Query<SearchQuery>,
) -> AppResult<Json<Vec<FileRecord>>> {
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<Self> { ... }
}
```
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::<Vec<serde_json::Value>>();
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::<Vec<serde_json::Value>>();
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::<Vec<serde_json::Value>>();
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<Message>;
#[derive(Default)]
pub struct PeerTracker {
pub by_file: HashMap<String, HashMap<String, WsSender>>,
}
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<String> {
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<PeerTracker>`.
- [ ] **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<SharedState>,
) -> 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::<Message>();
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::<serde_json::Value>(&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<SharedState>, Path(file_id): Path<String>) -> AppResult<Json<serde_json::Value>> {
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::<serde_json::Value>();
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<String, (Instant, u32)>,
}
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<SharedState>) -> AppResult<Json<serde_json::Value>> {
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::<std::collections::HashSet<_>>().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<RateLimiter>` (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::<serde_json::Value>();
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)
- [x] **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 Cargo.lock ./
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
ENV ARS_NEXT_DB=/data/aresnext.db
EXPOSE 3000
VOLUME ["/data"]
CMD ["/app/ares-server"]
```
> Nota: `dockerfile_location` no Coolify deve ser `/Dockerfile` (relativo a `base_directory=/server`), senão o build falha com `lstat .../server/server/Dockerfile`.
- [x] **Step 2: `server/.dockerignore`**
```
target/
data/
*.db
```
- [x] **Step 3: compilar release local**
Run: `cargo build --release --manifest-path server/Cargo.toml`
Expected: binário em `server/target/release/ares-server`.
- [x] **Step 4: push para Gitea (git.hokmatech.com)**
Repo: `https://git.hokmatech.com/Hokma-tech/ares-next.git` (branches `main` e `dev/backend-phase1`; o app no Coolify usa `main`).
- [x] **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` → `s9qv4gylwinynj3l4xtvbkdq` (env `production` `lcmykb94tfjgseekygkxuvmv`)
- Criar aplicação apontando para o repo (Dockerfile) → `tfemyyxvo66o9yptdvro8shk`
- Configurar domínio `https://ares.hokmatech.com` (scheme obrigatório — 422 sem ele), volume `/data` → `/data/aresnext` (storage `z5cxsthbdpaofrxar7cnvpvr`; POST /storages exige `type`, `mount_path`, `host_path`)
- HTTPS: Cloudflare proxy ON (registro A proxied) → Cloudflare Universal SSL no edge + Origin CA cert no origin (certificado LE on-demand do Coolify não foi emitido; default cert Cloudflare Origin CA é usada; modo SSL do zona é strict e bate com o cert do origin)
- **Login via API**: POST /login precisa de CSRF (`csrf-token` meta + cookie `XSRF-TOKEN` decodificado); sessão salva em `/tmp/ck.txt`. Tokens API criados via SQL (modelo `PersonalAccessToken` exige `team_id`): token `2|fbc2958dbae81c960442ac66b88360888704937a` (hash sha256 da parte após `|`), abilities `["root"]` (exigido por `ApiAbility`/`EnsureTokenBelongsToCurrentTeamMember`), arquivo em `/tmp/ares-coolify-token` na VPS.
- `instance_settings.is_api_enabled=true` necessário para `/api/v1/*` (403 "API is disabled").
- API acessível apenas em `127.0.0.1:8000` na VPS (executar via SSH direto; túnel local falhou pois `Start-Job` morre com o shell).
- [x] **Step 6: DNS no Cloudflare**
Registro A: `ares` → `13.140.174.24` (id `06c7c54466836569a65c979b3a66829f`), **proxy ON** — sem proxy o HTTPS quebra (Origin CA não é pública).
- [x] **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"}`
Deploy 3 (`cerav5xk6mablalovygvdgtk`) finished; container `tfemyyxvo66o9yptdvro8shk-015111669069` (10.0.1.8). Smoke público: health 200, announce 200 (`peer-vps-1::aaaaaaaaaaaa`), files 200, peers 200, search 200, stats 200, WS handshake 101 + broadcast `peer_online` entre 2 clients (`peer-ws-1`/`peer-ws-2`) via Python websockets na VPS. Banco persiste em `/data/aresnext/aresnext.db` (volume); teste de dados removido (app recria schema).
- [x] **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<PeerTracker>` — 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.