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