mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
4f7ea76347
* fix: preserve WebDAV password display and validate MKCOL 405 * fix: scope WebDAV password preservation to post-save refresh
555 lines
18 KiB
Rust
555 lines
18 KiB
Rust
//! 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<String>)>;
|
||
|
||
// ─── 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<Url, AppError> {
|
||
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<String, AppError> {
|
||
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<Item = &str> {
|
||
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<u8>,
|
||
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<(Vec<u8>, Option<String>)>, 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<Option<String>, 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<bool, AppError> {
|
||
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<String> = 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::<u64>() 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}"
|
||
);
|
||
}
|
||
}
|