feat(server): announce com validacao e persistencia + files por id

This commit is contained in:
2026-08-05 20:28:09 -03:00
parent 9c7cb1c9b8
commit 2564c33748
9 changed files with 319 additions and 2 deletions
+21
View File
@@ -0,0 +1,21 @@
use axum::{extract::State, Json};
use crate::error::{AppError, 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)
.map_err(|e| match e {
rusqlite::Error::SqliteFailure(err, _) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
AppError::BadRequest("duplicate file (hash already announced)".into())
}
other => AppError::Db(other),
})?;
Ok(Json(serde_json::json!({ "file_id": id, "ok": true })))
}
+15
View File
@@ -0,0 +1,15 @@
use axum::extract::{Path, State};
use axum::Json;
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();
crate::store::get_file(&db, &file_id)
.map(Json)
.map_err(|_| AppError::NotFound("file not found".into()))
}
+5 -1
View File
@@ -1,12 +1,16 @@
pub mod announce;
pub mod files;
pub mod health;
use axum::{routing::get, Router};
use axum::{routing::{get, post}, 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("/files/{file_id}", get(files::get_file))
.route("/announce", post(announce::announce))
.with_state(state);
Router::new()
+2
View File
@@ -1,8 +1,10 @@
pub mod api;
pub mod config;
pub mod error;
pub mod models;
pub mod state;
pub mod store;
pub mod validate;
use axum::Router;
use crate::config::Config;
+61
View File
@@ -0,0 +1,61 @@
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>,
}
impl FileRecord {
pub(crate) fn from_row(r: &rusqlite::Row) -> rusqlite::Result<Self> {
let tags: String = r.get(9)?;
Ok(Self {
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)?,
})
}
}
+26 -1
View File
@@ -1,6 +1,7 @@
use rusqlite::Connection;
use rusqlite::{params, Connection};
use crate::config::Config;
use crate::error::AppResult;
use crate::models::{AnnounceRequest, FileRecord};
pub fn open(cfg: &Config) -> AppResult<Connection> {
if let Some(parent) = std::path::Path::new(&cfg.db_path).parent() {
@@ -47,4 +48,28 @@ pub fn migrate(db: &Connection) -> rusqlite::Result<()> {
END;
"#,
)
}
pub fn insert_file(db: &Connection, req: &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)
}
pub fn get_file(db: &Connection, file_id: &str) -> rusqlite::Result<FileRecord> {
db.query_row(
"SELECT id, peer_id, name, description, ftype, extension, size_bytes, hash, license, tags, created_at
FROM files WHERE id = ?1",
[file_id],
FileRecord::from_row,
)
}
+48
View File
@@ -0,0 +1,48 @@
use crate::error::AppError;
use crate::models::{AnnounceRequest, FILE_TYPES};
pub const BANNED_EXT: [&str; 12] = [
"exe", "bat", "cmd", "msi", "scr", "ps1", "vbs", "js", "jar", "apk", "dll", "sh",
];
const MAX_NAME: usize = 255;
const MAX_TAGS: usize = 20;
const MAX_TAG_LEN: usize = 32;
const MAX_PEER_ID: usize = 128;
pub fn validate_announce(req: &AnnounceRequest) -> Result<(), AppError> {
if req.name.trim().is_empty() || req.name.len() > MAX_NAME {
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() > MAX_PEER_ID {
return Err(AppError::BadRequest("invalid peer_id".into()));
}
if req.tags.len() > MAX_TAGS {
return Err(AppError::BadRequest("too many tags".into()));
}
for t in &req.tags {
if t.len() > MAX_TAG_LEN {
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())
}