//! WebDAV HTTP transport layer. //! //! Low-level HTTP primitives for WebDAV operations (PUT, GET, HEAD, MKCOL, PROPFIND). //! The sync protocol logic lives in [`super::webdav_sync`]. use reqwest::{Method, RequestBuilder, StatusCode, Url}; use std::time::Duration; use crate::error::AppError; use crate::proxy::http_client; use futures::StreamExt; const DEFAULT_TIMEOUT_SECS: u64 = 30; /// Timeout for large file transfers (PUT/GET of db.sql, skills.zip). const TRANSFER_TIMEOUT_SECS: u64 = 300; /// Auth pair: `(username, Some(password))`. pub type WebDavAuth = Option<(String, Option)>; // ─── WebDAV extension methods ──────────────────────────────── fn method_propfind() -> Method { Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method") } fn method_mkcol() -> Method { Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method") } // ─── URL utilities ─────────────────────────────────────────── /// Parse and validate a WebDAV base URL (must be http or https). pub fn parse_base_url(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(AppError::localized( "webdav.base_url.required", "WebDAV 地址不能为空", "WebDAV URL is required.", )); } let url = Url::parse(trimmed).map_err(|e| { AppError::localized( "webdav.base_url.invalid", format!("WebDAV 地址无效: {e}"), format!("Invalid WebDAV URL: {e}"), ) })?; match url.scheme() { "http" | "https" => Ok(url), _ => Err(AppError::localized( "webdav.base_url.scheme_invalid", "WebDAV 仅支持 http/https 地址", "WebDAV URL must use http or https.", )), } } /// Build a full URL from a base URL string and path segments. /// /// Each segment is individually percent-encoded by the `url` crate. pub fn build_remote_url(base_url: &str, segments: &[String]) -> Result { let mut url = parse_base_url(base_url)?; { let mut path = url.path_segments_mut().map_err(|_| { AppError::localized( "webdav.base_url.unusable", "WebDAV 地址格式不支持追加路径", "WebDAV URL format does not support appending path segments.", ) })?; path.pop_if_empty(); for seg in segments { path.push(seg); } } Ok(url.to_string()) } /// Split a slash-delimited path into non-empty segments. pub fn path_segments(raw: &str) -> impl Iterator { raw.trim_matches('/').split('/').filter(|s| !s.is_empty()) } // ─── Auth ──────────────────────────────────────────────────── /// Build auth from username/password. Returns `None` if username is blank. pub fn auth_from_credentials(username: &str, password: &str) -> WebDavAuth { let user = username.trim(); if user.is_empty() { return None; } Some((user.to_string(), Some(password.to_string()))) } /// Apply Basic-Auth to a request builder if auth is present. fn apply_auth(builder: RequestBuilder, auth: &WebDavAuth) -> RequestBuilder { match auth { Some((user, pass)) => builder.basic_auth(user, pass.as_deref()), None => builder, } } fn webdav_transport_error( key: &'static str, op_zh: &str, op_en: &str, target_url: &str, err: &reqwest::Error, ) -> AppError { let (zh_reason, en_reason) = if err.is_timeout() { ("请求超时", "request timed out") } else if err.is_connect() { ("连接失败", "connection failed") } else if err.is_request() { ("请求构造失败", "request build failed") } else { ("网络请求失败", "network request failed") }; let safe_url = redact_url(target_url); AppError::localized( key, format!("WebDAV {op_zh}失败({zh_reason}): {safe_url}"), format!("WebDAV {op_en} failed ({en_reason}): {safe_url}"), ) } // ─── HTTP operations ───────────────────────────────────────── /// Test WebDAV connectivity via PROPFIND Depth=0 on the base URL. pub async fn test_connection(base_url: &str, auth: &WebDavAuth) -> Result<(), AppError> { let url = parse_base_url(base_url)?; let client = http_client::get(); let resp = apply_auth( client .request(method_propfind(), url) .header("Depth", "0") .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), auth, ) .send() .await .map_err(|e| { webdav_transport_error( "webdav.connection_failed", "连接", "connection", base_url, &e, ) })?; if resp.status().is_success() || resp.status() == StatusCode::MULTI_STATUS { return Ok(()); } Err(webdav_status_error("PROPFIND", resp.status(), base_url)) } /// Ensure a chain of remote directories exists. /// /// Uses optimistic MKCOL: try creating first, fall back to PROPFIND verification /// on ambiguous responses. This halves the round-trips vs PROPFIND-first approach. pub async fn ensure_remote_directories( base_url: &str, segments: &[String], auth: &WebDavAuth, ) -> Result<(), AppError> { if segments.is_empty() { return Ok(()); } let client = http_client::get(); for depth in 1..=segments.len() { let prefix = &segments[..depth]; let url = build_remote_url(base_url, prefix)?; let dir_url = if url.ends_with('/') { url } else { format!("{url}/") }; let resp = apply_auth( client .request(method_mkcol(), &dir_url) .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), auth, ) .send() .await .map_err(|e| { webdav_transport_error( "webdav.mkcol_failed", "MKCOL 请求", "MKCOL request", &dir_url, &e, ) })?; let status = resp.status(); match status { s if s == StatusCode::CREATED || s.is_success() => { log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url)); } // Ambiguous — verify directory actually exists via PROPFIND s if s == StatusCode::METHOD_NOT_ALLOWED || s == StatusCode::CONFLICT || s.is_redirection() => { if !propfind_exists(&client, &dir_url, auth).await? { return Err(webdav_status_error("MKCOL", status, &dir_url)); } } _ => { return Err(webdav_status_error("MKCOL", status, &dir_url)); } } } Ok(()) } /// PUT bytes to a remote WebDAV URL. pub async fn put_bytes( url: &str, auth: &WebDavAuth, bytes: Vec, content_type: &str, ) -> Result<(), AppError> { let client = http_client::get(); let resp = apply_auth( client .put(url) .header("Content-Type", content_type) .body(bytes) .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)), auth, ) .send() .await .map_err(|e| webdav_transport_error("webdav.put_failed", "PUT 请求", "PUT request", url, &e))?; if resp.status().is_success() { return Ok(()); } Err(webdav_status_error("PUT", resp.status(), url)) } /// GET bytes from a remote WebDAV URL. Returns `None` on 404. /// /// On success returns `(body_bytes, optional_etag)`. pub async fn get_bytes( url: &str, auth: &WebDavAuth, max_bytes: usize, ) -> Result, Option)>, AppError> { let client = http_client::get(); let resp = apply_auth( client .get(url) .timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)), auth, ) .send() .await .map_err(|e| webdav_transport_error("webdav.get_failed", "GET 请求", "GET request", url, &e))?; if resp.status() == StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { return Err(webdav_status_error("GET", resp.status(), url)); } ensure_content_length_within_limit(resp.headers(), max_bytes, url)?; let etag = resp .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let mut bytes = Vec::new(); let mut stream = resp.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| { AppError::localized( "webdav.response_read_failed", format!("读取 WebDAV 响应失败: {e}"), format!("Failed to read WebDAV response: {e}"), ) })?; if bytes.len().saturating_add(chunk.len()) > max_bytes { return Err(response_too_large_error(url, max_bytes)); } bytes.extend_from_slice(&chunk); } Ok(Some((bytes, etag))) } /// HEAD request to retrieve the ETag. Returns `None` on 404. pub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result, AppError> { let client = http_client::get(); let resp = apply_auth( client .head(url) .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), auth, ) .send() .await .map_err(|e| { webdav_transport_error("webdav.head_failed", "HEAD 请求", "HEAD request", url, &e) })?; if resp.status() == StatusCode::NOT_FOUND { return Ok(None); } if !resp.status().is_success() { return Err(webdav_status_error("HEAD", resp.status(), url)); } Ok(resp .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string())) } // ─── Internal helpers ──────────────────────────────────────── /// PROPFIND Depth=0 to check if a remote resource exists. async fn propfind_exists( client: &reqwest::Client, url: &str, auth: &WebDavAuth, ) -> Result { let resp = apply_auth( client .request(method_propfind(), url) .header("Depth", "0") .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), auth, ) .send() .await; match resp { Ok(r) => Ok(r.status().is_success() || r.status() == StatusCode::MULTI_STATUS), Err(e) => { log::warn!( "[WebDAV] PROPFIND check failed for {}: {e}", redact_url(url) ); Ok(false) } } } // ─── Service detection & error helpers ─────────────────────── /// Check if a URL points to Jianguoyun (坚果云). pub fn is_jianguoyun(url: &str) -> bool { Url::parse(url) .ok() .and_then(|u| u.host_str().map(|h| h.to_lowercase())) .map(|host| host.contains("jianguoyun.com") || host.contains("nutstore")) .unwrap_or(false) } /// Build an `AppError` with service-specific hints for WebDAV failures. pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError { let safe_url = redact_url(url); let mut zh = format!("WebDAV {op} 失败: {status} ({safe_url})"); let mut en = format!("WebDAV {op} failed: {status} ({safe_url})"); let jgy = is_jianguoyun(url); if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { if jgy { zh.push_str("。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。"); en.push_str( ". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.", ); } else { zh.push_str("。请检查 WebDAV 用户名、密码及目录读写权限。"); en.push_str(". Please check WebDAV username/password and directory permissions."); } } else if jgy && (status == StatusCode::NOT_FOUND || status.is_redirection()) { zh.push_str("。坚果云常见原因:地址不在 /dav/ 可写目录下。"); en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory."); } else if op == "MKCOL" && status == StatusCode::CONFLICT { if jgy { zh.push_str("。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。"); en.push_str( ". Jianguoyun does not allow creating top-level folders automatically; create it manually first.", ); } else { zh.push_str("。请确认上级目录存在。"); en.push_str(". Please ensure the parent directory exists."); } } AppError::localized("webdav.http.status", zh, en) } fn redact_url(raw: &str) -> String { match Url::parse(raw) { Ok(mut parsed) => { let _ = parsed.set_username(""); let _ = parsed.set_password(None); let mut out = format!("{}://", parsed.scheme()); if let Some(host) = parsed.host_str() { out.push_str(host); } if let Some(port) = parsed.port() { out.push(':'); out.push_str(&port.to_string()); } out.push_str(parsed.path()); let mut keys: Vec = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect(); keys.sort(); keys.dedup(); if !keys.is_empty() { out.push_str("?[keys:"); out.push_str(&keys.join(",")); out.push(']'); } out } Err(_) => raw.split('?').next().unwrap_or(raw).to_string(), } } fn response_too_large_error(url: &str, max_bytes: usize) -> AppError { let max_mb = max_bytes / 1024 / 1024; AppError::localized( "webdav.response_too_large", format!( "WebDAV 响应体超过上限({} MB): {}", max_mb, redact_url(url) ), format!( "WebDAV response body exceeds limit ({} MB): {}", max_mb, redact_url(url) ), ) } fn ensure_content_length_within_limit( headers: &reqwest::header::HeaderMap, max_bytes: usize, url: &str, ) -> Result<(), AppError> { let Some(content_length) = headers.get(reqwest::header::CONTENT_LENGTH) else { return Ok(()); }; let Ok(raw) = content_length.to_str() else { return Ok(()); }; let Ok(value) = raw.parse::() else { return Ok(()); }; if value > max_bytes as u64 { return Err(response_too_large_error(url, max_bytes)); } Ok(()) } #[cfg(test)] mod tests { use super::*; use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH}; #[test] fn build_remote_url_encodes_path_segments() { let url = build_remote_url( "https://dav.example.com/remote.php/dav/files/demo/", &[ "cc switch-sync".to_string(), "v2".to_string(), "db-v6".to_string(), "default profile".to_string(), "manifest.json".to_string(), ], ) .unwrap(); assert_eq!( url, "https://dav.example.com/remote.php/dav/files/demo/cc%20switch-sync/v2/db-v6/default%20profile/manifest.json" ); assert!(!url.contains("//cc"), "should not have double-slash"); } #[test] fn is_jianguoyun_detects_correctly() { assert!(is_jianguoyun("https://dav.jianguoyun.com/dav")); assert!(is_jianguoyun("https://dav.jianguoyun.com/dav/folder")); assert!(!is_jianguoyun("https://nextcloud.example.com/dav")); } #[test] fn path_segments_splits_correctly() { let segs: Vec<_> = path_segments("/a/b/c/").collect(); assert_eq!(segs, vec!["a", "b", "c"]); let segs: Vec<_> = path_segments("single").collect(); assert_eq!(segs, vec!["single"]); let segs: Vec<_> = path_segments("").collect(); assert!(segs.is_empty()); } #[test] fn auth_from_credentials_trims_and_rejects_blank() { assert!(auth_from_credentials(" ", "pass").is_none()); let auth = auth_from_credentials(" user ", "pass"); assert_eq!(auth, Some(("user".to_string(), Some("pass".to_string())))); } #[test] fn redact_url_hides_credentials_and_query_values() { let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1"); assert_eq!(redacted, "https://example.com:8443/dav?[keys:foo,token]"); assert!(!redacted.contains("secret")); } #[test] fn ensure_content_length_within_limit_accepts_missing_or_small_values() { let empty = HeaderMap::new(); assert!( ensure_content_length_within_limit(&empty, 1024, "https://dav.example.com").is_ok() ); let mut small = HeaderMap::new(); small.insert(CONTENT_LENGTH, HeaderValue::from_static("1024")); assert!( ensure_content_length_within_limit(&small, 1024, "https://dav.example.com").is_ok() ); } #[test] fn ensure_content_length_within_limit_rejects_oversized_values() { let mut large = HeaderMap::new(); large.insert(CONTENT_LENGTH, HeaderValue::from_static("2048")); let err = ensure_content_length_within_limit(&large, 1024, "https://dav.example.com") .expect_err("oversized response should be rejected"); assert!( err.to_string().contains("too large") || err.to_string().contains("超过"), "unexpected error: {err}" ); } }