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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
target/
*.log
.env
config/*.yaml

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["server"]
resolver = "2"

38
README.md Normal file
View File

@@ -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/` 目录

View File

@@ -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 presence30-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. 端到端加密需覆盖文件名和文件列表

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

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FileDrop - 安全文件传输</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📦</text></svg>">
</head>
<body>
<filedrop-app></filedrop-app>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

17
web/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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();
}
}

33
web/src/api/signaling.ts Normal file
View File

@@ -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<void>((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;
}
}

View File

@@ -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<ArrayBuffer> {
if (!this.sessionKey) throw new Error('Not initialized');
return crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce },
this.sessionKey,
ciphertext
);
}
}

46
web/src/main.ts Normal file
View File

@@ -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`<send-page></send-page>`;
case '/expired':
return html`<expired-page></expired-page>`;
default:
if (this.route.startsWith('/join/')) {
const roomId = this.route.split('/join/')[1];
return html`<join-page .roomId="${roomId}"></join-page>`;
}
return html`<home-page></home-page>`;
}
}
}

View File

@@ -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

26
web/src/transfer/state.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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`
<div>
<div class="bar-container">
<div class="bar-fill" style="width: ${this.progress}%"></div>
</div>
<div class="label">${Math.round(this.progress)}%</div>
</div>
`;
}
}

View File

@@ -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`
<div class="qr-container">
<p>扫码加入</p>
<canvas id="qr-canvas"></canvas>
</div>
`;
}
firstUpdated() {
this.renderQR();
}
private renderQR() {
// QR code rendering placeholder
// Will use a QR library in production
}
}

View File

@@ -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`
<div class="container">
<h1>会话已过期</h1>
<p>此会话已超时或被取消</p>
<p><a href="/">返回首页</a></p>
</div>
`;
}
}

View File

@@ -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`
<div class="container">
<h1>FileDrop</h1>
<p>安全、无痕的文件传输工具</p>
<div class="actions">
<button class="send-btn" @click=${this.goToSend}>
发送文件
</button>
<button class="receive-btn" @click=${this.showJoinInput}>
接收文件
</button>
</div>
</div>
`;
}
private goToSend() {
window.location.pathname = '/send';
}
private showJoinInput() {
const code = prompt('请输入房间码:');
if (code) {
window.location.pathname = `/join/${code}`;
}
}
}

View File

@@ -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`
<div class="container">
<h1>已连接</h1>
<p>等待发送端传输文件...</p>
</div>
`;
}
return html`
<div class="container">
<h1>加入房间</h1>
<div class="code">房间码: ${this.roomId}</div>
<button @click=${this.join}>确认加入</button>
</div>
`;
}
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;
}
}

View File

@@ -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`
<div class="container">
<h1>选择文件</h1>
<div class="file-picker" @click=${this.pickFiles}>
<p>点击选择文件</p>
<input
type="file"
multiple
@change=${this.onFilesSelected}
id="file-input"
/>
</div>
</div>
`;
}
return html`
<div class="container">
<h1>等待接收</h1>
<div class="code">${this.roomId}</div>
<p class="hint">让对方输入此房间码或扫码加入</p>
</div>
`;
}
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;
}
}

View File

@@ -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<RTCIceCandidate[]> {
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;
}
}

15
web/tsconfig.json Normal file
View File

@@ -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"]
}

17
web/vite.config.ts Normal file
View File

@@ -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,
},
},
},
});