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] [dependencies]
eframe = "0.31" eframe = "0.31"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "blocking"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

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