Compare commits

...

19 Commits

Author SHA1 Message Date
OpenCode Bot
7e78c07b42 docs: translate README to Chinese 2026-06-02 22:23:38 +08:00
OpenCode Bot
bbca5a404c docs: add API call methods documentation on homepage 2026-06-02 22:11:42 +08:00
OpenCode Bot
084a0fe6cd feat: add pyinstaller build config and README 2026-05-24 23:01:22 +08:00
OpenCode Bot
ea896cc88f fix: improve thread handling and cleanup 2026-05-24 22:59:26 +08:00
OpenCode Bot
2f23dce4ac feat: add main entry point with upload logic 2026-05-24 22:56:24 +08:00
OpenCode Bot
ac267e2277 fix: improve ui.py code quality 2026-05-24 22:51:53 +08:00
OpenCode Bot
6d7141c5b8 feat: add ui module with drop zone 2026-05-24 22:45:30 +08:00
OpenCode Bot
1b1dc933fc fix: add error handling to load_config 2026-05-24 22:42:55 +08:00
OpenCode Bot
38a273eb3d fix: improve error handling and add timeouts 2026-05-24 22:39:58 +08:00
OpenCode Bot
213716fa44 feat: add api module 2026-05-24 22:36:01 +08:00
OpenCode Bot
e3b3cbc0fe feat: add project基础文件 2026-05-24 22:30:30 +08:00
OpenCode Bot
09ce6bc7bf docs: add temp file trans client design spec 2026-05-24 22:21:40 +08:00
OpenCode Bot
fd4bab03fb feat(desktop): use scp+ssh for file upload, remove HTTP API dependency 2026-05-24 22:13:52 +08:00
OpenCode Bot
d2c60b56c9 feat(desktop): add detailed error messages and SSL bypass checkbox 2026-05-24 21:41:26 +08:00
OpenCode Bot
2255ce9c65 fix(desktop): rewrite upload with blocking reqwest, remove tokio dependency 2026-05-24 21:37:13 +08:00
OpenCode Bot
a50824e831 feat(desktop): add Windows GUI upload client with egui (GNU toolchain) 2026-05-24 17:52:36 +08:00
OpenCode Bot
4561aec7e0 feat(stats): monthly traffic/IP/file count stats displayed on status page 2026-05-11 22:11:11 +08:00
OpenCode Bot
9c7f8f0f62 feat(ui): beautiful status page with file size and expiry info, 1px form hidden 2026-05-11 21:37:36 +08:00
OpenCode Bot
a9c2b69112 feat(ui): polished status page with file size, expiry and traffic info cards 2026-05-11 21:29:07 +08:00
18 changed files with 1215 additions and 123 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ venv/
uploads/ uploads/
.flaskenv .flaskenv
.env .env
target/
Cargo.lock
*.exe

303
README.md
View File

