diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c41f69f..599d4e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ use che_tauri::{ TauriApi, }; use tauri::Manager; +use tauri_plugin_opener::OpenerExt; use crate::sync::SyncContractsResult; @@ -256,23 +257,76 @@ async fn sync_contracts( } #[tauri::command] -async fn load_application_file(local_path: String, scan_url: String) -> Result, ApiError> { - if !local_path.is_empty() { - match tokio::fs::metadata(&local_path).await { - Ok(metadata) if metadata.len() > 0 => { - return tokio::fs::read(&local_path) - .await - .map_err(|error| ApiError::new("file_error", error.to_string())); - } - Ok(_) | Err(_) => {} - } - } +async fn load_remote_file( + app: tauri::AppHandle, + api: tauri::State<'_, TauriApi>, + url: String, +) -> Result, ApiError> { + let path = cache_remote_file(&app, &api, &url).await?; + tokio::fs::read(path) + .await + .map_err(|error| ApiError::new("file_error", error.to_string())) +} - if scan_url.is_empty() { +#[tauri::command] +async fn ensure_remote_file( + app: tauri::AppHandle, + api: tauri::State<'_, TauriApi>, + url: String, +) -> Result { + let path = cache_remote_file(&app, &api, &url).await?; + Ok(path.to_string_lossy().into_owned()) +} + +#[tauri::command] +async fn open_remote_file( + app: tauri::AppHandle, + api: tauri::State<'_, TauriApi>, + url: String, +) -> Result<(), ApiError> { + let path = cache_remote_file(&app, &api, &url).await?; + app.opener() + .open_path(path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| ApiError::new("file_error", error.to_string())) +} + +async fn cache_remote_file( + app: &tauri::AppHandle, + api: &tauri::State<'_, TauriApi>, + url: &str, +) -> Result { + if url.trim().is_empty() { return Err(ApiError::new("file_error", "Файл недоступен")); } - let response = reqwest::Client::new().get(&scan_url).send().await?; + let remote = api.state().remote_config().ok_or_else(|| { + ApiError::bad_request("load_remote_file requires [remote].base_url config") + })?; + let file_url = normalize_remote_file_url(&remote.base_url, url)?; + let relative_path = remote_file_cache_path(file_url.path())?; + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|error| ApiError::new("file_error", error.to_string()))?; + let path = app_data_dir.join("remote_files").join(relative_path); + + match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.len() > 0 => { + return Ok(path); + } + Ok(_) | Err(_) => {} + } + + let token = api + .state() + .auth_token() + .ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?; + + let response = reqwest::Client::new() + .get(file_url) + .header(reqwest::header::AUTHORIZATION, format!("Token {token}")) + .send() + .await?; if !response.status().is_success() { return Err(ApiError::new( "remote_error", @@ -285,7 +339,61 @@ async fn load_application_file(local_path: String, scan_url: String) -> Result Result { + if let Ok(parsed) = reqwest::Url::parse(url.trim()) { + return Ok(parsed); + } + + let base = reqwest::Url::parse(&format!("{}/", base_url.trim_end_matches('/'))) + .map_err(|error| ApiError::new("file_error", error.to_string()))?; + base.join(url.trim_start_matches('/')) + .map_err(|error| ApiError::new("file_error", error.to_string())) +} + +fn remote_file_cache_path(url_path: &str) -> Result { + let mut path = PathBuf::new(); + + for segment in url_path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + continue; + } + + path.push(sanitize_path_segment(segment)); + } + + if path.as_os_str().is_empty() { + Err(ApiError::new("file_error", "URL не содержит имя файла")) + } else { + Ok(path) + } +} + +fn sanitize_path_segment(segment: &str) -> String { + let sanitized = segment + .chars() + .map(|character| match character { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => character, + }) + .collect::(); + + if sanitized.is_empty() { + "file".to_string() + } else { + sanitized + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -323,7 +431,9 @@ pub fn run() { reset_app_settings, current_employee, sync_contracts, - load_application_file + load_remote_file, + ensure_remote_file, + open_remote_file ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 0b72bc2..28dd971 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -158,7 +158,7 @@ struct RemoteEmployee { pub async fn sync_contracts( api: &TauriApi, - app_data_dir: PathBuf, + _app_data_dir: PathBuf, ) -> Result { let state = api.state(); let token = state @@ -205,12 +205,9 @@ pub async fn sync_contracts( fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?; for application in &detail.contractapplicationfile_set { - let local_path = - download_application_file(&client, &token, &app_data_dir, application).await?; - upsert_contract_application_file(api, contract.id, application, &local_path) - .await?; + upsert_contract_application_file(api, contract.id, application, "").await?; applications += 1; - if !local_path.is_empty() { + if !application.scan.is_empty() { files_downloaded += 1; } } @@ -324,69 +321,6 @@ async fn fetch_contract_detail( Ok(response.json::().await?) } -async fn download_application_file( - client: &reqwest::Client, - token: &str, - app_data_dir: &PathBuf, - application: &RemoteContractApplicationFile, -) -> Result { - if application.scan.is_empty() { - return Ok(String::new()); - } - - let directory = application_download_dir(app_data_dir); - tokio::fs::create_dir_all(&directory) - .await - .map_err(file_error)?; - - let file_name = format!( - "{}_{}", - application.id, - sanitize_file_name(&application.scan_name) - ); - let path = directory.join(file_name); - - let response = client - .get(&application.scan) - .header(reqwest::header::AUTHORIZATION, format!("Token {token}")) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let detail = response.text().await.unwrap_or_default(); - return Err(ApiError::new( - "remote_error", - format!("file download failed with {status}: {detail}"), - )); - } - - let bytes = response.bytes().await?; - tokio::fs::write(&path, bytes).await.map_err(file_error)?; - - Ok(path.to_string_lossy().into_owned()) -} - -fn application_download_dir(app_data_dir: &PathBuf) -> PathBuf { - app_data_dir.join("contract_applications") -} - -fn sanitize_file_name(file_name: &str) -> String { - let sanitized = file_name - .chars() - .map(|character| match character { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - _ => character, - }) - .collect::(); - - if sanitized.is_empty() { - "application_file".to_string() - } else { - sanitized - } -} - async fn upsert_contract_dependencies( api: &TauriApi, contract: &RemoteContract, @@ -604,7 +538,3 @@ async fn upsert_contract_application_file( fn database_error(error: sqlx::Error) -> ApiError { ApiError::new("database_error", error.to_string()) } - -fn file_error(error: impl std::fmt::Display) -> ApiError { - ApiError::new("file_error", error.to_string()) -} diff --git a/src/apps/contracts/components/ContractCategorySelect.vue b/src/apps/contracts/components/ContractCategorySelect.vue index 8668de3..8ac10f4 100644 --- a/src/apps/contracts/components/ContractCategorySelect.vue +++ b/src/apps/contracts/components/ContractCategorySelect.vue @@ -55,36 +55,42 @@ watch(search, (value) => { Все - +
+ - Загрузка... + Загрузка... - +