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:
2026-04-09 10:32:06 +08:00
commit 4b34a85599
34 changed files with 1561 additions and 0 deletions

19
server/Cargo.toml Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod health;
pub mod sessions;

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

@@ -0,0 +1 @@
pub mod config;

42
server/src/main.rs Normal file
View 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");
}

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod store;

View 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)
}

View 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)
}
}

View File

@@ -0,0 +1,2 @@
pub mod ws_handler;
pub mod room;

View File

@@ -0,0 +1,5 @@
pub struct RoomManager;
impl RoomManager {
// Placeholder for future room management logic
}

View 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,
_ => {}
}
}
}