@@ -1,120 +1,153 @@
# Temp File Transfer Service # 临时文件传输服务
A Flask-based personal temporary file sharing service with a Web UI, API access, and SQLite-backed metadata. 基于 Flask 的个人临时文件分享服务,支持 Web 界面、API 接口和 SQLite 数据库。
What it does ## 功能介绍
- Upload files via a Web UI or an API endpoint
- Choose an expiry: 1 hour, 24 hours, or 7 days - 通过 Web 界面或 API 接口上传文件
- Generate a share URL (UUID) for downloaded access - 选择过期时间1 小时、24 小时或 7 天
- File data stored on disk; metadata stored in SQLite - 生成分享链接UUID供下载使用
- Web-based download page and a simple API for programmatic uploads - 文件数据存储在磁盘,元数据存储在 SQLite
- Web 下载页面和简单的 API 接口支持程序化上传
## 技术栈
Tech stack
- Flask (Python) - Flask (Python)
- SQLite (metadata) - SQLite (元数据存储)
- Filesystem storage for actual file data - 文件系统存储实际文件数据
Local development setup ## 本地开发环境搭建
1. Prerequisites
- Python 3.8+ (the project currently uses Python 3.x in this environment) ### 1. 环境要求
- Python 3.8+ (当前环境使用 Python 3.x)
- pip - pip
2. Install dependencies ### 2. 安装依赖
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
3. Run the server ### 3. 运行服务
```bash ```bash
python app.py python app.py
``` ```
4. Access ### 4. 访问地址
- Web UI: http://localhost:5000
- API: see /api/upload and /api/file endpoints
Deployment - Web 界面: http://localhost:5000
- API 接口: 详见 /api/upload 和 /api/file 接口
The production instance runs at: ## 部署说明
- **Domain**: `xiaji-temp.duckdns.org`
- **HTTPS**: Let's Encrypt SSL with auto-renewal (certbot timer)
- **Nginx**: reverse proxy with 500MB upload limit, 80→443 redirect
- **Gunicorn**: 4 workers, systemd-managed
Project layout 生产环境部署信息:
- app.py # Flask application
- config.py # Configuration constants
- database.py # SQLite helpers and data access
- requirements.txt # Python dependencies
- templates/ # Jinja templates (index.html, download.html)
- upload_client.py # Simple API client example for testing
- uploads/ # Storage for uploaded files (created at runtime)
Data model (SQLite) - **域名**: `xiaji-temp.duckdns.org`
- Table: files - **HTTPS**: Let's Encrypt SSL 证书,自动续期 (certbot timer)
- id TEXT PRIMARY KEY - **Nginx**: 反向代理500MB 上传限制80→443 自动跳转
- filename TEXT - **Gunicorn**: 4 个工作者进程systemd 管理
- filepath TEXT
- filesize INTEGER
- expiry_hours INTEGER
- created_at TIMESTAMP
- expires_at TIMESTAMP
Expiry and cleanup ## 项目结构
- Expiry options are defined as 1h, 24h, 7d in config
- A cleanup operation removes expired files from disk and deletes DB rows
- Cleanup is invoked on access endpoints (and can be wired to a cron/daemon later)
Traffic limits | 文件 | 说明 |
- Maximum file size: 500 MB |------|------|
- Per-IP daily traffic limit: 20 GB (upload + download combined) | `app.py` | Flask 应用程序主文件 |
- IP traffic tracked in SQLite `ip_traffic` table, reset daily | `config.py` | 配置常量 |
| `database.py` | SQLite 辅助函数和数据访问 |
| `requirements.txt` | Python 依赖包 |
| `templates/` | Jinja 模板 (index.html, download.html) |
| `upload_client.py` | 简单的 API 客户端示例,用于测试 |
| `uploads/` | 上传文件存储目录 (运行时创建) |
| `temp_file_trans_client/` | PySide6 桌面客户端 |
SSL certificate ## 桌面客户端
- Let's Encrypt certificate deployed via certbot
- Auto-renewal via systemd timer: `certbot.timer`
- Manual renewal test: `certbot renew --dry-run`
Security notes 使用 Python + PySide6 + PyInstaller 构建的桌面客户端,支持拖拽上传文件。
- Do not commit secrets. Secrets should be provided via environment variables in production.
- This repository currently avoids embedding credentials.
Next steps (optional) ### 客户端功能
- Add authentication for admin/API usage
- Add rate limiting and upload size limits per user
- Add automated tests and CI integration
License - 拖拽文件到指定区域上传
- MIT or your preferred license (update as needed) - 点击选择文件上传
- 显示上传进度
- 上传成功显示分享链接
- 支持复制链接到剪贴板
- 可配置服务器地址
API Usage (Python) ### 运行客户端
Upload a file via the API endpoint: ```bash
cd temp_file_trans_client
```python pip install -r requirements.txt
import requests python main.py
BASE_URL = "https://xiaji-temp.duckdns.org"
expiry = "24h" # 1h, 24h, 7d
with open("/path/to/your/file.zip", "rb") as f:
resp = requests.post(
f"{BASE_URL}/api/upload",
files={"file": ("file.zip", f)},
data={"expiry": expiry},
)
if resp.status_code == 200:
data = resp.json()
print(f"Share URL: {data['share_url']}")
print(f"File ID: {data['id']}")
print(f"Size: {data['filesize']} bytes")
else:
print(f"Upload failed: {resp.json()['error']}")
``` ```
Response format: ### 打包为 exe
```bash
cd temp_file_trans_client
pip install pyinstaller
pyinstaller build.spec
```
打包后的可执行文件位于 `dist/temp_file_trans_client.exe`
## 数据模型 (SQLite)
**files 表结构:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | TEXT PRIMARY KEY | 文件唯一标识 (UUID) |
| filename | TEXT | 原始文件名 |
| filepath | TEXT | 文件存储路径 |
| filesize | INTEGER | 文件大小 (字节) |
| expiry_hours | INTEGER | 过期时间 (小时) |
| created_at | TIMESTAMP | 创建时间 |
| expires_at | TIMESTAMP | 过期时间 |
## 过期清理
- 过期选项在 config 中定义为 1h、24h、7d
- 清理操作会删除过期文件并清理数据库记录
- 访问接口时自动触发清理 (后续可配置 cron/定时任务)
## 流量限制
- 最大文件大小500 MB
- 单 IP 每日流量限制20 GB (上传 + 下载合计)
- IP 流量记录在 SQLite `ip_traffic` 表中,每日重置
## SSL 证书
- 使用 Let's Encrypt 证书,通过 certbot 部署
- 自动续期通过 systemd timer: `certbot.timer`
- 手动测试续期: `certbot renew --dry-run`
## 安全说明
- 请勿提交密钥等敏感信息。生产环境应通过环境变量提供
- 当前仓库未嵌入任何凭证
## API 接口说明
### 上传文件
```
POST /api/upload
Content-Type: multipart/form-data
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file | File | 是 | 要上传的文件 |
| expiry | String | 否 | 过期时间: 1h / 24h / 7d (默认 24h) |
**返回示例:**
```json ```json
{ {
"id": "550e8400-e29b-41d4-a716-446655440000", "id": "550e8400-e29b-41d4-a716-446655440000",
@@ -125,23 +158,91 @@ Response format:
} }
``` ```
Get file info via API: ### 获取文件信息
```python
resp = requests.get(f"{BASE_URL}/api/file/{file_id}") ```
if resp.status_code == 200: GET /api/file/{file_id}
info = resp.json()
print(f"Daily upload: {info['daily_upload']} bytes")
print(f"Daily download: {info['daily_download']} bytes")
print(f"Traffic limit: {info['traffic_limit']} bytes (20GB)")
``` ```
Or use the bundled client script: **返回示例:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"filename": "file.zip",
"filesize": 1048576,
"created_at": "2024-01-15 10:30:00",
"expires_at": "2024-01-16 10:30:00",
"daily_upload": 1048576,
"daily_download": 0,
"traffic_limit": 21474836480
}
```
### 下载文件
```
GET /file/{file_id}
GET /download/{file_id}
```
## Python API 调用示例
### 上传文件
```python
import requests
BASE_URL = "https://xiaji-temp.duckdns.org"
expiry = "24h" # 可选: 1h, 24h, 7d
with open("/path/to/your/file.zip", "rb") as f:
resp = requests.post(
f"{BASE_URL}/api/upload",
files={"file": ("file.zip", f)},
data={"expiry": expiry},
verify=False # 对于自签名证书
)
if resp.status_code == 200:
data = resp.json()
print(f"分享链接: {data['share_url']}")
print(f"文件 ID: {data['id']}")
print(f"文件大小: {data['filesize']} bytes")
else:
print(f"上传失败: {resp.json()['error']}")
```
### 获取文件信息
```python
resp = requests.get(f"{BASE_URL}/api/file/{file_id}", verify=False)
if resp.status_code == 200:
info = resp.json()
print(f"每日上传: {info['daily_upload']} bytes")
print(f"每日下载: {info['daily_download']} bytes")
print(f"流量限制: {info['traffic_limit']} bytes (20GB)")
```
## 使用客户端脚本
也可以直接使用附带的客户端脚本上传文件:
```bash ```bash
python upload_client.py /path/to/file.zip 24h python upload_client.py /path/to/file.zip 24h
``` ```
Contributing ## 后续计划
- Pull requests are welcome. Please follow the project style and ensure tests pass.
Contact - [ ] 添加管理员/API 认证
- If you need to reach the maintainer, use your preferred channel. - [ ] 添加用户级别的速率限制和上传大小限制
- [ ] 添加自动化测试和 CI/CD 集成
## 许可证
MIT 许可证 (或您偏好的许可证,按需更新)
## 联系方式
如需联系维护者,请使用您偏好的渠道。

