Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ pub(crate) enum Transport {
WebDav {
base_url: String,
auth: webdav::WebDavAuth,
/// 整次操作共用的客户端:PROPFIND / MKCOL / PUT 复用连接池,一次上传只建连一次
http: reqwest::Client,
/// GET 专用客户端(不自动跟转,download 模块逐跳处理重定向),同一操作内复用
http_no_redirect: reqwest::Client,
/// 远端目录段(`remoteRoot/v1/profile`),每次读写都要拼
segments: Vec<String>,
/// 自定义 User-Agent;空 = 默认。部分服务按它校验客户端类型
user_agent: String,
},
S3 {
credentials: s3::S3Credentials,
Expand Down Expand Up @@ -46,8 +48,11 @@ impl Transport {
&settings.webdav.username,
&settings.webdav_password_plain()?,
),
// 两个客户端在构建时就完成系统代理解析与 TLS 后端初始化,
// 之后整次操作不再重做
http: webdav::build_client(&settings.webdav.user_agent)?,
http_no_redirect: webdav::build_client_without_redirects(&settings.webdav.user_agent)?,
segments,
user_agent: settings.webdav.user_agent.clone(),
}),
}
}
Expand All @@ -68,11 +73,12 @@ impl Transport {
Transport::WebDav {
base_url,
auth,
http,
segments,
user_agent,
..
} => {
webdav::test_connection(base_url, auth, user_agent).await?;
webdav::ensure_remote_directories(base_url, segments, auth, user_agent).await
webdav::test_connection(http, base_url, auth).await?;
webdav::ensure_remote_directories(http, base_url, segments, auth).await
}
Transport::S3 { credentials, .. } => s3::test_connection(credentials).await,
}
Expand All @@ -89,13 +95,14 @@ impl Transport {
Transport::WebDav {
base_url,
auth,
http,
segments,
user_agent,
..
} => {
let mut file_segments = segments.clone();
file_segments.extend(manifest::sanitize_segments(name)?);
let url = webdav::build_remote_url(base_url, &file_segments)?;
webdav::put_bytes(&url, auth, bytes, content_type, user_agent).await
webdav::put_bytes(http, &url, auth, bytes, content_type).await
}
Transport::S3 { credentials, prefix } => {
let key = format!("{prefix}/{}", name.trim_start_matches('/'));
Expand All @@ -114,13 +121,14 @@ impl Transport {
Transport::WebDav {
base_url,
auth,
http_no_redirect,
segments,
user_agent,
..
} => {
let mut file_segments = segments.clone();
file_segments.extend(manifest::sanitize_segments(name)?);
let url = webdav::build_remote_url(base_url, &file_segments)?;
webdav::get_bytes(&url, auth, max_bytes, user_agent).await
webdav::get_bytes(http_no_redirect, &url, auth, max_bytes).await
}
Transport::S3 { credentials, prefix } => {
let key = format!("{prefix}/{}", name.trim_start_matches('/'));
Expand Down
100 changes: 69 additions & 31 deletions desktop-tauri/src-tauri/server/src/server/core/cloud_sync/webdav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@
//! ── 超时 ────────────────────────────────────────────────────
//! 传输层单独建 Client,不用 `egress` 的共享 Client:后者的 read_timeout 来自
//! 「请求超时」设置(面向 SSE,可能被用户调成 600 秒),而云同步要的是
//! 「连接 30 秒、整次传输 300 秒」这种一次性长任务的口径。建一个不缓存的
//! Client 的代价(一次连接池)远小于把两套语义挤进一个实例。
//! 「连接 30 秒、整次传输 300 秒」这种一次性长任务的口径,Client 由
//! Transport 按整次操作构建并持有,PROPFIND / MKCOL / PUT 复用同一连接池,
//! 一次上传只真正建连一次,GET 因为要逐跳处理重定向,单独配一个不跟转的
//! 姊妹客户端,同一操作内同样复用
//!
//! ── 连接重试 ────────────────────────────────────────────────
//! 首次上传偶发「Network is unreachable」:解析器偶尔只返回 IPv6 记录(A 记录
//! 查询瞬时失败时返回部分结果),连接器在应答缺 IPv4 时没有回退目标,整条
//! 连接直接失败,连接阶段失败时服务端没有收到任何字节,重发没有副作用,
//! 自动重试一次即可越过瞬态,非连接类错误不重试

mod download;

Expand All @@ -30,6 +38,8 @@ const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 大文件传输(PUT / GET 快照)的超时。整次请求的总预算 —— 256MB 上限下,
/// 300 秒意味着 0.85MB/s 的下限,家用宽带上足够。
const TRANSFER_TIMEOUT_SECS: u64 = 300;
/// 连接类失败的重试间隔:重试会重新解析域名,多数瞬态在这一档延迟内恢复
const CONNECT_RETRY_DELAY: Duration = Duration::from_millis(500);

/// Basic Auth 的 `(用户名, 密码)`
pub(crate) type WebDavAuth = Option<(String, Option<String>)>;
Expand All @@ -45,10 +55,6 @@ fn method_mkcol() -> Method {
/// ── 自定义 User-Agent ───────────────────────────────────────
/// 少数 WebDAV 服务(如中国科技云数据胶囊)把凭据绑到「应用类型」上,并按
/// User-Agent 校验它;UA 不含该应用名就拒绝。`user_agent` 为空 = 不覆盖。
fn client(user_agent: &str) -> Result<reqwest::Client, String> {
client_with_policy(reqwest::redirect::Policy::limited(10), user_agent)
}

fn client_with_policy(
policy: reqwest::redirect::Policy,
user_agent: &str,
Expand All @@ -67,6 +73,42 @@ fn client_with_policy(
.map_err(|error| format!("创建 HTTP 客户端失败: {}", redact_detail(&error.to_string())))
}

/// 整次同步操作共用的客户端:自动跟随跳转(≤10 次)。
///
/// 由 Transport 按操作构建并持有一次,PROPFIND / MKCOL / PUT 复用同一连接池,
/// 一次上传不再每个请求重做 DNS 解析与 TLS 握手
pub(crate) fn build_client(user_agent: &str) -> Result<reqwest::Client, String> {
client_with_policy(reqwest::redirect::Policy::limited(10), user_agent)
}

/// GET 专用的不自动跟转客户端:download 模块逐跳处理重定向以保留真实失败目标
pub(crate) fn build_client_without_redirects(user_agent: &str) -> Result<reqwest::Client, String> {
client_with_policy(reqwest::redirect::Policy::none(), user_agent)
}

/// 发送请求,连接类失败自动重试一次。
///
/// 连接阶段的失败(域名解析部分应答、路由未就绪等瞬态)发生时 TCP 与 TLS 都
/// 没有建立,服务端不会收到这次请求,重发没有副作用;非连接类错误说明请求
/// 可能已到达服务端,不重试。重试用克隆的请求重新发送,重新解析在连接器
/// 内部自然发生,多数瞬态在第二次尝试时拿到完整地址
async fn send_with_connect_retry(
builder: reqwest::RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error> {
match builder.try_clone() {
Some(retry) => match builder.send().await {
Ok(response) => Ok(response),
Err(error) if error.is_connect() => {
tokio::time::sleep(CONNECT_RETRY_DELAY).await;
retry.send().await
}
Err(error) => Err(error),
},
// 流式 body 之类不可克隆的请求没有重试机会,直接发送
None => builder.send().await,
}
}

/// 解析并校验 WebDAV 基地址(只认 http / https)。
pub(crate) fn parse_base_url(raw: &str) -> Result<Url, String> {
let trimmed = raw.trim();
Expand Down Expand Up @@ -225,19 +267,17 @@ fn transport_error(op: &str, url: &str, error: &reqwest::Error) -> String {
/// PROPFIND 是 WebDAV 的「你好,这里有东西吗」,能同时验证「地址对」与
/// 「这是 WebDAV 而不是普通静态站点」。
pub(crate) async fn test_connection(
http: &reqwest::Client,
base_url: &str,
auth: &WebDavAuth,
user_agent: &str,
) -> Result<(), String> {
let url = parse_base_url(base_url)?;
let response = apply_auth(
client(user_agent)?
.request(method_propfind(), url)
let response = send_with_connect_retry(apply_auth(
http.request(method_propfind(), url)
.header("Depth", "0")
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
auth,
)
.send()
))
.await
.map_err(|error| transport_error("连接", base_url, &error))?;

Expand All @@ -254,24 +294,22 @@ pub(crate) async fn test_connection(
/// 405(METHOD_NOT_ALLOWED)或 409(CONFLICT),这两种都可能是「已存在」也
/// 可能是「父目录不存在」,所以再补一次 PROPFIND 确认;确认存在就算成功。
pub(crate) async fn ensure_remote_directories(
http: &reqwest::Client,
base_url: &str,
segments: &[String],
auth: &WebDavAuth,
user_agent: &str,
) -> Result<(), String> {
if segments.is_empty() {
return Ok(());
}
let http = client(user_agent)?;
for depth in 1..=segments.len() {
let url = build_remote_url(base_url, &segments[..depth])?;
let dir_url = if url.ends_with('/') { url } else { format!("{url}/") };
let response = apply_auth(
let response = send_with_connect_retry(apply_auth(
http.request(method_mkcol(), &dir_url)
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
auth,
)
.send()
))
.await
.map_err(|error| transport_error("MKCOL", &dir_url, &error))?;

Expand Down Expand Up @@ -315,21 +353,19 @@ async fn propfind_exists(

/// PUT 一个文件。
pub(crate) async fn put_bytes(
http: &reqwest::Client,
url: &str,
auth: &WebDavAuth,
bytes: Vec<u8>,
content_type: &str,
user_agent: &str,
) -> Result<(), String> {
let response = apply_auth(
client(user_agent)?
.put(url)
let response = send_with_connect_retry(apply_auth(
http.put(url)
.header("Content-Type", content_type)
.body(bytes)
.timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS)),
auth,
)
.send()
))
.await
.map_err(|error| transport_error("PUT", url, &error))?;
if response.status().is_success() {
Expand All @@ -343,25 +379,27 @@ pub(crate) async fn put_bytes(
/// `max_bytes` 是**硬上限**:先看 `Content-Length`,再在流式读取时逐块累计 ——
/// 只看头不够(服务端可以不发长度,或发一个假的),所以两道都做。
pub(crate) async fn get_bytes(
http: &reqwest::Client,
url: &str,
auth: &WebDavAuth,
max_bytes: usize,
user_agent: &str,
) -> Result<Option<Vec<u8>>, String> {
download::get_bytes(url, auth, max_bytes, user_agent).await
download::get_bytes(http, url, auth, max_bytes).await
}

/// 删除一个文件(当前只在「上传失败后清理半截数据」之外使用;保留给将来的
/// 「清空远端」按钮)。404 视为成功。
#[allow(dead_code)]
pub(crate) async fn delete(url: &str, auth: &WebDavAuth, user_agent: &str) -> Result<(), String> {
let response = apply_auth(
client(user_agent)?
.delete(url)
pub(crate) async fn delete(
http: &reqwest::Client,
url: &str,
auth: &WebDavAuth,
) -> Result<(), String> {
let response = send_with_connect_retry(apply_auth(
http.delete(url)
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
auth,
)
.send()
))
.await
.map_err(|error| transport_error("DELETE", url, &error))?;
if response.status().is_success() || response.status() == StatusCode::NOT_FOUND {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
//! GET 逐跳处理重定向,以保留真实失败目标并限制跨来源凭据转发。
//! GET 逐跳处理重定向,以保留真实失败目标并限制跨来源凭据转发,客户端由
//! 调用方(Transport)传入,同一操作内的多次 GET 复用同一个连接池

use std::time::Duration;

use futures::StreamExt as _;
use reqwest::{header, StatusCode, Url};

use super::{
apply_auth, client_with_policy, parse_base_url, redact_url, status_error, transport_error,
WebDavAuth, TRANSFER_TIMEOUT_SECS,
apply_auth, parse_base_url, redact_url, send_with_connect_retry, status_error,
transport_error, WebDavAuth, TRANSFER_TIMEOUT_SECS,
};

const MAX_REDIRECTS: usize = 10;
Expand Down Expand Up @@ -36,12 +37,11 @@ fn redirect_error(reason: &str, status: StatusCode, urls: &[Url]) -> String {
}

pub(super) async fn get_bytes(
http: &reqwest::Client,
url: &str,
auth: &WebDavAuth,
max_bytes: usize,
user_agent: &str,
) -> Result<Option<Vec<u8>>, String> {
let http = client_with_policy(reqwest::redirect::Policy::none(), user_agent)?;
let mut current = parse_base_url(url)?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(TRANSFER_TIMEOUT_SECS);
let mut urls = vec![current.clone()];
Expand All @@ -63,7 +63,7 @@ pub(super) async fn get_bytes(
} else {
request
};
let response = request.send().await.map_err(|error| {
let response = send_with_connect_retry(request).await.map_err(|error| {
with_redirect_context(transport_error("GET", current.as_str(), &error), &urls)
})?;
let status = response.status();
Expand Down
Loading