* feat: WebDAV backup/restore

- Add WebDAV test/backup/restore commands and settings\n- Fix ja i18n missing keys; decode PROPFIND href as UTF-8\n- Stabilize Windows prompt auto-import tests via CC_SWITCH_TEST_HOME

* chore: format and minor cleanups

* fix: update build config

* feat(webdav): unify sync UX and hardening fixes

* fix(webdav): harden sync flow and stabilize sync UX/tests

* fix(webdav): add resource limits to skills.zip extraction

Prevent zip bomb / resource exhaustion by enforcing:
- MAX_EXTRACT_ENTRIES (10,000 files)
- MAX_EXTRACT_BYTES (512 MB cumulative)

* refactor(webdav): drop deviceId and display deviceName only

---------

Co-authored-by: small-lovely-cat <77799160+small-lovely-cat@users.noreply.github.com>
Co-authored-by: saladday <1203511142@qq.com>
This commit is contained in:
clx
2026-02-14 15:24:24 +08:00
committed by GitHub
parent 721771bfe5
commit 6098fa7536
26 changed files with 3755 additions and 41 deletions
+182 -1
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
@@ -58,6 +59,101 @@ impl VisibleApps {
}
}
/// WebDAV 同步状态(持久化同步进度信息)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WebDavSyncStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_sync_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_local_manifest_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_remote_manifest_hash: Option<String>,
}
fn default_remote_root() -> String {
"cc-switch-sync".to_string()
}
fn default_profile() -> String {
"default".to_string()
}
/// WebDAV v2 同步设置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebDavSyncSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default = "default_remote_root")]
pub remote_root: String,
#[serde(default = "default_profile")]
pub profile: String,
#[serde(default)]
pub status: WebDavSyncStatus,
}
impl Default for WebDavSyncSettings {
fn default() -> Self {
Self {
enabled: false,
base_url: String::new(),
username: String::new(),
password: String::new(),
remote_root: default_remote_root(),
profile: default_profile(),
status: WebDavSyncStatus::default(),
}
}
}
impl WebDavSyncSettings {
pub fn validate(&self) -> Result<(), crate::error::AppError> {
if self.base_url.trim().is_empty() {
return Err(crate::error::AppError::localized(
"webdav.base_url.required",
"WebDAV 地址不能为空",
"WebDAV URL is required.",
));
}
if self.username.trim().is_empty() {
return Err(crate::error::AppError::localized(
"webdav.username.required",
"WebDAV 用户名不能为空",
"WebDAV username is required.",
));
}
Ok(())
}
pub fn normalize(&mut self) {
self.base_url = self.base_url.trim().to_string();
self.username = self.username.trim().to_string();
self.remote_root = self.remote_root.trim().to_string();
self.profile = self.profile.trim().to_string();
if self.remote_root.is_empty() {
self.remote_root = default_remote_root();
}
if self.profile.is_empty() {
self.profile = default_profile();
}
}
/// Returns true if all credential fields are blank (no config to persist).
fn is_empty(&self) -> bool {
self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty()
}
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
@@ -118,6 +214,14 @@ pub struct AppSettings {
#[serde(default)]
pub skill_sync_method: SyncMethod,
// ===== WebDAV 同步设置 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdav_sync: Option<WebDavSyncSettings>,
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdav_backup: Option<serde_json::Value>,
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
@@ -155,6 +259,8 @@ impl Default for AppSettings {
current_provider_gemini: None,
current_provider_opencode: None,
skill_sync_method: SyncMethod::default(),
webdav_sync: None,
webdav_backup: None,
preferred_terminal: None,
}
}
@@ -205,6 +311,13 @@ impl AppSettings {
.map(|s| s.trim())
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
.map(|s| s.to_string());
if let Some(sync) = &mut self.webdav_sync {
sync.normalize();
if sync.is_empty() {
self.webdav_sync = None;
}
}
}
fn load_from_file() -> Self {
@@ -245,7 +358,27 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&path)
.map_err(|e| AppError::io(&path, e))?;
file.write_all(json.as_bytes())
.map_err(|e| AppError::io(&path, e))?;
}
#[cfg(not(unix))]
{
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
}
Ok(())
}
@@ -283,6 +416,15 @@ pub fn get_settings() -> AppSettings {
.clone()
}
pub fn get_settings_for_frontend() -> AppSettings {
let mut settings = get_settings();
if let Some(sync) = &mut settings.webdav_sync {
sync.password.clear();
}
settings.webdav_backup = None;
settings
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
@@ -295,6 +437,22 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
Ok(())
}
fn mutate_settings<F>(mutator: F) -> Result<(), AppError>
where
F: FnOnce(&mut AppSettings),
{
let mut guard = settings_store().write().unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
});
let mut next = guard.clone();
mutator(&mut next);
next.normalize_paths();
save_settings_file(&next)?;
*guard = next;
Ok(())
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
@@ -433,3 +591,26 @@ pub fn get_preferred_terminal() -> Option<String> {
.preferred_terminal
.clone()
}
// ===== WebDAV 同步设置管理函数 =====
/// 获取 WebDAV 同步设置
pub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {
settings_store().read().ok()?.webdav_sync.clone()
}
/// 保存 WebDAV 同步设置
pub fn set_webdav_sync_settings(settings: Option<WebDavSyncSettings>) -> Result<(), AppError> {
mutate_settings(|current| {
current.webdav_sync = settings;
})
}
/// 仅更新 WebDAV 同步状态,避免覆写 credentials/root/profile 等字段
pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {
mutate_settings(|current| {
if let Some(sync) = current.webdav_sync.as_mut() {
sync.status = status;
}
})
}