12
app.py
View File

@@ -3,7 +3,10 @@ import uuid
from datetime import datetime from datetime import datetime
from flask import Flask, request, render_template, send_file, jsonify, url_for, abort from flask import Flask, request, render_template, send_file, jsonify, url_for, abort
from config import UPLOAD_FOLDER, SECRET_KEY, MAX_CONTENT_LENGTH, EXPIRY_OPTIONS, DAILY_TRAFFIC_LIMIT from config import UPLOAD_FOLDER, SECRET_KEY, MAX_CONTENT_LENGTH, EXPIRY_OPTIONS, DAILY_TRAFFIC_LIMIT
from database import init_db, add_file, get_file, delete_file, cleanup_expired, add_upload_traffic, add_download_traffic, get_client_ip, is_traffic_exceeded, get_daily_traffic
MAX_FILE_SIZE_MB = MAX_CONTENT_LENGTH // (1024 * 1024)
DAILY_GB = DAILY_TRAFFIC_LIMIT // (1024 * 1024 * 1024)
from database import init_db, add_file, get_file, delete_file, cleanup_expired, add_upload_traffic, add_download_traffic, get_client_ip, is_traffic_exceeded, get_daily_traffic, get_monthly_stats
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = SECRET_KEY app.config['SECRET_KEY'] = SECRET_KEY
@@ -15,7 +18,12 @@ init_db()
@app.route('/') @app.route('/')
def index(): def index():
return render_template('index.html', expiry_options=EXPIRY_OPTIONS) stats = get_monthly_stats()
return render_template('index.html',
expiry_options=EXPIRY_OPTIONS,
max_file_size_mb=MAX_FILE_SIZE_MB,
daily_gb=DAILY_GB,
stats=stats)
@app.route('/upload', methods=['POST']) @app.route('/upload', methods=['POST'])
def upload(): def upload():

View File

@@ -119,3 +119,28 @@ def is_traffic_exceeded(ip, additional_bytes, direction='upload'):
else: else:
total += additional_bytes total += additional_bytes
return total > DAILY_TRAFFIC_LIMIT return total > DAILY_TRAFFIC_LIMIT
def get_monthly_stats():
now = datetime.utcnow()
month_start = now.strftime('%Y-%m-01')
conn = get_db()
traffic_row = conn.execute(
'SELECT COALESCE(SUM(upload_bytes),0) AS up, COALESCE(SUM(download_bytes),0) AS down FROM ip_traffic WHERE date >= ?',
(month_start,)
).fetchone()
ip_count = conn.execute(
'SELECT COUNT(DISTINCT ip) FROM ip_traffic WHERE date >= ?',
(month_start,)
).fetchone()[0]
file_count = conn.execute(
'SELECT COUNT(*) FROM files WHERE created_at >= ?',
(month_start,)
).fetchone()[0]
conn.close()
total = (traffic_row['up'] or 0) + (traffic_row['down'] or 0)
return {
'total_bytes': total,
'total_gb': round(total / (1024**3), 2),
'ip_count': ip_count,
'file_count': file_count,
}

View File

@@ -0,0 +1,5 @@
[build]
target = "x86_64-pc-windows-gnu"
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "target-feature=+crt-static"]

2
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
Cargo.lock

14
desktop/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "temp-file-transfer-desktop"
version = "0.1.0"
edition = "2021"
[dependencies]
eframe = "0.31"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = 2
lto = true
strip = true

6
desktop/build.bat Normal file
View File

@@ -0,0 +1,6 @@
@echo off
set PATH=C:\msys64\mingw64\bin;%PATH%
cargo build --release
echo.
echo Build complete: target\x86_64-pc-windows-gnu\release\temp-file-transfer-desktop.exe
pause

273
desktop/src/main.rs Normal file
View File

