From 4b34a8559943b196c5712ea332b57058a4d2b1fa Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 9 Apr 2026 10:32:06 +0800 Subject: [PATCH] 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 --- .gitignore | 6 + Cargo.toml | 3 + README.md | 38 +++ plans/phase1-architecture.md | 392 ++++++++++++++++++++++++++ server/Cargo.toml | 19 ++ server/src/api/health.rs | 11 + server/src/api/mod.rs | 2 + server/src/api/sessions.rs | 86 ++++++ server/src/config.rs | 41 +++ server/src/ice/mod.rs | 1 + server/src/main.rs | 42 +++ server/src/session/mod.rs | 2 + server/src/session/service.rs | 89 ++++++ server/src/session/store.rs | 62 ++++ server/src/signaling/mod.rs | 2 + server/src/signaling/room.rs | 5 + server/src/signaling/ws_handler.rs | 76 +++++ web/index.html | 13 + web/package.json | 17 ++ web/src/api/session-api.ts | 27 ++ web/src/api/signaling.ts | 33 +++ web/src/crypto/crypto-client.ts | 47 +++ web/src/main.ts | 46 +++ web/src/transfer/file-transfer.ts | 26 ++ web/src/transfer/state.ts | 26 ++ web/src/ui/components/progress-bar.ts | 39 +++ web/src/ui/components/qr-display.ts | 35 +++ web/src/ui/pages/expired-page.ts | 35 +++ web/src/ui/pages/home-page.ts | 79 ++++++ web/src/ui/pages/join-page.ts | 67 +++++ web/src/ui/pages/send-page.ts | 92 ++++++ web/src/webrtc/transport.ts | 70 +++++ web/tsconfig.json | 15 + web/vite.config.ts | 17 ++ 34 files changed, 1561 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 plans/phase1-architecture.md create mode 100644 server/Cargo.toml create mode 100644 server/src/api/health.rs create mode 100644 server/src/api/mod.rs create mode 100644 server/src/api/sessions.rs create mode 100644 server/src/config.rs create mode 100644 server/src/ice/mod.rs create mode 100644 server/src/main.rs create mode 100644 server/src/session/mod.rs create mode 100644 server/src/session/service.rs create mode 100644 server/src/session/store.rs create mode 100644 server/src/signaling/mod.rs create mode 100644 server/src/signaling/room.rs create mode 100644 server/src/signaling/ws_handler.rs create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/api/session-api.ts create mode 100644 web/src/api/signaling.ts create mode 100644 web/src/crypto/crypto-client.ts create mode 100644 web/src/main.ts create mode 100644 web/src/transfer/file-transfer.ts create mode 100644 web/src/transfer/state.ts create mode 100644 web/src/ui/components/progress-bar.ts create mode 100644 web/src/ui/components/qr-display.ts create mode 100644 web/src/ui/pages/expired-page.ts create mode 100644 web/src/ui/pages/home-page.ts create mode 100644 web/src/ui/pages/join-page.ts create mode 100644 web/src/ui/pages/send-page.ts create mode 100644 web/src/webrtc/transport.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed32130 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +target/ +*.log +.env +config/*.yaml diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..156820e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["server"] +resolver = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ecc044 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# FileDrop + +安全无痕文件传输 Web App + +## 特性 + +- 免费、无注册、无历史记录 +- 优先局域网,WebRTC 直连 +- 浏览器端端到端加密 +- 传完即销毁 + +## 技术栈 + +- 前端:Lit + TypeScript + Vite +- 后端:Rust + axum +- 存储:Redis +- NAT 穿透:coturn + +## 开发 + +### 后端 + +```bash +cd server +cargo run +``` + +### 前端 + +```bash +cd web +npm install +npm run dev +``` + +## 部署 + +见 `deploy/` 目录 diff --git a/plans/phase1-architecture.md b/plans/phase1-architecture.md new file mode 100644 index 0000000..66cc41d --- /dev/null +++ b/plans/phase1-architecture.md @@ -0,0 +1,392 @@ +# FileDrop - 安全无痕文件传输 Web App + +## 产品定位 + +免费、无注册、无历史记录的安全文件传输工具。 + +- 优先局域网,兼顾公网 +- WebRTC 直连优先,TURN 兜底 +- 浏览器端端到端加密 +- 传完即销毁,不留痕迹 + +--- + +## 第一期范围 + +### 要做 + +- 扫码/短码配对 +- WebRTC DataChannel 传输 +- 浏览器端 AES-GCM 分块加密 +- TURN 回退 +- 会话超时自动清理 +- 小文件传输(<100MB) + +### 不做 + +- 用户注册/登录 +- 历史记录 +- 大文件/断点续传 +- 付费/套餐 +- Postgres +- WASM +- 对象存储中转 + +--- + +## 技术栈 + +| 层 | 技术 | +|---|---| +| 前端 | Lit + TypeScript + Vite | +| 后端 | Rust + axum + tokio | +| 临时状态 | Redis | +| NAT 穿透 | coturn | +| 反向代理 | Caddy | +| 部署 | Debian 12 + systemd | + +--- + +## 系统架构 + +``` +Browser A Browser B + | | + |---- HTTPS / WSS -------------| + | | + Rust Signaling Server + | + Redis + | + coturn +``` + +### 职责划分 + +**浏览器** +- 选文件 +- 生成会话密钥 +- 二维码/短码配对 +- WebRTC 建连 +- 文件分块加密 +- DataChannel 发送/接收 + +**Rust 服务端** +- 创建/加入会话 +- WebSocket 信令转发 +- 临时会话状态 +- 过期清理 +- 下发 ICE 配置 + +**Redis** +- 会话临时状态 +- 在线状态 +- 一次性加入令牌 +- TTL 自动过期 + +**coturn** +- STUN/TURN 服务 + +--- + +## 核心流程 + +### 1. 创建会话 + +1. 发送端选择文件 +2. 浏览器本地生成 `session_secret` +3. 请求 `POST /api/sessions` +4. 服务端返回 `room_id`、`join_token`、`ws_url`、`ice_servers` +5. 前端生成分享信息: + - 短码:`room_id` + - 链接:`/join/{room_id}#k={session_secret}&t={join_token}` + +> `#k=` 在 URL fragment 中,不会发给服务端 + +### 2. 加入会话 + +1. 接收端扫码进入 +2. 前端从 fragment 取出 `session_secret` 和 `join_token` +3. 请求 `POST /api/sessions/{room_id}/join` +4. 服务端校验会话状态和 token +5. 成功后建立 WebSocket + +### 3. 信令协商 + +1. 双方连上 `WS /ws` +2. 发送端创建 offer +3. 接收端返回 answer +4. 双方交换 ICE candidate +5. 建立 `RTCDataChannel` + +### 4. 文件传输 + +1. 发送端发送 `file_manifest` +2. 文件切块(64KB ~ 256KB) +3. 每块独立 AES-GCM 加密 +4. 通过 DataChannel 发送 +5. 接收端逐块解密并缓存 +6. 完成后生成下载文件 +7. 双方发送完成确认并关闭会话 + +### 5. 会话销毁 + +触发条件: +- 传输完成 +- 用户主动取消 +- 超时(15 分钟) +- WebSocket/RTC 长时间断开 + +--- + +## 协议设计 + +### HTTP API + +#### `POST /api/sessions` + +创建会话 + +**请求** +```json +{ + "file_count": 2, + "total_size": 1834201 +} +``` + +**响应** +```json +{ + "room_id": "8F4K2P", + "join_token": "opaque-token", + "ws_url": "wss://app.example.com/ws", + "expires_at": "2026-04-09T12:00:00Z", + "ice_servers": [ + { "urls": ["stun:turn.example.com:3478"] }, + { + "urls": ["turn:turn.example.com:3478?transport=udp"], + "username": "u", + "credential": "p" + } + ] +} +``` + +#### `POST /api/sessions/{room_id}/join` + +加入会话 + +**请求** +```json +{ + "join_token": "opaque-token" +} +``` + +**响应** +```json +{ + "ws_url": "wss://app.example.com/ws", + "expires_at": "2026-04-09T12:00:00Z", + "ice_servers": [...] +} +``` + +#### `GET /health` + +健康检查 + +### WebSocket 消息 + +统一格式: +```json +{ + "type": "offer|answer|ice|ready|cancel|error|ping|pong", + "room_id": "8F4K2P", + "role": "sender|receiver", + "payload": {} +} +``` + +### DataChannel 消息 + +控制消息(JSON): +- `file_manifest` - 文件清单 +- `chunk_ack` - 分块确认 +- `transfer_complete` - 传输完成 + +数据面:`ArrayBuffer`(加密分块) + +--- + +## 安全设计 + +1. 会话密钥只在浏览器生成和持有 +2. 文件名和文件内容都加密 +3. 服务端只看到 room_id、连接状态、粗粒度元信息 +4. 不保存文件内容 +5. 不保存传输历史 +6. Redis 全部使用 TTL +7. 所有页面和 WS 强制 HTTPS/WSS +8. TURN 使用临时凭证 +9. 前端在完成或取消后清空密钥和缓存 + +**加密方案** +- 会话密钥:32 字节随机 +- 密钥派生:HKDF-SHA-256 +- 分块加密:AES-GCM +- 摘要校验:SHA-256 + +--- + +## 前端页面 + +| 路由 | 说明 | +|---|---| +| `/` | 首页:发送/接收入口 | +| `/send` | 选文件、创建会话、展示二维码 | +| `/join/:roomId` | 扫码进入、确认接收、下载 | +| `/expired` | 会话失效提示 | + +--- + +## Redis 设计 + +### Key 结构 + +- `session:{room_id}` - 会话元数据 +- `presence:{room_id}:sender` - 发送端在线状态 +- `presence:{room_id}:receiver` - 接收端在线状态 +- `join_token:{room_id}` - 一次性加入令牌 +- `ws:{connection_id}` - WebSocket 连接映射 + +### TTL + +- 会话:15 分钟 +- 已完成会话:立即删除或保留 1 分钟缓冲 +- WebSocket presence:30-60 秒心跳续期 + +--- + +## 开发里程碑 + +### P0 原型 +- 创建/加入会话 +- WS 信令 +- DataChannel 建立 +- 文本消息互通 + +### P1 小文件传输 +- 文件 manifest +- 分块发送 +- 接收后下载 +- 进度显示 + +### P2 安全闭环 +- 会话密钥生成 +- 文件名/内容加密 +- 会话销毁 +- Redis TTL 清理 + +### P3 稳定性 +- TURN 兜底验证 +- 异常断开处理 +- 超时与取消 +- Chrome / Safari / iOS 测试 + +### P4 上线 +- Debian 部署 +- HTTPS/WSS +- 基础限流 +- 基础日志与监控 + +--- + +## 项目结构 + +``` +filedrop/ +├── Cargo.toml +├── plans/ +│ └── phase1-architecture.md +├── server/ +│ ├── src/ +│ │ ├── main.rs +│ │ ├── config.rs +│ │ ├── api/ +│ │ │ ├── mod.rs +│ │ │ ├── sessions.rs +│ │ │ └── health.rs +│ │ ├── signaling/ +│ │ │ ├── mod.rs +│ │ │ ├── ws_handler.rs +│ │ │ └── room.rs +│ │ ├── session/ +│ │ │ ├── mod.rs +│ │ │ ├── service.rs +│ │ │ └── store.rs +│ │ └── ice/ +│ │ ├── mod.rs +│ │ └── config.rs +│ └── Cargo.toml +├── web/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── vite.config.ts +│ ├── index.html +│ └── src/ +│ ├── main.ts +│ ├── api/ +│ │ ├── session-api.ts +│ │ └── signaling.ts +│ ├── webrtc/ +│ │ ├── transport.ts +│ │ └── ice.ts +│ ├── crypto/ +│ │ └── crypto-client.ts +│ ├── transfer/ +│ │ ├── file-transfer.ts +│ │ └── state.ts +│ └── ui/ +│ ├── pages/ +│ │ ├── home-page.ts +│ │ ├── send-page.ts +│ │ ├── join-page.ts +│ │ └── expired-page.ts +│ └── components/ +│ ├── qr-display.ts +│ ├── file-picker.ts +│ ├── progress-bar.ts +│ └── status-indicator.ts +├── deploy/ +│ ├── docker-compose.yml +│ ├── Caddyfile +│ ├── coturn.conf +│ └── filedrop.service +└── README.md +``` + +--- + +## 部署拓扑 + +最小部署: +- Caddy/Nginx +- axum app +- Redis +- coturn + +域名建议: +- `app.example.com`:Web App + API + WS +- `turn.example.com`:TURN 服务 + +--- + +## 风险与注意事项 + +1. Safari 对 WebRTC/DataChannel、后台标签页较敏感 +2. TURN 成本需监控,大文件应尽快切应用层密文中转 +3. 浏览器内存占用,大文件必须按块处理 +4. 断点续传复杂度不适合首版 +5. 端到端加密需覆盖文件名和文件列表 diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..2e34c9a --- /dev/null +++ b/server/Cargo.toml @@ -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" diff --git a/server/src/api/health.rs b/server/src/api/health.rs new file mode 100644 index 0000000..50b11c0 --- /dev/null +++ b/server/src/api/health.rs @@ -0,0 +1,11 @@ +use axum::response::Json; +use serde::Serialize; + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +pub async fn handler() -> Json { + Json(HealthResponse { status: "ok" }) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs new file mode 100644 index 0000000..3cf43b9 --- /dev/null +++ b/server/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod health; +pub mod sessions; diff --git a/server/src/api/sessions.rs b/server/src/api/sessions.rs new file mode 100644 index 0000000..88351d9 --- /dev/null +++ b/server/src/api/sessions.rs @@ -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, +} + +#[derive(Serialize)] +pub struct IceServer { + pub urls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub credential: Option, +} + +#[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, +} + +pub async fn create( + State(config): State, + Json(req): Json, +) -> Result, 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, + Path(room_id): Path, + Json(req): Json, +) -> Result, 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(), + })) +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..81e726e --- /dev/null +++ b/server/src/config.rs @@ -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 { + 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 { + 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()), + }, + ] + } +} diff --git a/server/src/ice/mod.rs b/server/src/ice/mod.rs new file mode 100644 index 0000000..ef68c36 --- /dev/null +++ b/server/src/ice/mod.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..aa65229 --- /dev/null +++ b/server/src/main.rs @@ -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"); +} diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs new file mode 100644 index 0000000..ad3212f --- /dev/null +++ b/server/src/session/mod.rs @@ -0,0 +1,2 @@ +pub mod service; +pub mod store; diff --git a/server/src/session/service.rs b/server/src/session/service.rs new file mode 100644 index 0000000..c58f012 --- /dev/null +++ b/server/src/session/service.rs @@ -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> { + 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> { + SessionStore::validate_token(room_id, token, config).await + } + + pub async fn get_session( + room_id: &str, + config: &AppConfig, + ) -> Result> { + 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) +} diff --git a/server/src/session/store.rs b/server/src/session/store.rs new file mode 100644 index 0000000..49ff4d3 --- /dev/null +++ b/server/src/session/store.rs @@ -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> { + 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> { + 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 = conn.get(&token_key).await?; + + Ok(stored_token.as_deref() == Some(token)) + } + + pub async fn get_session( + room_id: &str, + config: &AppConfig, + ) -> Result> { + 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 = conn.get(&session_key).await?; + + let session: Session = serde_json::from_str( + &session_json.ok_or("Session not found")?, + )?; + + Ok(session) + } +} diff --git a/server/src/signaling/mod.rs b/server/src/signaling/mod.rs new file mode 100644 index 0000000..014c4b5 --- /dev/null +++ b/server/src/signaling/mod.rs @@ -0,0 +1,2 @@ +pub mod ws_handler; +pub mod room; diff --git a/server/src/signaling/room.rs b/server/src/signaling/room.rs new file mode 100644 index 0000000..0754889 --- /dev/null +++ b/server/src/signaling/room.rs @@ -0,0 +1,5 @@ +pub struct RoomManager; + +impl RoomManager { + // Placeholder for future room management logic +} diff --git a/server/src/signaling/ws_handler.rs b/server/src/signaling/ws_handler.rs new file mode 100644 index 0000000..19eef73 --- /dev/null +++ b/server/src/signaling/ws_handler.rs @@ -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>>; + +struct Room { + sender_ws: Option, + receiver_ws: Option, +} + +#[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, +) -> 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 = None; + let mut role: Option = None; + + while let Some(Ok(msg)) = receiver.next().await { + match msg { + Message::Text(text) => { + if let Ok(signal) = serde_json::from_str::(&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, + _ => {} + } + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ed4cd3e --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + FileDrop - 安全文件传输 + + + + + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..2477b78 --- /dev/null +++ b/web/package.json @@ -0,0 +1,17 @@ +{ + "name": "filedrop-web", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lit": "^3.1.0" + }, + "devDependencies": { + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} diff --git a/web/src/api/session-api.ts b/web/src/api/session-api.ts new file mode 100644 index 0000000..d040d2e --- /dev/null +++ b/web/src/api/session-api.ts @@ -0,0 +1,27 @@ +export class SessionApi { + private baseUrl: string; + + constructor(baseUrl: string = '') { + this.baseUrl = baseUrl; + } + + async createSession(fileCount: number, totalSize: number) { + const res = await fetch(`${this.baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_count: fileCount, total_size: totalSize }), + }); + if (!res.ok) throw new Error('Failed to create session'); + return res.json(); + } + + async joinSession(roomId: string, joinToken: string) { + const res = await fetch(`${this.baseUrl}/api/sessions/${roomId}/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ join_token: joinToken }), + }); + if (!res.ok) throw new Error('Failed to join session'); + return res.json(); + } +} diff --git a/web/src/api/signaling.ts b/web/src/api/signaling.ts new file mode 100644 index 0000000..bad1b41 --- /dev/null +++ b/web/src/api/signaling.ts @@ -0,0 +1,33 @@ +export class SignalingClient { + private ws: WebSocket | null = null; + private messageHandler: ((msg: any) => void) | null = null; + + connect(url: string) { + this.ws = new WebSocket(url); + return new Promise((resolve, reject) => { + if (!this.ws) return reject('No WebSocket'); + this.ws.onopen = () => resolve(); + this.ws.onerror = reject; + }); + } + + send(msg: any) { + this.ws?.send(JSON.stringify(msg)); + } + + onMessage(callback: (msg: any) => void) { + this.messageHandler = callback; + if (this.ws) { + this.ws.onmessage = (e) => { + try { + this.messageHandler?.(JSON.parse(e.data)); + } catch {} + }; + } + } + + close() { + this.ws?.close(); + this.ws = null; + } +} diff --git a/web/src/crypto/crypto-client.ts b/web/src/crypto/crypto-client.ts new file mode 100644 index 0000000..cc5d5ab --- /dev/null +++ b/web/src/crypto/crypto-client.ts @@ -0,0 +1,47 @@ +export class CryptoClient { + private sessionKey: CryptoKey | null = null; + + async initFromSecret(secret: string) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + 'HKDF', + false, + ['deriveKey'] + ); + + this.sessionKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode('filedrop-v1'), + info: encoder.encode('file-transfer-key'), + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + async encrypt(data: ArrayBuffer): Promise<{ ciphertext: ArrayBuffer; nonce: Uint8Array }> { + if (!this.sessionKey) throw new Error('Not initialized'); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, + this.sessionKey, + data + ); + return { ciphertext, nonce }; + } + + async decrypt(ciphertext: ArrayBuffer, nonce: Uint8Array): Promise { + if (!this.sessionKey) throw new Error('Not initialized'); + return crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce }, + this.sessionKey, + ciphertext + ); + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..9e32c04 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,46 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import './ui/pages/home-page.js'; +import './ui/pages/send-page.js'; +import './ui/pages/join-page.js'; +import './ui/pages/expired-page.js'; + +@customElement('filedrop-app') +export class FileDropApp extends LitElement { + static styles = css` + :host { + display: block; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f7; + color: #1d1d1f; + } + `; + + @state() private route = '/'; + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('popstate', () => this.updateRoute()); + this.updateRoute(); + } + + private updateRoute() { + this.route = window.location.pathname; + } + + render() { + switch (this.route) { + case '/send': + return html``; + case '/expired': + return html``; + default: + if (this.route.startsWith('/join/')) { + const roomId = this.route.split('/join/')[1]; + return html``; + } + return html``; + } + } +} diff --git a/web/src/transfer/file-transfer.ts b/web/src/transfer/file-transfer.ts new file mode 100644 index 0000000..def3f58 --- /dev/null +++ b/web/src/transfer/file-transfer.ts @@ -0,0 +1,26 @@ +export interface FileManifest { + type: 'file_manifest'; + transfer_id: string; + files: { + file_id: string; + name_enc: string; + size: number; + mime: string; + chunk_size: number; + chunk_count: number; + }[]; +} + +export interface ChunkAck { + type: 'chunk_ack'; + transfer_id: string; + file_id: string; + chunk_index: number; +} + +export interface TransferComplete { + type: 'transfer_complete'; + transfer_id: string; +} + +export const CHUNK_SIZE = 64 * 1024; // 64KB diff --git a/web/src/transfer/state.ts b/web/src/transfer/state.ts new file mode 100644 index 0000000..f81a086 --- /dev/null +++ b/web/src/transfer/state.ts @@ -0,0 +1,26 @@ +export type TransferState = + | 'idle' + | 'creating' + | 'waiting' + | 'connecting' + | 'transferring' + | 'completed' + | 'cancelled' + | 'expired' + | 'error'; + +export interface TransferStateContext { + state: TransferState; + progress: number; + error: string | null; + roomId: string | null; +} + +export function createInitialState(): TransferStateContext { + return { + state: 'idle', + progress: 0, + error: null, + roomId: null, + }; +} diff --git a/web/src/ui/components/progress-bar.ts b/web/src/ui/components/progress-bar.ts new file mode 100644 index 0000000..44779ce --- /dev/null +++ b/web/src/ui/components/progress-bar.ts @@ -0,0 +1,39 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +@customElement('progress-bar') +export class ProgressBar extends LitElement { + static styles = css` + .bar-container { + width: 100%; + height: 8px; + background: #e5e5ea; + border-radius: 4px; + overflow: hidden; + } + .bar-fill { + height: 100%; + background: #0071e3; + transition: width 0.2s; + } + .label { + font-size: 14px; + color: #666; + margin-top: 8px; + text-align: center; + } + `; + + @property({ type: Number }) progress = 0; + + render() { + return html` +
+
+
+
+
${Math.round(this.progress)}%
+
+ `; + } +} diff --git a/web/src/ui/components/qr-display.ts b/web/src/ui/components/qr-display.ts new file mode 100644 index 0000000..b56f4e2 --- /dev/null +++ b/web/src/ui/components/qr-display.ts @@ -0,0 +1,35 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +@customElement('qr-display') +export class QrDisplay extends LitElement { + static styles = css` + .qr-container { + text-align: center; + padding: 24px; + } + canvas { + border-radius: 12px; + } + `; + + @property() data = ''; + + render() { + return html` +
+

扫码加入

+ +
+ `; + } + + firstUpdated() { + this.renderQR(); + } + + private renderQR() { + // QR code rendering placeholder + // Will use a QR library in production + } +} diff --git a/web/src/ui/pages/expired-page.ts b/web/src/ui/pages/expired-page.ts new file mode 100644 index 0000000..807db22 --- /dev/null +++ b/web/src/ui/pages/expired-page.ts @@ -0,0 +1,35 @@ +import { LitElement, html, css } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +@customElement('expired-page') +export class ExpiredPage extends LitElement { + static styles = css` + .container { + max-width: 480px; + margin: 0 auto; + padding: 48px 24px; + text-align: center; + } + h1 { + font-size: 24px; + margin-bottom: 16px; + } + p { + color: #666; + } + a { + color: #0071e3; + text-decoration: none; + } + `; + + render() { + return html` +
+

会话已过期

+

此会话已超时或被取消

+

返回首页

+
+ `; + } +} diff --git a/web/src/ui/pages/home-page.ts b/web/src/ui/pages/home-page.ts new file mode 100644 index 0000000..0991746 --- /dev/null +++ b/web/src/ui/pages/home-page.ts @@ -0,0 +1,79 @@ +import { LitElement, html, css } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +@customElement('home-page') +export class HomePage extends LitElement { + static styles = css` + .container { + max-width: 480px; + margin: 0 auto; + padding: 48px 24px; + text-align: center; + } + h1 { + font-size: 32px; + font-weight: 600; + margin-bottom: 8px; + } + p { + color: #666; + margin-bottom: 48px; + } + .actions { + display: flex; + flex-direction: column; + gap: 16px; + } + button { + padding: 16px 32px; + font-size: 18px; + border: none; + border-radius: 12px; + cursor: pointer; + transition: background 0.2s; + } + .send-btn { + background: #0071e3; + color: white; + } + .send-btn:hover { + background: #0077ed; + } + .receive-btn { + background: #f5f5f7; + color: #1d1d1f; + border: 1px solid #d2d2d7; + } + .receive-btn:hover { + background: #e8e8ed; + } + `; + + render() { + return html` +
+

FileDrop

+

安全、无痕的文件传输工具

+
+ + +
+
+ `; + } + + private goToSend() { + window.location.pathname = '/send'; + } + + private showJoinInput() { + const code = prompt('请输入房间码:'); + if (code) { + window.location.pathname = `/join/${code}`; + } + } +} diff --git a/web/src/ui/pages/join-page.ts b/web/src/ui/pages/join-page.ts new file mode 100644 index 0000000..222d48f --- /dev/null +++ b/web/src/ui/pages/join-page.ts @@ -0,0 +1,67 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { SessionApi } from '../../api/session-api.js'; + +@customElement('join-page') +export class JoinPage extends LitElement { + static styles = css` + .container { + max-width: 480px; + margin: 0 auto; + padding: 48px 24px; + text-align: center; + } + h1 { + font-size: 24px; + margin-bottom: 24px; + } + .code { + font-size: 20px; + font-family: monospace; + padding: 16px; + background: #f5f5f7; + border-radius: 8px; + margin-bottom: 24px; + } + button { + padding: 16px 32px; + font-size: 16px; + background: #0071e3; + color: white; + border: none; + border-radius: 12px; + cursor: pointer; + } + `; + + @property() roomId = ''; + @state() private joined = false; + + render() { + if (this.joined) { + return html` +
+

已连接

+

等待发送端传输文件...

+
+ `; + } + + return html` +
+

加入房间

+
房间码: ${this.roomId}
+ +
+ `; + } + + private async join() { + const params = new URLSearchParams(window.location.hash.slice(1)); + const token = params.get('t') || ''; + + const api = new SessionApi(); + await api.joinSession(this.roomId, token); + this.joined = true; + } +} diff --git a/web/src/ui/pages/send-page.ts b/web/src/ui/pages/send-page.ts new file mode 100644 index 0000000..d175d20 --- /dev/null +++ b/web/src/ui/pages/send-page.ts @@ -0,0 +1,92 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { SessionApi } from '../../api/session-api.js'; + +@customElement('send-page') +export class SendPage extends LitElement { + static styles = css` + .container { + max-width: 480px; + margin: 0 auto; + padding: 48px 24px; + } + h1 { + font-size: 24px; + margin-bottom: 24px; + } + .file-picker { + border: 2px dashed #d2d2d7; + border-radius: 12px; + padding: 48px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s; + } + .file-picker:hover { + border-color: #0071e3; + } + .code { + font-size: 32px; + font-family: monospace; + letter-spacing: 4px; + text-align: center; + padding: 24px; + background: #f5f5f7; + border-radius: 12px; + margin: 24px 0; + } + .hint { + color: #666; + text-align: center; + } + input[type="file"] { + display: none; + } + `; + + @state() private roomId: string | null = null; + @state() private files: File[] = []; + + render() { + if (!this.roomId) { + return html` +
+

选择文件

+
+

点击选择文件

+ +
+
+ `; + } + + return html` +
+

等待接收

+
${this.roomId}
+

让对方输入此房间码或扫码加入

+
+ `; + } + + private pickFiles() { + document.getElementById('file-input')?.click(); + } + + private async onFilesSelected(e: Event) { + const input = e.target as HTMLInputElement; + if (!input.files?.length) return; + + this.files = Array.from(input.files); + const totalSize = this.files.reduce((sum, f) => sum + f.size, 0); + + const api = new SessionApi(); + const session = await api.createSession(this.files.length, totalSize); + this.roomId = session.room_id; + } +} diff --git a/web/src/webrtc/transport.ts b/web/src/webrtc/transport.ts new file mode 100644 index 0000000..8b6fa41 --- /dev/null +++ b/web/src/webrtc/transport.ts @@ -0,0 +1,70 @@ +export class WebRTCTransport { + private pc: RTCPeerConnection | null = null; + private dc: RTCDataChannel | null = null; + private iceServers: RTCIceServer[]; + + constructor(iceServers: RTCIceServer[]) { + this.iceServers = iceServers; + } + + async createOffer(): Promise<{ sdp: string; candidates: RTCIceCandidate[] }> { + this.pc = new RTCPeerConnection({ iceServers: this.iceServers }); + this.dc = this.pc.createDataChannel('filedrop', { ordered: true }); + + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); + + const candidates = await this.waitForIceGathering(); + return { + sdp: this.pc.localDescription?.sdp || '', + candidates, + }; + } + + async handleAnswer(answer: { sdp: string }) { + if (!this.pc) return; + await this.pc.setRemoteDescription( + new RTCSessionDescription({ type: 'answer', sdp: answer.sdp }) + ); + } + + async handleCandidate(candidate: RTCIceCandidateInit) { + await this.pc?.addIceCandidate(candidate); + } + + onDataChannel(callback: (dc: RTCDataChannel) => void) { + if (this.pc) { + this.pc.ondatachannel = (e) => callback(e.channel); + } + } + + getDataChannel() { + return this.dc; + } + + private waitForIceGathering(): Promise { + return new Promise((resolve) => { + const candidates: RTCIceCandidate[] = []; + if (!this.pc) return resolve(candidates); + + this.pc.onicecandidate = (e) => { + if (e.candidate) candidates.push(e.candidate); + }; + + this.pc.onicegatheringstatechange = () => { + if (this.pc?.iceGatheringState === 'complete') { + resolve(candidates); + } + }; + + setTimeout(() => resolve(candidates), 5000); + }); + } + + close() { + this.dc?.close(); + this.pc?.close(); + this.dc = null; + this.pc = null; + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d33f99a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..911990b --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + outDir: '../server/static', + emptyOutDir: true, + }, + server: { + proxy: { + '/api': 'http://localhost:3000', + '/ws': { + target: 'ws://localhost:3000', + ws: true, + }, + }, + }, +});