mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-12 20:52:21 +02:00
feat: e2e encrypted sync
This commit is contained in:
+294
-30
@@ -1,8 +1,9 @@
|
||||
use super::client::SyncClient;
|
||||
use super::encryption;
|
||||
use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest};
|
||||
use super::types::*;
|
||||
use crate::events;
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::profile::types::{BrowserProfile, SyncMode};
|
||||
use crate::profile::ProfileManager;
|
||||
use crate::settings_manager::SettingsManager;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -12,6 +13,18 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
|
||||
return true;
|
||||
}
|
||||
let manager = SettingsManager::instance();
|
||||
if let Ok(settings) = manager.load_settings() {
|
||||
return settings.sync_server_url.is_some();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub struct SyncEngine {
|
||||
client: SyncClient,
|
||||
}
|
||||
@@ -68,6 +81,24 @@ impl SyncEngine {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Derive encryption key if encrypted sync
|
||||
let encryption_key = if profile.is_encrypted_sync() {
|
||||
let password = encryption::load_e2e_password()
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
let _ = events::emit("profile-sync-e2e-password-required", ());
|
||||
SyncError::InvalidData("E2E password not set".to_string())
|
||||
})?;
|
||||
let salt = profile.encryption_salt.as_deref().ok_or_else(|| {
|
||||
SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string())
|
||||
})?;
|
||||
let key = encryption::derive_profile_key(&password, salt)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?;
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profile_dir = profiles_dir.join(profile.id.to_string());
|
||||
@@ -154,7 +185,13 @@ impl SyncEngine {
|
||||
// Perform uploads
|
||||
if !diff.files_to_upload.is_empty() {
|
||||
self
|
||||
.upload_profile_files(app_handle, &profile_id, &profile_dir, &diff.files_to_upload)
|
||||
.upload_profile_files(
|
||||
app_handle,
|
||||
&profile_id,
|
||||
&profile_dir,
|
||||
&diff.files_to_upload,
|
||||
encryption_key.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -166,6 +203,7 @@ impl SyncEngine {
|
||||
&profile_id,
|
||||
&profile_dir,
|
||||
&diff.files_to_download,
|
||||
encryption_key.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -190,7 +228,9 @@ impl SyncEngine {
|
||||
self.upload_profile_metadata(&profile_id, profile).await?;
|
||||
|
||||
// Upload manifest.json last for atomicity
|
||||
self.upload_manifest(&profile_id, &local_manifest).await?;
|
||||
let mut final_manifest = local_manifest;
|
||||
final_manifest.encrypted = encryption_key.is_some();
|
||||
self.upload_manifest(&profile_id, &final_manifest).await?;
|
||||
|
||||
// Sync associated proxy, group, and VPN
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
@@ -291,6 +331,7 @@ impl SyncEngine {
|
||||
profile_id: &str,
|
||||
profile_dir: &Path,
|
||||
files: &[super::manifest::ManifestFileEntry],
|
||||
encryption_key: Option<&[u8; 32]>,
|
||||
) -> SyncResult<()> {
|
||||
if files.is_empty() {
|
||||
return Ok(());
|
||||
@@ -324,6 +365,7 @@ impl SyncEngine {
|
||||
let client = self.client.clone();
|
||||
let profile_dir = profile_dir.to_path_buf();
|
||||
let profile_id = profile_id.to_string();
|
||||
let enc_key = encryption_key.copied();
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
@@ -355,8 +397,20 @@ impl SyncEngine {
|
||||
}
|
||||
};
|
||||
|
||||
let upload_data = if let Some(ref key) = enc_key {
|
||||
match encryption::encrypt_bytes(key, &data) {
|
||||
Ok(encrypted) => encrypted,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to encrypt {}: {}", file_path.display(), e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data
|
||||
};
|
||||
|
||||
if let Err(e) = client
|
||||
.upload_bytes(&url, &data, content_type.as_deref())
|
||||
.upload_bytes(&url, &upload_data, content_type.as_deref())
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to upload {}: {}", file_path.display(), e);
|
||||
@@ -387,6 +441,7 @@ impl SyncEngine {
|
||||
profile_id: &str,
|
||||
profile_dir: &Path,
|
||||
files: &[super::manifest::ManifestFileEntry],
|
||||
encryption_key: Option<&[u8; 32]>,
|
||||
) -> SyncResult<()> {
|
||||
if files.is_empty() {
|
||||
return Ok(());
|
||||
@@ -418,6 +473,7 @@ impl SyncEngine {
|
||||
let client = self.client.clone();
|
||||
let profile_dir = profile_dir.to_path_buf();
|
||||
let profile_id = profile_id.to_string();
|
||||
let enc_key = encryption_key.copied();
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
@@ -440,10 +496,22 @@ impl SyncEngine {
|
||||
|
||||
match client.download_bytes(&url).await {
|
||||
Ok(data) => {
|
||||
let write_data = if let Some(ref key) = enc_key {
|
||||
match encryption::decrypt_bytes(key, &data) {
|
||||
Ok(decrypted) => decrypted,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to decrypt {}, skipping: {}", remote_key, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data
|
||||
};
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = fs::write(&file_path, &data) {
|
||||
if let Err(e) = fs::write(&file_path, &write_data) {
|
||||
log::warn!("Failed to write {}: {}", file_path.display(), e);
|
||||
}
|
||||
}
|
||||
@@ -1016,7 +1084,9 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
profile.sync_enabled = true;
|
||||
if profile.sync_mode == SyncMode::Disabled {
|
||||
profile.sync_mode = SyncMode::Regular;
|
||||
}
|
||||
profile.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -1052,6 +1122,26 @@ impl SyncEngine {
|
||||
));
|
||||
};
|
||||
|
||||
// If remote manifest is encrypted, we need the E2E password
|
||||
let encryption_key = if manifest.encrypted {
|
||||
let password = encryption::load_e2e_password()
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
let _ = events::emit("profile-sync-e2e-password-required", ());
|
||||
SyncError::InvalidData(
|
||||
"Remote profile is encrypted but no E2E password is set".to_string(),
|
||||
)
|
||||
})?;
|
||||
let salt = profile.encryption_salt.as_deref().ok_or_else(|| {
|
||||
SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string())
|
||||
})?;
|
||||
let key = encryption::derive_profile_key(&password, salt)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?;
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Ensure profile directory exists
|
||||
fs::create_dir_all(&profile_dir).map_err(|e| {
|
||||
SyncError::IoError(format!(
|
||||
@@ -1078,12 +1168,24 @@ impl SyncEngine {
|
||||
}
|
||||
if !manifest.files.is_empty() {
|
||||
self
|
||||
.download_profile_files(app_handle, profile_id, &profile_dir, &manifest.files)
|
||||
.download_profile_files(
|
||||
app_handle,
|
||||
profile_id,
|
||||
&profile_dir,
|
||||
&manifest.files,
|
||||
encryption_key.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Set sync enabled and save profile
|
||||
profile.sync_enabled = true;
|
||||
// Set sync mode and save profile
|
||||
if profile.sync_mode == SyncMode::Disabled {
|
||||
profile.sync_mode = if manifest.encrypted {
|
||||
SyncMode::Encrypted
|
||||
} else {
|
||||
SyncMode::Regular
|
||||
};
|
||||
}
|
||||
profile.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -1170,23 +1272,23 @@ impl SyncEngine {
|
||||
// Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device)
|
||||
let profile_manager = ProfileManager::instance();
|
||||
// Collect cross-OS profiles before async operations to avoid holding non-Send Result across await
|
||||
let cross_os_profiles: Vec<(String, bool)> = profile_manager
|
||||
let cross_os_profiles: Vec<(String, SyncMode)> = profile_manager
|
||||
.list_profiles()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|p| p.is_cross_os() && p.sync_enabled)
|
||||
.map(|p| (p.id.to_string(), p.sync_enabled))
|
||||
.filter(|p| p.is_cross_os() && p.is_sync_enabled())
|
||||
.map(|p| (p.id.to_string(), p.sync_mode))
|
||||
.collect();
|
||||
|
||||
if !cross_os_profiles.is_empty() {
|
||||
for (pid, sync_enabled) in &cross_os_profiles {
|
||||
for (pid, sync_mode) in &cross_os_profiles {
|
||||
let metadata_key = format!("profiles/{}/metadata.json", pid);
|
||||
match self.client.stat(&metadata_key).await {
|
||||
Ok(stat) if stat.exists => match self.client.presign_download(&metadata_key).await {
|
||||
Ok(presign) => match self.client.download_bytes(&presign.url).await {
|
||||
Ok(data) => {
|
||||
if let Ok(mut remote_profile) = serde_json::from_slice::<BrowserProfile>(&data) {
|
||||
remote_profile.sync_enabled = *sync_enabled;
|
||||
remote_profile.sync_mode = *sync_mode;
|
||||
remote_profile.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -1220,6 +1322,111 @@ impl SyncEngine {
|
||||
|
||||
Ok(downloaded)
|
||||
}
|
||||
|
||||
/// Check for remote entities (proxies, groups, VPNs) not present locally and download them
|
||||
pub async fn check_for_missing_synced_entities(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> SyncResult<()> {
|
||||
log::info!("Checking for missing synced entities...");
|
||||
|
||||
// Check for remote proxies not present locally
|
||||
let remote_proxies = self.client.list("proxies/").await?;
|
||||
for obj in &remote_proxies.objects {
|
||||
if let Some(proxy_id) = obj
|
||||
.key
|
||||
.strip_prefix("proxies/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
{
|
||||
let exists_locally = crate::proxy_manager::PROXY_MANAGER
|
||||
.get_stored_proxies()
|
||||
.iter()
|
||||
.any(|p| p.id == proxy_id);
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"Proxy {} exists remotely but not locally, downloading...",
|
||||
proxy_id
|
||||
);
|
||||
if let Err(e) = self.download_proxy(proxy_id, Some(app_handle)).await {
|
||||
log::warn!("Failed to download missing proxy {}: {}", proxy_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for remote groups not present locally
|
||||
let remote_groups = self.client.list("groups/").await?;
|
||||
for obj in &remote_groups.objects {
|
||||
if let Some(group_id) = obj
|
||||
.key
|
||||
.strip_prefix("groups/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
{
|
||||
let exists_locally = {
|
||||
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.get_all_groups()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|g| g.id == group_id)
|
||||
};
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/groups/{}.json", group_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"Group {} exists remotely but not locally, downloading...",
|
||||
group_id
|
||||
);
|
||||
if let Err(e) = self.download_group(group_id, Some(app_handle)).await {
|
||||
log::warn!("Failed to download missing group {}: {}", group_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for remote VPNs not present locally
|
||||
let remote_vpns = self.client.list("vpns/").await?;
|
||||
for obj in &remote_vpns.objects {
|
||||
if let Some(vpn_id) = obj
|
||||
.key
|
||||
.strip_prefix("vpns/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
{
|
||||
let exists_locally = {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage.load_config(vpn_id).is_ok()
|
||||
};
|
||||
if !exists_locally {
|
||||
let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id);
|
||||
if let Ok(stat) = self.client.stat(&tombstone_key).await {
|
||||
if stat.exists {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"VPN {} exists remotely but not locally, downloading...",
|
||||
vpn_id
|
||||
);
|
||||
if let Err(e) = self.download_vpn(vpn_id, Some(app_handle)).await {
|
||||
log::warn!("Failed to download missing VPN {}: {}", vpn_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Missing synced entities check complete");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if proxy is used by any synced profile
|
||||
@@ -1228,7 +1435,7 @@ pub fn is_proxy_used_by_synced_profile(proxy_id: &str) -> bool {
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
profiles
|
||||
.iter()
|
||||
.any(|p| p.sync_enabled && p.proxy_id.as_deref() == Some(proxy_id))
|
||||
.any(|p| p.is_sync_enabled() && p.proxy_id.as_deref() == Some(proxy_id))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -1240,7 +1447,7 @@ pub fn is_group_used_by_synced_profile(group_id: &str) -> bool {
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
profiles
|
||||
.iter()
|
||||
.any(|p| p.sync_enabled && p.group_id.as_deref() == Some(group_id))
|
||||
.any(|p| p.is_sync_enabled() && p.group_id.as_deref() == Some(group_id))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -1281,7 +1488,7 @@ pub fn is_vpn_used_by_synced_profile(vpn_id: &str) -> bool {
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
profiles
|
||||
.iter()
|
||||
.any(|p| p.sync_enabled && p.vpn_id.as_deref() == Some(vpn_id))
|
||||
.any(|p| p.is_sync_enabled() && p.vpn_id.as_deref() == Some(vpn_id))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -1346,11 +1553,18 @@ pub async fn enable_group_sync_if_needed(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_profile_sync_enabled(
|
||||
pub async fn set_profile_sync_mode(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
enabled: bool,
|
||||
sync_mode: String,
|
||||
) -> Result<(), String> {
|
||||
let new_mode = match sync_mode.as_str() {
|
||||
"Disabled" => SyncMode::Disabled,
|
||||
"Regular" => SyncMode::Regular,
|
||||
"Encrypted" => SyncMode::Encrypted,
|
||||
_ => return Err(format!("Invalid sync mode: {sync_mode}")),
|
||||
};
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
@@ -1367,9 +1581,14 @@ pub async fn set_profile_sync_enabled(
|
||||
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
|
||||
}
|
||||
|
||||
// If enabling, first check that sync settings are configured
|
||||
if enabled {
|
||||
// Cloud auth provides sync settings dynamically — skip local checks
|
||||
if profile.ephemeral {
|
||||
return Err("Cannot enable sync for an ephemeral profile".to_string());
|
||||
}
|
||||
|
||||
let old_mode = profile.sync_mode;
|
||||
let enabling = new_mode != SyncMode::Disabled;
|
||||
|
||||
if enabling {
|
||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
|
||||
if !cloud_logged_in {
|
||||
@@ -1407,7 +1626,32 @@ pub async fn set_profile_sync_enabled(
|
||||
}
|
||||
}
|
||||
|
||||
profile.sync_enabled = enabled;
|
||||
// If switching to Encrypted, verify password and generate salt
|
||||
if new_mode == SyncMode::Encrypted {
|
||||
if !encryption::has_e2e_password() {
|
||||
return Err("E2E password not set. Please set a password in Settings first.".to_string());
|
||||
}
|
||||
if profile.encryption_salt.is_none() {
|
||||
profile.encryption_salt = Some(encryption::generate_salt());
|
||||
}
|
||||
}
|
||||
|
||||
// If switching between Regular<->Encrypted, delete remote manifest to force full re-upload
|
||||
let mode_switched = old_mode != SyncMode::Disabled && enabling && old_mode != new_mode;
|
||||
if mode_switched {
|
||||
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
|
||||
let manifest_key = format!("profiles/{}/manifest.json", profile_id);
|
||||
let _ = engine.client.delete(&manifest_key, None).await;
|
||||
log::info!(
|
||||
"Deleted remote manifest for profile {} due to sync mode change ({:?} -> {:?})",
|
||||
profile_id,
|
||||
old_mode,
|
||||
new_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
profile.sync_mode = new_mode;
|
||||
|
||||
profile_manager
|
||||
.save_profile(&profile)
|
||||
@@ -1415,8 +1659,7 @@ pub async fn set_profile_sync_enabled(
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
|
||||
if enabled {
|
||||
// Check if profile is running to determine status
|
||||
if enabling {
|
||||
let is_running = profile.process_id.is_some();
|
||||
|
||||
let _ = events::emit(
|
||||
@@ -1427,13 +1670,11 @@ pub async fn set_profile_sync_enabled(
|
||||
}),
|
||||
);
|
||||
|
||||
// Queue sync via scheduler (not direct sync)
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler
|
||||
.queue_profile_sync_immediate(profile_id.clone())
|
||||
.await;
|
||||
|
||||
// Auto-enable sync for proxy and group if they exist
|
||||
if let Some(ref proxy_id) = profile.proxy_id {
|
||||
if let Err(e) = enable_proxy_sync_if_needed(proxy_id, &app_handle).await {
|
||||
log::warn!("Failed to enable sync for proxy {}: {}", proxy_id, e);
|
||||
@@ -1459,6 +1700,30 @@ pub async fn set_profile_sync_enabled(
|
||||
log::warn!("Scheduler not initialized, sync will not start");
|
||||
}
|
||||
} else {
|
||||
// Delete remote data when disabling sync
|
||||
if old_mode != SyncMode::Disabled {
|
||||
let profile_id_clone = profile_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
match SyncEngine::create_from_settings(&app_handle_clone).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
|
||||
log::warn!(
|
||||
"Failed to delete profile {} from sync: {}",
|
||||
profile_id_clone,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!("Profile {} deleted from sync service", profile_id_clone);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping remote deletion: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
@@ -1468,11 +1733,10 @@ pub async fn set_profile_sync_enabled(
|
||||
);
|
||||
}
|
||||
|
||||
// Report updated sync-enabled profile count to the cloud backend
|
||||
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
let sync_count = profile_manager
|
||||
.list_profiles()
|
||||
.map(|profiles| profiles.iter().filter(|p| p.sync_enabled).count())
|
||||
.map(|profiles| profiles.iter().filter(|p| p.is_sync_enabled()).count())
|
||||
.unwrap_or(0);
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -1506,7 +1770,7 @@ pub async fn request_profile_sync(
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
if !profile.sync_enabled {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Err("Sync is not enabled for this profile".to_string());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user