@@ -0,0 +1,273 @@
#![windows_subsystem = "windows"]
use eframe::egui;
use std::process::Command;
use std::sync::mpsc;
const SERVER: &str = "23.226.133.121";
const PORT: &str = "10022";
const USERNAME: &str = "root";
const UPLOAD_DIR: &str = "/opt/temp-file-trans/repo/uploads";
const DB_PATH: &str = "/opt/temp-file-trans/repo/files.db";
const BASE_URL: &str = "https://xiaji-temp.duckdns.org";
struct App {
status: String,
share_url: String,
uploading: bool,
upload_rx: Option<mpsc::Receiver<Result<String, String>>>,
expiry_hours: i32,
}
impl App {
fn new() -> Self {
Self {
status: "就绪,拖放文件到下方区域上传".to_owned(),
share_url: String::new(),
uploading: false,
upload_rx: None,
expiry_hours: 24,
}
}
fn start_upload(&mut self, filepath: std::path::PathBuf) {
let (tx, rx) = mpsc::channel();
self.upload_rx = Some(rx);
self.uploading = true;
let expiry_hours = self.expiry_hours;
std::thread::spawn(move || {
let result = do_ssh_upload(&filepath, expiry_hours);
let _ = tx.send(result);
});
}
fn poll_result(&mut self) {
if let Some(rx) = &self.upload_rx {
if let Ok(result) = rx.try_recv() {
self.uploading = false;
self.upload_rx = None;
match result {
Ok(url) => {
self.status = "上传成功".to_owned();
self.share_url = url;
}
Err(e) => {
self.status = format!("上传失败: {}", e);
self.share_url.clear();
}
}
}
}
}
}
fn do_ssh_upload(filepath: &std::path::Path, expiry_hours: i32) -> Result<String, String> {
let local_path = filepath.to_string_lossy();
let filename = filepath
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_owned());
let filesize = std::fs::metadata(&filepath)
.map(|m| m.len() as i64)
.map_err(|e| format!("读取文件失败: {}", e))?;
let remote_dest_arg = format!("root@{}:{}/{}", SERVER, UPLOAD_DIR, filename);
let scp_output = Command::new("scp")
.args(["-P", PORT, "-p", &local_path, &remote_dest_arg])
.output()
.map_err(|e| format!("scp 执行失败: {}", e))?;
if !scp_output.status.success() {
let stderr = String::from_utf8_lossy(&scp_output.stderr);
return Err(format!("scp 上传失败: {}", stderr.trim()));
}
let python_script = format!(
"python3 -c \"import sqlite3,uuid,os,datetime; db='{}'; upload_dir='{}'; orig='{}'; fs={}; eh={}; fid=str(uuid.uuid4()); ext=os.path.splitext(orig)[1]; sn=fid+ext; rp=os.path.join(upload_dir,sn); os.rename(os.path.join(upload_dir,'{}'),rp); now=datetime.datetime.utcnow(); ex=now+datetime.timedelta(hours=eh); c=sqlite3.connect(db); c.execute('INSERT INTO files(id,filename,filepath,filesize,expiry_hours,created_at,expires_at) VALUES(?,?,?,?,?,?,?)',(fid,orig,rp,fs,eh,now,ex)); c.commit();c.close();print('{}/file/'+fid)\"",
DB_PATH, UPLOAD_DIR, filename, filesize, expiry_hours, filename, BASE_URL
);
let ssh_output = Command::new("ssh")
.args(["-p", PORT, &format!("{}@{}", USERNAME, SERVER), &python_script])
.output()
.map_err(|e| format!("ssh 执行失败: {}", e))?;
if !ssh_output.status.success() {
let stderr = String::from_utf8_lossy(&ssh_output.stderr);
return Err(format!("SSH 命令执行失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&ssh_output.stdout).trim().to_string();
if stdout.starts_with("https://") {
Ok(stdout)
} else {
Err(format!("服务器返回异常: {}", stdout))
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_result();
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(16.0);
ui.heading("临时文件传输");
ui.add_space(8.0);
ui.label(
egui::RichText::new("通过 SSH 上传 · 服务器: 23.226.133.121:10022")
.size(12.0)
.color(egui::Color32::from_gray(150)),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("过期时间:");
egui::ComboBox::from_id_salt("expiry")
.selected_text(format!("{} 小时", self.expiry_hours))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.expiry_hours, 1, "1 小时");
ui.selectable_value(&mut self.expiry_hours, 24, "24 小时");
ui.selectable_value(&mut self.expiry_hours, 168, "7 天");
});
});
ui.add_space(12.0);
let drop_frame = egui::Frame::dark_canvas(ui.style())
.inner_margin(egui::Margin::same(40))
.corner_radius(egui::CornerRadius::same(12));
drop_frame.show(ui, |ui| {
ui.set_min_size(egui::vec2(440.0, 180.0));
ui.vertical_centered(|ui| {
ui.add_space(50.0);
if self.uploading {
ui.add(egui::Spinner::new());
ui.add_space(8.0);
ui.label("正在上传...");
} else {
ui.label(
egui::RichText::new("拖放文件到此处上传")
.size(24.0)
.color(egui::Color32::from_gray(180)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("最大 500MB · SSH 直传")
.size(14.0)
.color(egui::Color32::from_gray(140)),
);
}
});
});
let dropped = ctx.input(|i| {
if !i.raw.dropped_files.is_empty() {
Some(i.raw.dropped_files.clone())
} else {
None
}
});
if let Some(files) = dropped {
if !self.uploading {
for f in files {
if let Some(ref path) = f.path {
self.start_upload(path.to_owned());
break;
}
}
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("状态:");
let color = if self.status.starts_with("上传失败") {
egui::Color32::from_rgb(220, 80, 80)
} else if self.status.starts_with("上传成功") {
egui::Color32::from_rgb(80, 200, 80)
} else {
egui::Color32::from_rgb(100, 180, 100)
};
ui.label(egui::RichText::new(&self.status).color(color));
});
ui.add_space(8.0);
if !self.share_url.is_empty() {
ui.horizontal(|ui| {
ui.label("下载链接:");
ui.add_sized(
[400.0, 24.0],
egui::TextEdit::singleline(&mut self.share_url)
.interactive(false),
);
if ui.button("复制").clicked() {
ui.ctx().copy_text(self.share_url.clone());
}
});
}
});
});
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
fn load_chinese_font(ctx: &egui::Context) {
let font_paths = [
"C:\\Windows\\Fonts\\msyh.ttc",
"C:\\Windows\\Fonts\\msyhbd.ttc",
"C:\\Windows\\Fonts\\simhei.ttf",
"C:\\Windows\\Fonts\\simsun.ttc",
];
for path in &font_paths {
if let Ok(data) = std::fs::read(path) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"chinese".to_owned(),
std::sync::Arc::new(egui::FontData::from_owned(data)),
);
fonts
.families
.get_mut(&egui::FontFamily::Proportional)
.unwrap()
.insert(0, "chinese".to_owned());
fonts
.families
.get_mut(&egui::FontFamily::Monospace)
.unwrap()
.insert(0, "chinese".to_owned());
ctx.set_fonts(fonts);
return;
}
}
}
fn main() {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([560.0, 440.0])
.with_resizable(false)
.with_title("临时文件传输"),
..Default::default()
};
let _ = eframe::run_native(
"临时文件传输",
options,
Box::new(|cc| {
load_chinese_font(&cc.egui_ctx);
Ok(Box::new(App::new()))
}),
);
}

