init: FileDrop phase1 architecture and scaffold

- Rust axum signaling server with WebSocket support
- Lit + TypeScript frontend with Vite
- Redis session storage with TTL
- WebRTC transport and crypto client stubs
- Phase1 architecture plan in plans/
- Deploy directory structure prepared
This commit is contained in:
2026-04-09 10:32:06 +08:00
commit 4b34a85599
34 changed files with 1561 additions and 0 deletions

13
web/index.html Normal file
View File

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

17
web/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "filedrop-web",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lit": "^3.1.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,27 @@
export class SessionApi {
private baseUrl: string;
constructor(baseUrl: string = '') {
this.baseUrl = baseUrl;
}
async createSession(fileCount: number, totalSize: number) {
const res = await fetch(`${this.baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_count: fileCount, total_size: totalSize }),
});
if (!res.ok) throw new Error('Failed to create session');
return res.json();
}
async joinSession(roomId: string, joinToken: string) {
const res = await fetch(`${this.baseUrl}/api/sessions/${roomId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ join_token: joinToken }),
});
if (!res.ok) throw new Error('Failed to join session');
return res.json();
}
}

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

@@ -0,0 +1,33 @@
export class SignalingClient {
private ws: WebSocket | null = null;
private messageHandler: ((msg: any) => void) | null = null;
connect(url: string) {
this.ws = new WebSocket(url);
return new Promise<void>((resolve, reject) => {
if (!this.ws) return reject('No WebSocket');
this.ws.onopen = () => resolve();
this.ws.onerror = reject;
});
}
send(msg: any) {
this.ws?.send(JSON.stringify(msg));
}
onMessage(callback: (msg: any) => void) {
this.messageHandler = callback;
if (this.ws) {
this.ws.onmessage = (e) => {
try {
this.messageHandler?.(JSON.parse(e.data));
} catch {}
};
}
}
close() {
this.ws?.close();
this.ws = null;
}
}

View File

@@ -0,0 +1,47 @@
export class CryptoClient {
private sessionKey: CryptoKey | null = null;
async initFromSecret(secret: string) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
'HKDF',
false,
['deriveKey']
);
this.sessionKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode('filedrop-v1'),
info: encoder.encode('file-transfer-key'),
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async encrypt(data: ArrayBuffer): Promise<{ ciphertext: ArrayBuffer; nonce: Uint8Array }> {
if (!this.sessionKey) throw new Error('Not initialized');
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce },
this.sessionKey,
data
);
return { ciphertext, nonce };
}
async decrypt(ciphertext: ArrayBuffer, nonce: Uint8Array): Promise<ArrayBuffer> {
if (!this.sessionKey) throw new Error('Not initialized');
return crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce },
this.sessionKey,
ciphertext
);
}
}

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

@@ -0,0 +1,46 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import './ui/pages/home-page.js';
import './ui/pages/send-page.js';
import './ui/pages/join-page.js';
import './ui/pages/expired-page.js';
@customElement('filedrop-app')
export class FileDropApp extends LitElement {
static styles = css`
:host {
display: block;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
}
`;
@state() private route = '/';
connectedCallback() {
super.connectedCallback();
window.addEventListener('popstate', () => this.updateRoute());
this.updateRoute();
}
private updateRoute() {
this.route = window.location.pathname;
}
render() {
switch (this.route) {
case '/send':
return html`<send-page></send-page>`;
case '/expired':
return html`<expired-page></expired-page>`;
default:
if (this.route.startsWith('/join/')) {
const roomId = this.route.split('/join/')[1];
return html`<join-page .roomId="${roomId}"></join-page>`;
}
return html`<home-page></home-page>`;
}
}
}

View File

@@ -0,0 +1,26 @@
export interface FileManifest {
type: 'file_manifest';
transfer_id: string;
files: {
file_id: string;
name_enc: string;
size: number;
mime: string;
chunk_size: number;
chunk_count: number;
}[];
}
export interface ChunkAck {
type: 'chunk_ack';
transfer_id: string;
file_id: string;
chunk_index: number;
}
export interface TransferComplete {
type: 'transfer_complete';
transfer_id: string;
}
export const CHUNK_SIZE = 64 * 1024; // 64KB

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

