mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Webdav (#923)
* 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:
+182
-1
@@ -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;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user