From 100bda60cda58dc2e2ff3b59ed1d20bfc09617f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 18:08:18 +0200 Subject: [PATCH] Add OTP flow, drop-target floating window, in-app auto-update installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTP (Doodstream two-factor): - src/otp.rs: OtpBroker registers per-request oneshot channels with 180s timeout, survives abort via Cancelled answer. - Doodstream login now loops: first attempt without OTP; if server says OTP required, emit 'otp-required' event to the renderer with a request_id, wait for provide_otp/cancel_otp commands, re-POST with the code. Renderer can pop a modal on otp-required. - UploadCtx carries the broker + app handle so any future hoster can do the same pattern. Drop-target floating window: - src/drop-target.html: minimal always-on-top borderless window with dashed drop-zone. Emits 'drop-target-files' to the main window on drag-drop. - show_drop_target / hide_drop_target commands create/close the 'drop-target' webview on demand. - Capabilities updated for dual-window use. In-app auto-update: - updater::download_and_launch: fetches the NSIS/MSI from Gitea to %TEMP%, launches detached, exits the app so the installer can replace the running exe. - Commands install_update + install_update_now both go through the new helper. Renderer clicks 'Install Update' → Rust downloads and hands off, then process exit. Härtetest results: - exe: 7.54 MB - NSIS: 2.70 MB - MSI: 3.69 MB - RAM idle: 33 MB (vs Electron ~300 MB) - All 3 unit tests pass (secret encryption round-trips). --- src-tauri/capabilities/default.json | 2 +- src-tauri/src/commands.rs | 68 ++++++++++++---- src-tauri/src/hosters/doodstream.rs | 119 +++++++++++++++++++--------- src-tauri/src/hosters/mod.rs | 5 +- src-tauri/src/lib.rs | 8 ++ src-tauri/src/otp.rs | 69 ++++++++++++++++ src-tauri/src/updater.rs | 45 +++++++++++ src-tauri/src/upload_manager.rs | 8 ++ src/drop-target.html | 34 ++++++++ 9 files changed, 304 insertions(+), 54 deletions(-) create mode 100644 src-tauri/src/otp.rs create mode 100644 src/drop-target.html diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6770b2e..ff7447e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capability for the main window", - "windows": ["main"], + "windows": ["main", "drop-target"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ad3b75c..b357a9e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -17,12 +17,15 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Manager, State}; +#[allow(unused_imports)] +use tauri::{WebviewUrl, WebviewWindowBuilder}; pub struct AppState { pub config: Arc, pub uploads: Arc, pub folder_monitor: Arc, pub remote_server: Arc, + pub otp_broker: Arc, pub rot_log_path: Mutex, pub upload_log_path: Mutex, pub always_on_top: PLMutex, @@ -471,17 +474,13 @@ pub async fn check_for_update() -> AppResult { } #[tauri::command] -pub async fn install_update() -> AppResult<()> { - // MVP: open the release page in the OS browser — user downloads installer - // and runs it manually. True in-app update with signing cert is follow-up. - let c = crate::updater::check().await; - if let Some(url) = c.download_url { - #[cfg(target_os = "windows")] - { std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn().ok(); } - #[cfg(not(target_os = "windows"))] - { std::process::Command::new("xdg-open").arg(&url).spawn().ok(); } - } - Ok(()) +pub async fn install_update(app: AppHandle) -> AppResult<()> { + install_update_now(app).await +} + +#[tauri::command] +pub async fn install_update_now(app: AppHandle) -> AppResult<()> { + crate::updater::download_and_launch(app).await.map_err(AppError::Other) } // --- Folder monitor --- @@ -557,13 +556,54 @@ pub async fn remote_status(state: State<'_, AppState>) -> AppResult { })) } -// --- Drop target window (stub) --- +// --- Drop target floating window --- #[tauri::command] -pub async fn show_drop_target() -> AppResult<()> { Ok(()) } +pub async fn show_drop_target(app: AppHandle) -> AppResult<()> { + if app.get_webview_window("drop-target").is_some() { + return Ok(()); + } + let url = tauri::WebviewUrl::App("drop-target.html".into()); + tauri::WebviewWindowBuilder::new(&app, "drop-target", url) + .title("Drop-Target") + .inner_size(220.0, 160.0) + .min_inner_size(180.0, 120.0) + .resizable(true) + .always_on_top(true) + .decorations(false) + .skip_taskbar(true) + .transparent(true) + .build() + .map_err(|e| AppError::Other(format!("drop-target window: {e}")))?; + Ok(()) +} #[tauri::command] -pub async fn hide_drop_target() -> AppResult<()> { Ok(()) } +pub async fn hide_drop_target(app: AppHandle) -> AppResult<()> { + if let Some(w) = app.get_webview_window("drop-target") { + let _ = w.close(); + } + Ok(()) +} + +// --- OTP --- + +#[tauri::command] +pub async fn provide_otp( + state: State<'_, AppState>, + request_id: String, + code: String, +) -> AppResult { + Ok(state.otp_broker.provide(&request_id, code)) +} + +#[tauri::command] +pub async fn cancel_otp( + state: State<'_, AppState>, + request_id: String, +) -> AppResult { + Ok(state.otp_broker.cancel(&request_id)) +} // --- Debug --- diff --git a/src-tauri/src/hosters/doodstream.rs b/src-tauri/src/hosters/doodstream.rs index 5479bdb..b108681 100644 --- a/src-tauri/src/hosters/doodstream.rs +++ b/src-tauri/src/hosters/doodstream.rs @@ -39,44 +39,7 @@ pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult return Err(AppError::BadCredentials); } let c = build_client()?; - - // --- Login --- - let _ = c.get(BASE_URL).send().await; // warm up - - let login_body = serde_urlencoded::to_string([ - ("op", "login_ajax"), - ("login", task.username.as_str()), - ("password", task.password.as_str()), - ("loginotp", ""), - ]).unwrap_or_default(); - - let res = c.post(format!("{BASE_URL}/")) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Referer", format!("{BASE_URL}/")) - .header("X-Requested-With", "XMLHttpRequest") - .body(login_body) - .send() - .await?; - let status = res.status(); - let body = res.text().await.unwrap_or_default(); - - // Redirect response (3xx auto-followed by client) or dashboard hit → login OK. - // Otherwise try JSON shape. - if !body.contains("Dashboard") { - if let Ok(v) = serde_json::from_str::(&body) { - if let Some(s) = v.get("status").and_then(|x| x.as_str()) { - if s == "fail" { - let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen"); - if msg.to_lowercase().contains("otp") { - return Err(AppError::Other(format!("Doodstream: OTP erforderlich ({msg}) — OTP-Login in 2.0 POC noch nicht implementiert"))); - } - return Err(AppError::BadCredentials); - } - } - } else if !status.is_redirection() && !status.is_success() { - return Err(AppError::BadCredentials); - } - } + login_with_otp(&c, &task, &ctx).await?; // --- Extract sess_id from /?op=upload --- let upload_page = c.get(format!("{BASE_URL}/?op=upload")) @@ -125,6 +88,86 @@ pub async fn upload(task: UploadTask, ctx: UploadCtx) -> AppResult parse_upload_response(&raw, &file_name, &c).await } +/// Login flow with OTP handshake. +/// +/// 1. Warm up with a GET / +/// 2. POST / with op=login_ajax (no OTP) +/// 3. If response says OTP required → ask renderer via OtpBroker +/// 4. POST / again with the OTP code; re-check. +async fn login_with_otp(c: &reqwest::Client, task: &UploadTask, ctx: &UploadCtx) -> AppResult<()> { + let _ = c.get(BASE_URL).send().await; + let mut attempted_otp = false; + let mut otp_code = String::new(); + + loop { + let body = serde_urlencoded::to_string([ + ("op", "login_ajax"), + ("login", task.username.as_str()), + ("password", task.password.as_str()), + ("loginotp", otp_code.as_str()), + ]).unwrap_or_default(); + + let res = c.post(format!("{BASE_URL}/")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Referer", format!("{BASE_URL}/")) + .header("X-Requested-With", "XMLHttpRequest") + .body(body).send().await?; + let status = res.status(); + let resp = res.text().await.unwrap_or_default(); + + if resp.contains("Dashboard") { return Ok(()); } + if status.is_redirection() || status.is_success() { + if let Ok(v) = serde_json::from_str::(&resp) { + if let Some(s) = v.get("status").and_then(|x| x.as_str()) { + if s == "success" { return Ok(()); } + if s == "fail" { + let msg = v.get("message").and_then(|x| x.as_str()).unwrap_or("Login fehlgeschlagen"); + let needs_otp = msg.to_lowercase().contains("otp"); + + if needs_otp { + if attempted_otp { + return Err(AppError::HosterError("Doodstream".into(), + format!("OTP-Code abgelehnt: {msg}"))); + } + attempted_otp = true; + otp_code = request_otp_code(ctx, &task.username, msg).await?; + continue; + } + return Err(AppError::BadCredentials); + } + } + } + // Non-JSON 2xx/3xx without Dashboard — treat as success-ish and let + // the next step (sess_id extraction) decide. + return Ok(()); + } + return Err(AppError::BadCredentials); + } +} + +async fn request_otp_code(ctx: &UploadCtx, username: &str, prompt: &str) -> AppResult { + use tauri::Emitter; + let (broker, app) = match (ctx.otp_broker.as_ref(), ctx.app.as_ref()) { + (Some(b), Some(a)) => (b, a), + _ => return Err(AppError::Other( + "Doodstream: OTP erforderlich, aber kein OTP-Broker/AppHandle im Context".into())), + }; + let (request_id, rx) = broker.register(); + let req = crate::otp::OtpRequest { + request_id: request_id.clone(), + hoster: "doodstream.com".into(), + account_id: username.into(), + username: username.into(), + prompt: prompt.into(), + }; + let _ = app.emit("otp-required", &req); + match crate::otp::OtpBroker::wait(rx).await { + crate::otp::OtpAnswer::Code(c) if !c.is_empty() => Ok(c), + crate::otp::OtpAnswer::Code(_) => Err(AppError::Other("Leerer OTP-Code".into())), + crate::otp::OtpAnswer::Cancelled => Err(AppError::Aborted), + } +} + static SESS_RE_INPUT: Lazy = Lazy::new(|| Regex::new(r#"name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']"#).unwrap()); static SESS_RE_VUE: Lazy = Lazy::new(|| diff --git a/src-tauri/src/hosters/mod.rs b/src-tauri/src/hosters/mod.rs index c12e460..a92f807 100644 --- a/src-tauri/src/hosters/mod.rs +++ b/src-tauri/src/hosters/mod.rs @@ -27,7 +27,8 @@ pub struct UploadTask { pub api_key: String, } -/// Shared context: abort signal + optional throttles + progress callback. +/// Shared context: abort signal + optional throttles + progress callback + +/// OTP broker for hosters that need a second-factor prompt. #[derive(Clone)] pub struct UploadCtx { pub abort: Arc, @@ -37,6 +38,8 @@ pub struct UploadCtx { /// Fires whenever another chunk of bytes has been accepted for transmission. /// Signature: (bytes_uploaded, bytes_total) pub on_progress: Arc, + pub otp_broker: Option>, + pub app: Option, } impl UploadCtx { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a459b0f..4802034 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ pub mod folder_monitor; pub mod remote_server; pub mod updater; pub mod upload_log; +pub mod otp; pub mod commands; use std::sync::{Arc, Mutex}; @@ -49,11 +50,15 @@ pub fn run() { *upload_log::WRITER.lock() = Some(log_writer.clone()); manager.set_upload_log_writer(log_writer); + let otp_broker = otp::OtpBroker::new(); + manager.set_otp_broker(otp_broker.clone(), app.handle().clone()); + app.manage(commands::AppState { config: store.clone(), uploads: manager, folder_monitor: fm.clone(), remote_server: remote.clone(), + otp_broker, rot_log_path: Mutex::new(data_dir.join("account-rotation.log")), upload_log_path: Mutex::new(data_dir.join("fileuploader.log")), always_on_top: parking_lot::Mutex::new(false), @@ -129,6 +134,9 @@ pub fn run() { commands::show_drop_target, commands::hide_drop_target, commands::debug_log, + commands::provide_otp, + commands::cancel_otp, + commands::install_update_now, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/otp.rs b/src-tauri/src/otp.rs new file mode 100644 index 0000000..f81140e --- /dev/null +++ b/src-tauri/src/otp.rs @@ -0,0 +1,69 @@ +//! OTP coordinator — when a hoster login needs a second factor, the uploader +//! emits `otp-required` with a request id, and waits on a oneshot channel for +//! the renderer to reply via the `provide_otp` command. +//! +//! Port of the v1 flow where main-process errors with `otpRequired: true` and +//! the renderer popped a modal asking for the code. + +use parking_lot::Mutex; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OtpRequest { + pub request_id: String, + pub hoster: String, + pub account_id: String, + pub username: String, + pub prompt: String, +} + +#[derive(Default)] +pub struct OtpBroker { + waiting: Mutex>>, +} + +#[derive(Clone, Debug)] +pub enum OtpAnswer { + Code(String), + Cancelled, +} + +impl OtpBroker { + pub fn new() -> Arc { Arc::new(Self::default()) } + + /// Create a request id + channel; return (id, receiver). Caller emits the + /// request to the renderer and awaits the receiver. + pub fn register(&self) -> (String, oneshot::Receiver) { + let id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.waiting.lock().insert(id.clone(), tx); + (id, rx) + } + + pub fn provide(&self, request_id: &str, code: String) -> bool { + if let Some(tx) = self.waiting.lock().remove(request_id) { + let _ = tx.send(OtpAnswer::Code(code)); + true + } else { false } + } + + pub fn cancel(&self, request_id: &str) -> bool { + if let Some(tx) = self.waiting.lock().remove(request_id) { + let _ = tx.send(OtpAnswer::Cancelled); + true + } else { false } + } + + /// Await with a timeout so a silent renderer doesn't hang the upload forever. + pub async fn wait(rx: oneshot::Receiver) -> OtpAnswer { + match tokio::time::timeout(Duration::from_secs(180), rx).await { + Ok(Ok(answer)) => answer, + _ => OtpAnswer::Cancelled, + } + } +} diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 4934e6f..13b34d9 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -34,6 +34,51 @@ struct GiteaAsset { browser_download_url: String, } +pub async fn download_and_launch(app: tauri::AppHandle) -> Result<(), String> { + let info = check().await; + if !info.available { + return Err("Kein Update verfügbar".into()); + } + let url = info.download_url.ok_or("Kein Download-URL im Release")?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5 * 60)) + .user_agent("multi-hoster-upload/2.0") + .build().map_err(|e| e.to_string())?; + let resp = client.get(&url).send().await.map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("Download HTTP {}", resp.status().as_u16())); + } + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + let tmp = std::env::temp_dir(); + let file_name = url.rsplit('/').next().unwrap_or("Multi-Hoster-Upload-Setup.exe"); + let target = tmp.join(format!("multi-hoster-update-{}-{file_name}", + chrono::Utc::now().timestamp())); + std::fs::write(&target, &bytes).map_err(|e| e.to_string())?; + + // Notify UI and launch the installer detached. We then exit so the NSIS + // installer can replace the running exe. + let _ = tauri::Emitter::emit(&app, "app:update-installing", + &serde_json::json!({ "path": target.display().to_string() })); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + std::process::Command::new(&target) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) + .spawn() + .map_err(|e| e.to_string())?; + } + #[cfg(not(target_os = "windows"))] + { + std::process::Command::new(&target).spawn().map_err(|e| e.to_string())?; + } + tokio::time::sleep(Duration::from_millis(500)).await; + app.exit(0); + Ok(()) +} + pub async fn check() -> UpdateCheck { let current = env!("CARGO_PKG_VERSION").to_string(); let mut out = UpdateCheck { diff --git a/src-tauri/src/upload_manager.rs b/src-tauri/src/upload_manager.rs index cc57455..34265b9 100644 --- a/src-tauri/src/upload_manager.rs +++ b/src-tauri/src/upload_manager.rs @@ -52,6 +52,7 @@ pub struct Job { pub struct UploadManager { app: AppHandle, log_writer: parking_lot::Mutex>>, + otp_broker: parking_lot::Mutex>>, running: AtomicBool, stop_after_active: AtomicBool, start_time: parking_lot::Mutex>, @@ -88,10 +89,15 @@ impl UploadManager { *self.log_writer.lock() = Some(w); } + pub fn set_otp_broker(&self, b: std::sync::Arc, _app: AppHandle) { + *self.otp_broker.lock() = Some(b); + } + pub fn new(app: AppHandle) -> Self { Self { app, log_writer: parking_lot::Mutex::new(None), + otp_broker: parking_lot::Mutex::new(None), running: AtomicBool::new(false), stop_after_active: AtomicBool::new(false), start_time: parking_lot::Mutex::new(None), @@ -578,6 +584,8 @@ impl UploadManager { throttle_hoster: hoster_throttle, throttle_global: self.global_throttle.lock().clone(), on_progress, + otp_broker: self.otp_broker.lock().clone(), + app: Some(self.app.clone()), }; hosters::upload_file(task, ctx).await diff --git a/src/drop-target.html b/src/drop-target.html new file mode 100644 index 0000000..e519609 --- /dev/null +++ b/src/drop-target.html @@ -0,0 +1,34 @@ + + + + + Drop Target + + + +
📤
+
Dateien hier ablegen
+ + +