@@ -0,0 +1,26 @@
export type TransferState =
| 'idle'
| 'creating'
| 'waiting'
| 'connecting'
| 'transferring'
| 'completed'
| 'cancelled'
| 'expired'
| 'error';
export interface TransferStateContext {
state: TransferState;
progress: number;
error: string | null;
roomId: string | null;
}
export function createInitialState(): TransferStateContext {
return {
state: 'idle',
progress: 0,
error: null,
roomId: null,
};
}

View File

@@ -0,0 +1,39 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('progress-bar')
export class ProgressBar extends LitElement {
static styles = css`
.bar-container {
width: 100%;
height: 8px;
background: #e5e5ea;
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #0071e3;
transition: width 0.2s;
}
.label {
font-size: 14px;
color: #666;
margin-top: 8px;
text-align: center;
}
`;
@property({ type: Number }) progress = 0;
render() {
return html`
<div>
<div class="bar-container">
<div class="bar-fill" style="width: ${this.progress}%"></div>
</div>
<div class="label">${Math.round(this.progress)}%</div>
</div>
`;
}
}

View File

@@ -0,0 +1,35 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('qr-display')
export class QrDisplay extends LitElement {
static styles = css`
.qr-container {
text-align: center;
padding: 24px;
}
canvas {
border-radius: 12px;
}
`;
@property() data = '';
render() {
return html`
<div class="qr-container">
<p>扫码加入</p>
<canvas id="qr-canvas"></canvas>
</div>
`;
}
firstUpdated() {
this.renderQR();
}
private renderQR() {
// QR code rendering placeholder
// Will use a QR library in production
}
}

View File

@@ -0,0 +1,35 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('expired-page')
export class ExpiredPage extends LitElement {
static styles = css`
.container {
max-width: 480px;
margin: 0 auto;
padding: 48px 24px;
text-align: center;
}
h1 {
font-size: 24px;
margin-bottom: 16px;
}
p {
color: #666;
}
a {
color: #0071e3;
text-decoration: none;
}
`;
render() {
return html`
<div class="container">
<h1>会话已过期</h1>
<p>此会话已超时或被取消</p>
<p><a href="/">返回首页</a></p>
</div>
`;
}
}

View File

@@ -0,0 +1,79 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('home-page')
export class HomePage extends LitElement {
static styles = css`
.container {
max-width: 480px;
margin: 0 auto;
padding: 48px 24px;
text-align: center;
}
h1 {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
}
p {
color: #666;
margin-bottom: 48px;
}
.actions {
display: flex;
flex-direction: column;
gap: 16px;
}
button {
padding: 16px 32px;
font-size: 18px;
border: none;
border-radius: 12px;
cursor: pointer;
transition: background 0.2s;
}
.send-btn {
background: #0071e3;
color: white;
}
.send-btn:hover {
background: #0077ed;
}
.receive-btn {
background: #f5f5f7;
color: #1d1d1f;
border: 1px solid #d2d2d7;
}
.receive-btn:hover {
background: #e8e8ed;
}
`;
render() {
return html`
<div class="container">
<h1>FileDrop</h1>
<p>安全、无痕的文件传输工具</p>
<div class="actions">
<button class="send-btn" @click=${this.goToSend}>
发送文件
</button>
<button class="receive-btn" @click=${this.showJoinInput}>
接收文件
</button>
</div>
</div>
`;
}
private goToSend() {
window.location.pathname = '/send';
}
private showJoinInput() {
const code = prompt('请输入房间码:');
if (code) {
window.location.pathname = `/join/${code}`;
}
}
}

View File

