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