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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
target/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
config/*.yaml
|
||||||
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["server"]
|
||||||
|
resolver = "2"
|
||||||
38
README.md
Normal file
38
README.md
Normal 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/` 目录
|
||||||
392
plans/phase1-architecture.md
Normal file
392
plans/phase1-architecture.md
Normal 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 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. 端到端加密需覆盖文件名和文件列表
|
||||||
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,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal 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
17
web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
web/src/api/session-api.ts
Normal file
27
web/src/api/session-api.ts
Normal 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
33
web/src/api/signaling.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
web/src/crypto/crypto-client.ts
Normal file
47
web/src/crypto/crypto-client.ts
Normal 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
46
web/src/main.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
web/src/transfer/file-transfer.ts
Normal file
26
web/src/transfer/file-transfer.ts
Normal 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
26
web/src/transfer/state.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
web/src/ui/components/progress-bar.ts
Normal file
39
web/src/ui/components/progress-bar.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
web/src/ui/components/qr-display.ts
Normal file
35
web/src/ui/components/qr-display.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
35
web/src/ui/pages/expired-page.ts
Normal file
35
web/src/ui/pages/expired-page.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
web/src/ui/pages/home-page.ts
Normal file
79
web/src/ui/pages/home-page.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
web/src/ui/pages/join-page.ts
Normal file
67
web/src/ui/pages/join-page.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
web/src/ui/pages/send-page.ts
Normal file
92
web/src/ui/pages/send-page.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
web/src/webrtc/transport.ts
Normal file
70
web/src/webrtc/transport.ts
Normal 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
15
web/tsconfig.json
Normal 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
17
web/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user