@@ -0,0 +1,67 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { SessionApi } from '../../api/session-api.js';
@customElement('join-page')
export class JoinPage extends LitElement {
static styles = css`
.container {
max-width: 480px;
margin: 0 auto;
padding: 48px 24px;
text-align: center;
}
h1 {
font-size: 24px;
margin-bottom: 24px;
}
.code {
font-size: 20px;
font-family: monospace;
padding: 16px;
background: #f5f5f7;
border-radius: 8px;
margin-bottom: 24px;
}
button {
padding: 16px 32px;
font-size: 16px;
background: #0071e3;
color: white;
border: none;
border-radius: 12px;
cursor: pointer;
}
`;
@property() roomId = '';
@state() private joined = false;
render() {
if (this.joined) {
return html`
<div class="container">
<h1>已连接</h1>
<p>等待发送端传输文件...</p>
</div>
`;
}
return html`
<div class="container">
<h1>加入房间</h1>
<div class="code">房间码: ${this.roomId}</div>
<button @click=${this.join}>确认加入</button>
</div>
`;
}
private async join() {
const params = new URLSearchParams(window.location.hash.slice(1));
const token = params.get('t') || '';
const api = new SessionApi();
await api.joinSession(this.roomId, token);
this.joined = true;
}
}

View File

@@ -0,0 +1,92 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { SessionApi } from '../../api/session-api.js';
@customElement('send-page')
export class SendPage extends LitElement {
static styles = css`
.container {
max-width: 480px;
margin: 0 auto;
padding: 48px 24px;
}
h1 {
font-size: 24px;
margin-bottom: 24px;
}
.file-picker {
border: 2px dashed #d2d2d7;
border-radius: 12px;
padding: 48px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
}
.file-picker:hover {
border-color: #0071e3;
}
.code {
font-size: 32px;
font-family: monospace;
letter-spacing: 4px;
text-align: center;
padding: 24px;
background: #f5f5f7;
border-radius: 12px;
margin: 24px 0;
}
.hint {
color: #666;
text-align: center;
}
input[type="file"] {
display: none;
}
`;
@state() private roomId: string | null = null;
@state() private files: File[] = [];
render() {
if (!this.roomId) {
return html`
<div class="container">
<h1>选择文件</h1>
<div class="file-picker" @click=${this.pickFiles}>
<p>点击选择文件</p>
<input
type="file"
multiple
@change=${this.onFilesSelected}
id="file-input"
/>
</div>
</div>
`;
}
return html`
<div class="container">
<h1>等待接收</h1>
<div class="code">${this.roomId}</div>
<p class="hint">让对方输入此房间码或扫码加入</p>
</div>
`;
}
private pickFiles() {
document.getElementById('file-input')?.click();
}
private async onFilesSelected(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
this.files = Array.from(input.files);
const totalSize = this.files.reduce((sum, f) => sum + f.size, 0);
const api = new SessionApi();
const session = await api.createSession(this.files.length, totalSize);
this.roomId = session.room_id;
}
}

View File

@@ -0,0 +1,70 @@
export class WebRTCTransport {
private pc: RTCPeerConnection | null = null;
private dc: RTCDataChannel | null = null;
private iceServers: RTCIceServer[];
constructor(iceServers: RTCIceServer[]) {
this.iceServers = iceServers;
}
async createOffer(): Promise<{ sdp: string; candidates: RTCIceCandidate[] }> {
this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
this.dc = this.pc.createDataChannel('filedrop', { ordered: true });
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
const candidates = await this.waitForIceGathering();
return {
sdp: this.pc.localDescription?.sdp || '',
candidates,
};
}
async handleAnswer(answer: { sdp: string }) {
if (!this.pc) return;
await this.pc.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: answer.sdp })
);
}
async handleCandidate(candidate: RTCIceCandidateInit) {
await this.pc?.addIceCandidate(candidate);
}
onDataChannel(callback: (dc: RTCDataChannel) => void) {
if (this.pc) {
this.pc.ondatachannel = (e) => callback(e.channel);
}
}
getDataChannel() {
return this.dc;
}
private waitForIceGathering(): Promise<RTCIceCandidate[]> {
return new Promise((resolve) => {
const candidates: RTCIceCandidate[] = [];
if (!this.pc) return resolve(candidates);
this.pc.onicecandidate = (e) => {
if (e.candidate) candidates.push(e.candidate);
};
this.pc.onicegatheringstatechange = () => {
if (this.pc?.iceGatheringState === 'complete') {
resolve(candidates);
}
};
setTimeout(() => resolve(candidates), 5000);
});
}
close() {
this.dc?.close();
this.pc?.close();
this.dc = null;
this.pc = null;
}
}

15
web/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src"]
}

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

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: '../server/static',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://localhost:3000',
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
});