feat(desktop): use scp+ssh for file upload, remove HTTP API dependency

This commit is contained in:
OpenCode Bot
2026-05-24 22:13:52 +08:00
parent d2c60b56c9
commit fd4bab03fb
2 changed files with 64 additions and 90 deletions

View File

@@ -5,7 +5,6 @@ edition = "2021"
[dependencies]
eframe = "0.31"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -1,38 +1,32 @@
#![windows_subsystem = "windows"]
use eframe::egui;
use reqwest::blocking::multipart;
use std::process::Command;
use std::sync::mpsc;
#[derive(serde::Deserialize)]
struct UploadResponse {
share_url: Option<String>,
error: Option<String>,
}
enum UploadResult {
Ok(String),
Err(String),
}
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 {
api_url: String,
status: String,
share_url: String,
upload_rx: Option<mpsc::Receiver<UploadResult>>,
uploading: bool,
skip_ssl: bool,
upload_rx: Option<mpsc::Receiver<Result<String, String>>>,
expiry_hours: i32,
}
impl App {
fn new() -> Self {
Self {
api_url: "https://xiaji-temp.duckdns.org/api/upload".to_owned(),
status: "就绪,拖放文件到下方区域上传".to_owned(),
share_url: String::new(),
upload_rx: None,
uploading: false,
skip_ssl: false,
upload_rx: None,
expiry_hours: 24,
}
}
@@ -40,11 +34,10 @@ impl App {
let (tx, rx) = mpsc::channel();
self.upload_rx = Some(rx);
self.uploading = true;
let api_url = self.api_url.clone();
let skip_ssl = self.skip_ssl;
let expiry_hours = self.expiry_hours;
std::thread::spawn(move || {
let result = do_upload(&api_url, &filepath, skip_ssl);
let result = do_ssh_upload(&filepath, expiry_hours);
let _ = tx.send(result);
});
}
@@ -55,11 +48,11 @@ impl App {
self.uploading = false;
self.upload_rx = None;
match result {
UploadResult::Ok(url) => {
Ok(url) => {
self.status = "上传成功".to_owned();
self.share_url = url;
}
UploadResult::Err(e) => {
Err(e) => {
self.status = format!("上传失败: {}", e);
self.share_url.clear();
}
@@ -69,71 +62,50 @@ impl App {
}
}
fn do_upload(api_url: &str, filepath: &std::path::Path, skip_ssl: bool) -> UploadResult {
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 data = match std::fs::read(filepath) {
Ok(d) => d,
Err(e) => return UploadResult::Err(format!("读取文件失败: {}", e)),
};
let filesize = std::fs::metadata(&filepath)
.map(|m| m.len() as i64)
.map_err(|e| format!("读取文件失败: {}", e))?;
let part = multipart::Part::bytes(data).file_name(filename);
let form = multipart::Form::new()
.part("file", part)
.text("expiry", "24h");
let remote_dest_arg = format!("root@{}:{}/{}", SERVER, UPLOAD_DIR, filename);
let mut builder = reqwest::blocking::Client::builder()
.user_agent("TempFileTransfer-Desktop/0.2")
.timeout(std::time::Duration::from_secs(300));
let scp_output = Command::new("scp")
.args(["-P", PORT, "-p", &local_path, &remote_dest_arg])
.output()
.map_err(|e| format!("scp 执行失败: {}", e))?;
if skip_ssl {
builder = builder.danger_accept_invalid_certs(true);
if !scp_output.status.success() {
let stderr = String::from_utf8_lossy(&scp_output.stderr);
return Err(format!("scp 上传失败: {}", stderr.trim()));
}
let client = match builder.build() {
Ok(c) => c,
Err(e) => return UploadResult::Err(format!("创建客户端失败: {}", e)),
};
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 resp = match client.post(api_url).multipart(form).send() {
Ok(r) => r,
Err(e) => {
let detail = if e.is_connect() {
format!("连接失败(无法连接到服务器): {}", e)
} else if e.is_timeout() {
format!("连接超时: {}", e)
} else if e.is_body() {
format!("SSL/数据传输出错请尝试勾选「跳过SSL验证」: {}", e)
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 {
format!("{}", e)
};
return UploadResult::Err(detail);
}
};
let status = resp.status();
let body = match resp.text() {
Ok(b) => b,
Err(e) => return UploadResult::Err(format!("读取响应失败: {}", e)),
};
if !status.is_success() {
let err: UploadResponse = serde_json::from_str(&body).unwrap_or(UploadResponse {
share_url: None,
error: Some(body),
});
return UploadResult::Err(err.error.unwrap_or_else(|| format!("HTTP {}", status)));
}
match serde_json::from_str::<UploadResponse>(&body) {
Ok(r) => match r.share_url {
Some(url) => UploadResult::Ok(url),
None => UploadResult::Err(r.error.unwrap_or_else(|| "未知服务器响应".to_owned())),
},
Err(e) => UploadResult::Err(format!("解析响应失败: {} - body: {}", e, body)),
Err(format!("服务器返回异常: {}", stdout))
}
}
@@ -147,31 +119,34 @@ impl eframe::App for App {
ui.heading("临时文件传输");
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("API 地址:");
ui.add_sized(
[400.0, 24.0],
egui::TextEdit::singleline(&mut self.api_url)
.hint_text("https://xiaji-temp.duckdns.org/api/upload"),
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.checkbox(&mut self.skip_ssl, "跳过 SSL 证书验证(仅当连接失败时尝试)");
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(8.0);
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, 200.0));
ui.set_min_size(egui::vec2(440.0, 180.0));
ui.vertical_centered(|ui| {
ui.add_space(60.0);
ui.add_space(50.0);
if self.uploading {
ui.add(egui::Spinner::new());
ui.add_space(8.0);
@@ -184,7 +159,7 @@ impl eframe::App for App {
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("最大 500MB · 过期 24小时")
egui::RichText::new("最大 500MB · SSH 直传")
.size(14.0)
.color(egui::Color32::from_gray(140)),
);
@@ -281,7 +256,7 @@ fn load_chinese_font(ctx: &egui::Context) {
fn main() {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([560.0, 500.0])
.with_inner_size([560.0, 440.0])
.with_resizable(false)
.with_title("临时文件传输"),
..Default::default()