View File

@@ -0,0 +1,148 @@
# Temp File Transfer Client - 设计规格
## 1. 项目概述
- **项目名称**: temp_file_trans_client
- **类型**: 桌面客户端应用
- **核心功能**: 通过拖放或点击上传文件到临时文件传输服务器,获取分享链接
- **目标用户**: 需要快速分享文件给其他人的用户
## 2. 技术栈
| 组件 | 技术 |
|------|------|
| GUI框架 | PySide6 |
| HTTP客户端 | requests |
| 打包工具 | PyInstaller |
| Python版本 | 3.8+ |
## 3. 功能列表
### 3.1 服务器配置
- 顶部输入框配置服务器地址
- 默认值: `http://localhost:5000`
- 保存到本地配置文件
### 3.2 文件上传
- 拖放文件到拖放区域上传
- 点击拖放区域选择文件
- 显示上传进度条
- 支持过期时间选择: `1h`, `24h`, `7d`
### 3.3 结果展示
- 上传成功后显示分享链接
- 点击链接复制到剪贴板
- 显示文件ID和文件名
### 3.4 错误处理
- 网络错误提示
- 服务器返回错误展示
- 文件大小超限提示参考服务器500MB限制
## 4. UI设计
### 4.1 窗口布局
```
┌──────────────────────────────────────┐
│ 服务器: [http://localhost:5000] [保存] │ ← 配置栏 (40px)
├──────────────────────────────────────┤
│ │
│ ┌────────────────────────────────┐ │
│ │ │ │
│ │ 📁 拖放文件到此处 │ │ ← 拖放区域 (flex)
│ │ 或点击选择文件 │ │
│ │ │ │
│ └────────────────────────────────┘ │
│ │
│ 过期时间: [24h ▼] [上传文件] │ ← 操作栏 (50px)
│ │
│ ════════════════════════════════ │ ← 进度条 (隐藏/显示)
│ │
│ ┌────────────────────────────────┐ │
│ │ https://xxx/file_id │ │ ← 结果区 (隐藏/显示)
│ │ [复制链接] │ │
│ └────────────────────────────────┘ │
│ │
│ 状态: 就绪 │ ← 状态栏 (30px)
└──────────────────────────────────────┘
```
### 4.2 窗口规格
- 默认尺寸: 500 x 450 px
- 最小尺寸: 400 x 350 px
- 无边框简约设计(可选)
- 居中显示
### 4.3 拖放区域样式
- 虚线边框 (#aaa)
- 圆角 (8px)
- 悬停时边框变蓝 (#3498db)
- 拖拽进入时背景变浅蓝 (#ecf0f1)
## 5. API交互
### 5.1 上传接口
参考 `upload_client.py`,使用 `requests.post` 上传:
```
POST /api/upload
Content-Type: multipart/form-data
file: <binary>
expiry: 1h|24h|7d
```
**成功响应 (200)**:
```json
{
"id": "uuid",
"filename": "example.pdf",
"filesize": 1234567,
"expiry_hours": 24,
"share_url": "http://server/file/<id>"
}
```
**错误响应 (400/429/500)**:
```json
{
"error": "错误描述"
}
```
### 5.2 链接格式
```
分享链接: {服务器地址}/file/{file_id}
直接下载: {服务器地址}/download/{file_id}
```
## 6. 文件结构
```
temp_file_trans_client/
├── main.py # 入口,创建应用和主窗口
├── ui.py # UI组件和布局
├── api.py # API调用封装
├── config.json # 本地配置(服务器地址等)
├── requirements.txt # 依赖
├── build.spec # PyInstaller配置
└── README.md # 使用说明
```
## 7. 打包配置
### PyInstaller spec
- 单文件输出
- 窗口模式(无控制台)
- 包含PySide6和requests
## 8. 验收标准
1. ✅ 拖放文件到拖放区域触发上传
2. ✅ 点击拖放区域可选择文件
3. ✅ 显示上传进度
4. ✅ 上传成功显示分享链接
5. ✅ 可复制分享链接
6. ✅ 可配置服务器地址
7. ✅ 错误情况有友好提示
8. ✅ 可打包成exe运行

View File

@@ -0,0 +1,11 @@
# Temp File Transfer Client
## 安装依赖
pip install -r requirements.txt
## 运行
python main.py
## 打包
pip install pyinstaller
pyinstaller build.spec

View File

@@ -0,0 +1,70 @@
import requests
import json
import os
from requests.exceptions import RequestException
CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json')
def load_config():
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {"server_url": "http://localhost:5000", "default_expiry": "24h"}
return {"server_url": "http://localhost:5000", "default_expiry": "24h"}
def save_config(config):
try:
with open(CONFIG_PATH, 'w') as f:
json.dump(config, f, indent=4)
except IOError as e:
raise Exception(f"保存配置失败: {e}")
def upload_file(filepath, expiry='24h', server_url=None):
if server_url is None:
config = load_config()
server_url = config.get('server_url', 'http://localhost:5000')
url = f"{server_url}/api/upload"
try:
with open(filepath, 'rb') as f:
files = {'file': (os.path.basename(filepath), f)}
data = {'expiry': expiry}
response = requests.post(url, files=files, data=data, timeout=30)
except RequestException as e:
raise Exception(f"网络请求失败: {e}")
if response.status_code == 200:
try:
return response.json()
except json.JSONDecodeError:
raise Exception("服务器响应格式错误")
else:
try:
error_msg = response.json().get('error', '未知错误')
except json.JSONDecodeError:
error_msg = '未知错误'
raise Exception(f"上传失败: {error_msg}")
def get_file_info(file_id, server_url=None):
if server_url is None:
config = load_config()
server_url = config.get('server_url', 'http://localhost:5000')
url = f"{server_url}/api/file/{file_id}"
try:
response = requests.get(url, timeout=30)
except RequestException as e:
raise Exception(f"网络请求失败: {e}")
if response.status_code == 200:
try:
return response.json()
except json.JSONDecodeError:
return None
else:
return None

View File

@@ -0,0 +1,42 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=[('config.json', '.')],
hiddenimports=['PySide6', 'requests'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='temp_file_trans_client',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)

View File

@@ -0,0 +1,4 @@
{
"server_url": "http://localhost:5000",
"default_expiry": "24h"
}

View File

@@ -0,0 +1,113 @@
import sys
import os
from PySide6.QtWidgets import QApplication, QMessageBox, QMainWindow
from PySide6.QtCore import QThread, Signal
from ui import UiCreator
from api import load_config, save_config, upload_file, get_file_info
class UploadThread(QThread):
finished = Signal(dict)
error = Signal(str)
progress = Signal(int)
def __init__(self, filepath, expiry, server_url):
super().__init__()
self.filepath = filepath
self.expiry = expiry
self.server_url = server_url
def run(self):
try:
self.progress.emit(50)
result = upload_file(self.filepath, self.expiry, self.server_url)
self.progress.emit(100)
self.finished.emit(result)
except Exception as e:
self.error.emit(str(e))
class MainWindow:
def __init__(self):
self.app = QApplication(sys.argv)
self.window = QMainWindow()
config = load_config()
self.server_url = config.get('server_url', 'http://localhost:5000')
self.selected_file = None
self.upload_thread = None
central = UiCreator.create_main_ui(self.window)
self.window.server_input.setText(self.server_url)
self.window.save_btn.clicked.connect(self.save_server_config)
self.window.upload_btn.clicked.connect(self.start_upload)
self.window.copy_btn.clicked.connect(self.copy_result)
self.window.drop_zone.file_dropped.connect(self.on_file_selected)
def save_server_config(self):
self.server_url = self.window.server_input.text().strip()
config = {"server_url": self.server_url, "default_expiry": "24h"}
save_config(config)
self.window.status_label.setText("状态: 配置已保存")
def on_file_selected(self, filepath):
self.selected_file = filepath
filename = os.path.basename(filepath)
self.window.drop_label.setText(f"已选择: {filename}")
self.window.status_label.setText(f"状态: 已选择文件: {filename}")
def start_upload(self):
if not self.selected_file:
self.window.status_label.setText("状态: 请先选择文件")
return
if self.upload_thread is not None and self.upload_thread.isRunning():
self.window.status_label.setText("状态: 上传进行中,请等待")
return
expiry = self.window.expiry_combo.currentText()
self.window.progress_bar.setVisible(True)
self.window.upload_btn.setEnabled(False)
self.window.status_label.setText("状态: 上传中...")
self.upload_thread = UploadThread(self.selected_file, expiry, self.server_url)
self.upload_thread.finished.connect(self.on_upload_success)
self.upload_thread.finished.connect(self.upload_thread.deleteLater)
self.upload_thread.error.connect(self.on_upload_error)
self.upload_thread.progress.connect(self.window.progress_bar.setValue)
self.upload_thread.start()
def on_upload_success(self, result):
self.window.progress_bar.setVisible(False)
self.window.upload_btn.setEnabled(True)
share_url = result.get('share_url', '')
file_id = result.get('id', '')
filename = result.get('filename', '')
filesize = result.get('filesize', 0)
result_text = f"✅ 上传成功!\n\n文件: {filename}\n大小: {filesize:,} 字节\n\n分享链接:\n{share_url}\n\n文件ID: {file_id}"
self.window.result_area.setPlainText(result_text)
self.window.result_area.setVisible(True)
self.window.copy_btn.setVisible(True)
self.window.share_url = share_url
self.window.status_label.setText("状态: 上传完成")
def on_upload_error(self, error_msg):
self.window.progress_bar.setVisible(False)
self.window.upload_btn.setEnabled(True)
self.window.status_label.setText(f"状态: 上传失败")
QMessageBox.critical(self.window, "上传失败", error_msg)
def copy_result(self):
if hasattr(self.window, 'share_url'):
clipboard = self.app.clipboard()
clipboard.setText(self.window.share_url)
self.window.status_label.setText("状态: 链接已复制到剪贴板")
def run(self):
self.window.show()
sys.exit(self.app.exec())
if __name__ == '__main__':
MainWindow().run()

View File

@@ -0,0 +1,2 @@
PySide6>=6.5.0
requests>=2.31.0

View File

@@ -0,0 +1,134 @@
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout,
QLineEdit, QPushButton, QLabel, QComboBox,
QProgressBar, QTextEdit, QFrame, QFileDialog)
from PySide6.QtCore import Qt, Signal
class DropZone(QFrame):
file_dropped = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self.file_selected = None
self.setMinimumSize(400, 200)
self.setStyleSheet("""
QFrame {
border: 2px dashed #aaa;
border-radius: 8px;
background-color: #fafafa;
}
QFrame:hover {
border-color: #3498db;
background-color: #ecf0f1;
}
""")
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
self.setStyleSheet("""
QFrame {
border: 2px dashed #3498db;
border-radius: 8px;
background-color: #d5e8f7;
}
""")
def dragLeaveEvent(self, event):
self.setStyleSheet("""
QFrame {
border: 2px dashed #aaa;
border-radius: 8px;
background-color: #fafafa;
}
""")
def dropEvent(self, event):
if event.mimeData().hasUrls():
urls = event.mimeData().urls()
if urls:
self.file_selected = urls[0].toLocalFile()
event.acceptProposedAction()
self.file_dropped.emit(self.file_selected)
def mousePressEvent(self, event):
filepath, _ = QFileDialog.getOpenFileName(self, "选择文件")
if filepath:
self.file_selected = filepath
self.file_dropped.emit(filepath)
class UiCreator:
@staticmethod
def create_main_ui(window):
window.setWindowTitle("临时文件传输客户端")
window.setMinimumSize(400, 350)
window.resize(500, 450)
central_widget = QWidget()
window.setCentralWidget(central_widget)
layout = QVBoxLayout(central_widget)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
server_layout = QHBoxLayout()
server_label = QLabel("服务器:")
window.server_input = QLineEdit("http://localhost:5000")
window.save_btn = QPushButton("保存")
window.save_btn.setFixedWidth(60)
server_layout.addWidget(server_label)
server_layout.addWidget(window.server_input)
server_layout.addWidget(window.save_btn)
layout.addLayout(server_layout)
window.drop_zone = DropZone(window)
window.drop_label = QLabel("📁 拖放文件到此处\n或点击选择文件")
window.drop_label.setAlignment(Qt.AlignCenter)
drop_layout = QVBoxLayout(window.drop_zone)
drop_layout.addWidget(window.drop_label)
layout.addWidget(window.drop_zone)
action_layout = QHBoxLayout()
expiry_label = QLabel("过期时间:")
window.expiry_combo = QComboBox()
window.expiry_combo.addItems(["1h", "24h", "7d"])
window.expiry_combo.setCurrentText("24h")
window.upload_btn = QPushButton("上传文件")
window.upload_btn.setFixedWidth(100)
action_layout.addWidget(expiry_label)
action_layout.addWidget(window.expiry_combo)
action_layout.addStretch()
action_layout.addWidget(window.upload_btn)
layout.addLayout(action_layout)
window.progress_bar = QProgressBar()
window.progress_bar.setVisible(False)
window.progress_bar.setTextVisible(True)
layout.addWidget(window.progress_bar)
window.result_area = QTextEdit()
window.result_area.setVisible(False)
window.result_area.setReadOnly(True)
window.result_area.setMaximumHeight(80)
window.result_area.setStyleSheet("""
QTextEdit {
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
}
""")
layout.addWidget(window.result_area)
window.copy_btn = QPushButton("复制链接")
window.copy_btn.setVisible(False)
window.copy_btn.setFixedWidth(80)
copy_layout = QHBoxLayout()
copy_layout.addStretch()
copy_layout.addWidget(window.copy_btn)
layout.addLayout(copy_layout)
window.status_label = QLabel("状态: 就绪")
window.status_label.setStyleSheet("color: #666; font-size: 12px;")
layout.addWidget(window.status_label)
return central_widget

View File

@@ -4,29 +4,160 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>临时文件传输</title> <title>临时文件传输</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { margin: 0; padding: 0; } body {
.container { width: auto; height: auto; overflow: visible; } margin: 0; padding: 0;
h1 { font-size: 16px; color: #333; height: auto; overflow: visible; } font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
.upload-area { border: 1px solid transparent; border-radius: 0; padding: 0; width: 1px; height: 1px; overflow: hidden; cursor: pointer; } background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
.upload-area:hover, .upload-area.dragover { border-color: transparent; background: transparent; } min-height: 100vh;
.upload-area input { display: none; } display: flex; justify-content: center; align-items: center;
.upload-area p { font-size: 1px; color: transparent; } }
.expiry { width: 1px; height: 1px; overflow: hidden; } .status-card {
.expiry label { font-size: 1px; color: transparent; height: 1px; overflow: hidden; } background: white;
.expiry select { width: 1px; height: 1px; font-size: 1px; padding: 0; border: none; } border-radius: 16px;
button { width: 1px; height: 1px; padding: 0; margin: 0; background: transparent; color: transparent; border: none; border-radius: 0; font-size: 1px; cursor: pointer; overflow: hidden; } padding: 48px 56px;
button:hover { background: transparent; } box-shadow: 0 20px 60px rgba(0,0,0,0.15);
button:disabled { background: transparent; cursor: not-allowed; } text-align: center;
.result { width: 1px; height: 1px; overflow: hidden; } max-width: 480px;
.result a { font-size: 1px; color: transparent; } }
.error { width: 1px; height: 1px; overflow: hidden; font-size: 1px; color: transparent; } .status-icon {
</style> font-size: 56px;
margin-bottom: 16px;
}
h1 {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 8px;
}
.status-tag {
display: inline-block;
background: #d4edda;
color: #155724;
padding: 4px 14px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
margin-bottom: 24px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #eef0f5;
font-size: 15px;
color: #555;
}
.info-row:last-child { border-bottom: none; }
.info-label { font-weight: 500; color: #888; }
.info-value { font-weight: 600; color: #1a1a2e; }
.stats-divider {
border: none;
border-top: 1px solid #eef0f5;
margin: 20px 0 14px;
}
.stats-title {
font-size: 12px;
color: #aaa;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 12px;
}
.stat-label { color: #aaa; }
.stat-value { color: #666; font-weight: 500; }
.container { width: 1px; height: 1px; overflow: hidden; }
.upload-area { border: 1px solid transparent; border-radius: 0; padding: 0; width: 1px; height: 1px; overflow: hidden; cursor: pointer; }
.upload-area:hover, .upload-area.dragover { border-color: transparent; background: transparent; }
.upload-area input { display: none; }
.upload-area p { font-size: 1px; color: transparent; }
.expiry { width: 1px; height: 1px; overflow: hidden; }
.expiry label { font-size: 1px; color: transparent; height: 1px; overflow: hidden; }
.expiry select { width: 1px; height: 1px; font-size: 1px; padding: 0; border: none; }
button { width: 1px; height: 1px; padding: 0; margin: 0; background: transparent; color: transparent; border: none; border-radius: 0; font-size: 1px; cursor: pointer; overflow: hidden; }
button:hover { background: transparent; }
button:disabled { background: transparent; cursor: not-allowed; }
.result { width: 1px; height: 1px; overflow: hidden; }
.result a { font-size: 1px; color: transparent; }
.error { width: 1px; height: 1px; overflow: hidden; font-size: 1px; color: transparent; }
</style>
</head> </head>
<body> <body>
<div class="container"> <div class="status-card">
<div class="status-icon">&#9889;</div>
<h1>服务正在运行中</h1> <h1>服务正在运行中</h1>
<span class="status-tag">运行正常</span>
<div class="info-row">
<span class="info-label">最大文件大小</span>
<span class="info-value">{{ max_file_size_mb }} MB</span>
</div>
<div class="info-row">
<span class="info-label">可保存时间</span>
<span class="info-value">1小时 / 24小时 / 7天</span>
</div>
<div class="info-row">
<span class="info-label">每日流量限制</span>
<span class="info-value">{{ daily_gb }} GB / IP</span>
</div>
<div class="info-row">
<span class="info-label">API 上传</span>
<span class="info-value">POST /api/upload</span>
</div>
<hr class="stats-divider">
<div class="stats-title">API 调用方法 (参考 Flask 风格)</div>
<div style="text-align: left; margin-top: 12px; padding: 12px; background: #f8f9fa; border-radius: 8px; font-size: 12px; color: #444; font-family: 'Courier New', monospace; line-height: 1.5; overflow-x: auto;">
<strong>1. 上传文件:</strong><br>
<code style="color: #d63384;">POST /api/upload</code><br>
Headers: <code style="color: #0d6efd;">Content-Type: multipart/form-data</code><br>
Form-data:<br>
· file: &lt;文件内容&gt;<br>
· expiry: 1h | 24h | 7d (可选, 默认24h)<br><br>
<strong>2. 获取文件信息:</strong><br>
<code style="color: #d63384;">GET /api/file/&lt;file_id&gt;</code><br><br>
<strong>3. 下载/访问文件:</strong><br>
<code style="color: #d63384;">GET /file/&lt;file_id&gt;</code><br><br>
<strong>cURL 示例:</strong><br>
<span style="color: #198754;">curl -k</span> -X POST <span style="color: #0d6efd;">"https://xiaji-temp.duckdns.org/api/upload"</span> \
&nbsp;&nbsp;-F "file=@example.jpg" \
&nbsp;&nbsp;-F "expiry=24h"<br><br>
<strong>Python requests 示例:</strong><br>
<span style="color: #0d6efd;">import</span> requests<br><br>
url = <span style="color: #198754;">"https://xiaji-temp.duckdns.org/api/upload"</span><br>
files = {<span style="color: #198754;">'file'</span>: <span style="color #d63384">open</span>(<span style="color: #198754;">'example.jpg'</span>, <span style="color: #198754;">'rb'</span>)}<br>
data = {<span style="color: #198754;">'expiry'</span>: <span style="color: #198754;">'24h'</span>}<br>
resp = requests.post(url, files=files, data=data, verify=<span style="color: #d63384">False</span>)<br>
<span style="color: #0d6efd;">print</span>(resp.json())
</div>
<hr class="stats-divider">
<div class="stats-title">本月统计</div>
<div class="stat-item">
<span class="stat-label">总流量</span>
<span class="stat-value">{{ stats.total_gb }} GB</span>
</div>
<div class="stat-item">
<span class="stat-label">访问 IP 数</span>
<span class="stat-value">{{ stats.ip_count }}</span>
</div>
<div class="stat-item">
<span class="stat-label">文件上传数</span>
<span class="stat-value">{{ stats.file_count }}</span>
</div>
</div>
<div class="container">
<div class="upload-area" id="uploadArea"> <div class="upload-area" id="uploadArea">
<input type="file" id="fileInput"> <input type="file" id="fileInput">
<p>点击或拖拽文件到此处</p> <p>点击或拖拽文件到此处</p>