1229 lines
37 KiB
Markdown
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. |