feat(desktop): use scp+ssh for file upload, remove HTTP API dependency
This commit is contained in:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user