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

37 KiB

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

[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
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
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
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
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
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)
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
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
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
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

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
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
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

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
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
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
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
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
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
#[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
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

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
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
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
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
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
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

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
pub mod tracker;
  • Step 3: server/src/api/ws.rs
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:

futures-util = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
  • Step 5: server/src/api/peers.rs
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
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
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

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
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
#[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
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

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.

  • 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)

Repo: https://git.hokmatech.com/Hokma-tech/ares-next.git (branches main e dev/backend-phase1; o app no Coolify usa main).

  • Step 5: criar projeto no Coolify via API (túnel SSH)
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).

  • 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).

  • 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).

  • Step 8: commit docs
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.