diff --git a/rust/src/commands/export.rs b/rust/src/commands/export.rs index 3150fd83d8..1c15508345 100644 --- a/rust/src/commands/export.rs +++ b/rust/src/commands/export.rs @@ -195,9 +195,34 @@ async fn export_account(storage: &Storage, account: &Account, filter: &ExportFil } }; + // Decrypt public magic metadata to check for edited name + let pub_magic_metadata = if file.pub_magic_metadata.is_some() { + match decrypt_magic_metadata( + file.pub_magic_metadata.as_ref().unwrap(), + &file_key, + ) { + Ok(meta) => meta, + Err(e) => { + log::debug!( + "Failed to decrypt public magic metadata for file {}: {}", + file.id, + e + ); + None + } + } + } else { + None + }; + // Generate export path with original filename from metadata - let file_path = - generate_export_path(export_path, &file, Some(collection), metadata.as_ref())?; + let file_path = generate_export_path( + export_path, + &file, + Some(collection), + metadata.as_ref(), + pub_magic_metadata.as_ref(), + )?; // Skip if file already exists if file_path.exists() { @@ -285,6 +310,7 @@ fn generate_export_path( file: &crate::api::models::File, collection: Option<&crate::api::models::Collection>, metadata: Option<&FileMetadata>, + pub_magic_metadata: Option<&serde_json::Value>, ) -> Result { use chrono::{TimeZone, Utc}; @@ -324,16 +350,25 @@ fn generate_export_path( path.push(safe_name); } - // Use original filename from metadata if available - let filename = if let Some(meta) = metadata { - if let Some(title) = meta.get_title() { - // Sanitize filename for filesystem - sanitize_filename(title) + // Use filename from public magic metadata (edited name) or regular metadata + let filename = { + // First check for edited name in public magic metadata + if let Some(pub_meta) = pub_magic_metadata + && let Some(edited_name) = pub_meta.get("editedName") + && let Some(name_str) = edited_name.as_str() + && !name_str.is_empty() + { + sanitize_filename(name_str) + } else if let Some(meta) = metadata { + // Fall back to original title from metadata + if let Some(title) = meta.get_title() { + sanitize_filename(title) + } else { + generate_fallback_filename(file, metadata) + } } else { - generate_fallback_filename(file, metadata) + generate_fallback_filename(file, None) } - } else { - generate_fallback_filename(file, None) }; path.push(filename); @@ -425,3 +460,26 @@ fn decrypt_file_metadata( let metadata: FileMetadata = serde_json::from_slice(&decrypted)?; Ok(Some(metadata)) } + +/// Decrypt magic metadata (public or private) +fn decrypt_magic_metadata( + magic_metadata: &crate::api::models::MagicMetadata, + file_key: &[u8], +) -> Result> { + use base64::engine::general_purpose::STANDARD as BASE64; + + // Check if data exists + if magic_metadata.data.is_empty() || magic_metadata.header.is_empty() { + return Ok(None); + } + + let encrypted_bytes = BASE64.decode(&magic_metadata.data)?; + let header_bytes = BASE64.decode(&magic_metadata.header)?; + + // Decrypt the metadata using streaming XChaCha20-Poly1305 + let decrypted = decrypt_stream(&encrypted_bytes, &header_bytes, file_key)?; + + // Parse as generic JSON since magic metadata structure can vary + let metadata: serde_json::Value = serde_json::from_slice(&decrypted)?; + Ok(Some(metadata)) +} diff --git a/rust/src/commands/sync.rs b/rust/src/commands/sync.rs index d5a8170368..e8a709707e 100644 --- a/rust/src/commands/sync.rs +++ b/rust/src/commands/sync.rs @@ -268,6 +268,20 @@ fn decrypt_collection_keys( Ok(keys) } +/// Sanitize a filename for the filesystem +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + '\0' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect::() + .trim() + .to_string() +} + /// Prepare download tasks with proper file paths async fn prepare_download_tasks( files: &[crate::models::file::RemoteFile], @@ -290,7 +304,7 @@ async fn prepare_download_tasks( let collection = collection_map.get(&file.collection_id); // Try to decrypt metadata to get original filename - let metadata = if let Some(col_key) = + let (metadata, pub_magic_metadata) = if let Some(col_key) = download_manager.collection_keys.get(&file.collection_id) { // Decrypt file key first @@ -300,9 +314,8 @@ async fn prepare_download_tasks( secret_box_open(&key_bytes, &nonce, col_key)? }; - // Then decrypt metadata if available - // Note: file.metadata is a crate::models::file::MetadataInfo, not FileAttributes - if !file.metadata.encrypted_data.is_empty() { + // Decrypt regular metadata + let regular_meta = if !file.metadata.encrypted_data.is_empty() { if !file.metadata.decryption_header.is_empty() { let encrypted_bytes = BASE64.decode(&file.metadata.encrypted_data)?; let header_bytes = BASE64.decode(&file.metadata.decryption_header)?; @@ -319,9 +332,37 @@ async fn prepare_download_tasks( } } else { None - } + }; + + // Decrypt public magic metadata if available + let pub_meta = if let Some(ref magic) = file.pub_magic_metadata { + if !magic.data.is_empty() && !magic.header.is_empty() { + let encrypted_bytes = BASE64.decode(&magic.data)?; + let header_bytes = BASE64.decode(&magic.header)?; + + match decrypt_stream(&encrypted_bytes, &header_bytes, &file_key) { + Ok(decrypted) => { + serde_json::from_slice::(&decrypted).ok() + } + Err(e) => { + log::debug!( + "Failed to decrypt public magic metadata for file {}: {}", + file.id, + e + ); + None + } + } + } else { + None + } + } else { + None + }; + + (regular_meta, pub_meta) } else { - None + (None, None) }; // Generate export path @@ -356,15 +397,25 @@ async fn prepare_download_tasks( path.push(safe_name.trim()); } - // Use original filename from metadata or generate fallback - let filename = if let Some(ref meta) = metadata { - if let Some(title) = meta.get_title() { - title.to_string() + // Use filename from public magic metadata (edited name) or regular metadata + let filename = { + // First check for edited name in public magic metadata + if let Some(ref pub_meta) = pub_magic_metadata + && let Some(edited_name) = pub_meta.get("editedName") + && let Some(name_str) = edited_name.as_str() + && !name_str.is_empty() + { + sanitize_filename(name_str) + } else if let Some(ref meta) = metadata { + // Fall back to original title from metadata + if let Some(title) = meta.get_title() { + sanitize_filename(title) + } else { + format!("file_{}.jpg", file.id) + } } else { format!("file_{}.jpg", file.id) } - } else { - format!("file_{}.jpg", file.id) }; path.push(filename); diff --git a/rust/src/models/file.rs b/rust/src/models/file.rs index f85b40c3e6..1f52c01198 100644 --- a/rust/src/models/file.rs +++ b/rust/src/models/file.rs @@ -20,6 +20,16 @@ pub struct RemoteFile { pub is_deleted: bool, #[serde(rename = "updatedAt")] pub updated_at: i64, + #[serde(rename = "pubMagicMetadata")] + pub pub_magic_metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MagicMetadata { + pub version: i32, + pub count: i32, + pub data: String, + pub header: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rust/src/storage/sync.rs b/rust/src/storage/sync.rs index fcc65f6c19..9c601b0429 100644 --- a/rust/src/storage/sync.rs +++ b/rust/src/storage/sync.rs @@ -141,6 +141,7 @@ impl<'a> SyncStore<'a> { metadata: metadata_obj, is_deleted: row.get::<_, i32>(6)? != 0, updated_at: row.get(7)?, + pub_magic_metadata: None, // TODO: Store and retrieve from database if needed }) })? .collect::, _>>()?; @@ -275,6 +276,7 @@ impl<'a> SyncStore<'a> { metadata: metadata_obj, is_deleted: row.get::<_, i32>(6)? != 0, updated_at: row.get(7)?, + pub_magic_metadata: None, // TODO: Store and retrieve from database if needed }) })? .collect::, _>>()?; diff --git a/rust/src/sync/engine.rs b/rust/src/sync/engine.rs index ef3d04ad45..69a8920a38 100644 --- a/rust/src/sync/engine.rs +++ b/rust/src/sync/engine.rs @@ -196,6 +196,14 @@ impl SyncEngine { }, is_deleted: file.is_deleted, updated_at: file.updation_time, + pub_magic_metadata: file.pub_magic_metadata.as_ref().map(|m| { + crate::models::file::MagicMetadata { + version: m.version, + count: m.count, + data: m.data.clone(), + header: m.header.clone(), + } + }), }; // Upsert file (insert or update)