init: FileDrop phase1 architecture and scaffold
- Rust axum signaling server with WebSocket support - Lit + TypeScript frontend with Vite - Redis session storage with TTL - WebRTC transport and crypto client stubs - Phase1 architecture plan in plans/ - Deploy directory structure prepared
This commit is contained in:
19
server/Cargo.toml
Normal file
19
server/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "filedrop-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
redis = { version = "0.25", features = ["tokio-comp", "connection-manager"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
rand = "0.8"
|
||||
thiserror = "1"
|
||||
config = "0.14"
|
||||
chrono = "0.4"
|
||||
base64-url = "2"
|
||||
futures = "0.3"
|
||||
11
server/src/api/health.rs
Normal file
11
server/src/api/health.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use axum::response::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
pub async fn handler() -> Json<HealthResponse> {
|
||||
Json(HealthResponse { status: "ok" })
|
||||
}
|
||||
2
server/src/api/mod.rs
Normal file
2
server/src/api/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod health;
|
||||
pub mod sessions;
|
||||
86
server/src/api/sessions.rs
Normal file
86
server/src/api/sessions.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::session::service::SessionService;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
pub file_count: u32,
|
||||
pub total_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateSessionResponse {
|
||||
pub room_id: String,
|
||||
pub join_token: String,
|
||||
pub ws_url: String,
|
||||
pub expires_at: String,
|
||||
pub ice_servers: Vec<IceServer>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IceServer {
|
||||
pub urls: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinSessionRequest {
|
||||
pub join_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct JoinSessionResponse {
|
||||
pub ws_url: String,
|
||||
pub expires_at: String,
|
||||
pub ice_servers: Vec<IceServer>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(config): State<AppConfig>,
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> Result<Json<CreateSessionResponse>, StatusCode> {
|
||||
let session = SessionService::create_session(req.file_count, req.total_size, &config)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(CreateSessionResponse {
|
||||
room_id: session.room_id,
|
||||
join_token: session.join_token,
|
||||
ws_url: format!("wss://{}/ws", config.app_host),
|
||||
expires_at: session.expires_at,
|
||||
ice_servers: config.ice_servers(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn join(
|
||||
State(config): State<AppConfig>,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<JoinSessionRequest>,
|
||||
) -> Result<Json<JoinSessionResponse>, StatusCode> {
|
||||
let valid = SessionService::validate_join_token(&room_id, &req.join_token, &config)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
if !valid {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let session = SessionService::get_session(&room_id, &config)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(JoinSessionResponse {
|
||||
ws_url: format!("wss://{}/ws", config.app_host),
|
||||
expires_at: session.expires_at,
|
||||
ice_servers: config.ice_servers(),
|
||||
}))
|
||||
}
|
||||
41
server/src/config.rs
Normal file
41
server/src/config.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub redis_url: String,
|
||||
pub app_host: String,
|
||||
pub turn_host: String,
|
||||
pub turn_username: String,
|
||||
pub turn_credential: String,
|
||||
pub session_ttl_secs: u64,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
let config = Config::builder()
|
||||
.add_source(File::with_name("config/default").required(false))
|
||||
.add_source(Environment::with_prefix("FILEDROP"))
|
||||
.build()?;
|
||||
|
||||
config.try_deserialize()
|
||||
}
|
||||
|
||||
pub fn ice_servers(&self) -> Vec<crate::api::sessions::IceServer> {
|
||||
vec![
|
||||
crate::api::sessions::IceServer {
|
||||
urls: vec![format!("stun:{}:3478", self.turn_host)],
|
||||
username: None,
|
||||
credential: None,
|
||||
},
|
||||
crate::api::sessions::IceServer {
|
||||
urls: vec![
|
||||
format!("turn:{}:3478?transport=udp", self.turn_host),
|
||||
format!("turn:{}:3478?transport=tcp", self.turn_host),
|
||||
],
|
||||
username: Some(self.turn_username.clone()),
|
||||
credential: Some(self.turn_credential.clone()),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
1
server/src/ice/mod.rs
Normal file
1
server/src/ice/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod config;
|
||||
42
server/src/main.rs
Normal file
42
server/src/main.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod api;
|
||||
mod config;
|
||||
mod ice;
|
||||
mod session;
|
||||
mod signaling;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "filedrop_server=info,tower_http=debug,axum=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = config::AppConfig::load().expect("Failed to load config");
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(api::health::handler))
|
||||
.route("/api/sessions", post(api::sessions::create))
|
||||
.route("/api/sessions/:room_id/join", post(api::sessions::join))
|
||||
.route("/ws", get(signaling::ws_handler::ws_handler))
|
||||
.with_state(config);
|
||||
|
||||
let addr = "0.0.0.0:3000";
|
||||
tracing::info!("Starting server on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("Failed to bind");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Server failed");
|
||||
}
|
||||
2
server/src/session/mod.rs
Normal file
2
server/src/session/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod store;
|
||||
89
server/src/session/service.rs
Normal file
89
server/src/session/service.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
use super::store::SessionStore;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub room_id: String,
|
||||
pub join_token: String,
|
||||
pub status: SessionStatus,
|
||||
pub file_count: u32,
|
||||
pub total_size: u64,
|
||||
pub expires_at: String,
|
||||
pub sender_connected: bool,
|
||||
pub receiver_connected: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionStatus {
|
||||
Waiting,
|
||||
Connecting,
|
||||
Transferring,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Expired,
|
||||
}
|
||||
|
||||
pub struct SessionService;
|
||||
|
||||
impl SessionService {
|
||||
pub async fn create_session(
|
||||
file_count: u32,
|
||||
total_size: u64,
|
||||
config: &AppConfig,
|
||||
) -> Result<Session, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let room_id = generate_room_id();
|
||||
let join_token = generate_token();
|
||||
let expires_at = chrono::Utc::now()
|
||||
+ chrono::Duration::seconds(config.session_ttl_secs as i64);
|
||||
|
||||
let session = Session {
|
||||
room_id: room_id.clone(),
|
||||
join_token: join_token.clone(),
|
||||
status: SessionStatus::Waiting,
|
||||
file_count,
|
||||
total_size,
|
||||
expires_at: expires_at.to_rfc3339(),
|
||||
sender_connected: false,
|
||||
receiver_connected: false,
|
||||
};
|
||||
|
||||
SessionStore::save_session(&room_id, &join_token, &session, config).await?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn validate_join_token(
|
||||
room_id: &str,
|
||||
token: &str,
|
||||
config: &AppConfig,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
SessionStore::validate_token(room_id, token, config).await
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
room_id: &str,
|
||||
config: &AppConfig,
|
||||
) -> Result<Session, Box<dyn std::error::Error + Send + Sync>> {
|
||||
SessionStore::get_session(room_id, config).await
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_room_id() -> String {
|
||||
const CHARS: &[u8] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..6)
|
||||
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 24];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
base64_url::encode(&bytes)
|
||||
}
|
||||
62
server/src/session/store.rs
Normal file
62
server/src/session/store.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use redis::AsyncCommands;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
use super::service::Session;
|
||||
|
||||
pub struct SessionStore;
|
||||
|
||||
impl SessionStore {
|
||||
pub async fn save_session(
|
||||
room_id: &str,
|
||||
join_token: &str,
|
||||
session: &Session,
|
||||
config: &AppConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = redis::Client::open(config.redis_url.as_str())?;
|
||||
let mut conn = client.get_multiplexed_async_connection().await?;
|
||||
|
||||
let session_key = format!("session:{}", room_id);
|
||||
let token_key = format!("join_token:{}", room_id);
|
||||
let ttl = config.session_ttl_secs as usize;
|
||||
|
||||
let session_json = serde_json::to_string(session)?;
|
||||
|
||||
let _: () = conn.set_ex(&session_key, &session_json, ttl).await?;
|
||||
let _: () = conn.set_ex(&token_key, join_token, ttl).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn validate_token(
|
||||
room_id: &str,
|
||||
token: &str,
|
||||
config: &AppConfig,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = redis::Client::open(config.redis_url.as_str())?;
|
||||
let mut conn = client.get_multiplexed_async_connection().await?;
|
||||
|
||||
let token_key = format!("join_token:{}", room_id);
|
||||
let stored_token: Option<String> = conn.get(&token_key).await?;
|
||||
|
||||
Ok(stored_token.as_deref() == Some(token))
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
room_id: &str,
|
||||
config: &AppConfig,
|
||||
) -> Result<Session, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = redis::Client::open(config.redis_url.as_str())?;
|
||||
let mut conn = client.get_multiplexed_async_connection().await?;
|
||||
|
||||
let session_key = format!("session:{}", room_id);
|
||||
let session_json: Option<String> = conn.get(&session_key).await?;
|
||||
|
||||
let session: Session = serde_json::from_str(
|
||||
&session_json.ok_or("Session not found")?,
|
||||
)?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
2
server/src/signaling/mod.rs
Normal file
2
server/src/signaling/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod ws_handler;
|
||||
pub mod room;
|
||||
5
server/src/signaling/room.rs
Normal file
5
server/src/signaling/room.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub struct RoomManager;
|
||||
|
||||
impl RoomManager {
|
||||
// Placeholder for future room management logic
|
||||
}
|
||||
76
server/src/signaling/ws_handler.rs
Normal file
76
server/src/signaling/ws_handler.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
type RoomMap = Arc<Mutex<HashMap<String, Room>>>;
|
||||
|
||||
struct Room {
|
||||
sender_ws: Option<WebSocket>,
|
||||
receiver_ws: Option<WebSocket>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct SignalingMessage {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
room_id: String,
|
||||
role: String,
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(config): State<AppConfig>,
|
||||
) -> impl IntoResponse {
|
||||
let rooms: RoomMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, rooms, config))
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, rooms: RoomMap, config: AppConfig) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let mut room_id: Option<String> = None;
|
||||
let mut role: Option<String> = None;
|
||||
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
if let Ok(signal) = serde_json::from_str::<SignalingMessage>(&text) {
|
||||
room_id = Some(signal.room_id.clone());
|
||||
role = Some(signal.role.clone());
|
||||
|
||||
let mut rooms_lock = rooms.lock().await;
|
||||
let room = rooms_lock
|
||||
.entry(signal.room_id.clone())
|
||||
.or_insert(Room {
|
||||
sender_ws: None,
|
||||
receiver_ws: None,
|
||||
});
|
||||
|
||||
// Route message to the other peer
|
||||
let target_ws = if signal.role == "sender" {
|
||||
&mut room.receiver_ws
|
||||
} else {
|
||||
&mut room.sender_ws
|
||||
};
|
||||
|
||||
if let Some(ws) = target_ws {
|
||||
let _ = ws.send(Message::Text(text)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user