feat(rust): Handle renamed files using public magic metadata
Check both public magic metadata (for edited names) and regular metadata when determining file names during export and sync, matching Go CLI behavior Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<PathBuf> {
|
||||
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<Option<serde_json::Value>> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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::<String>()
|
||||
.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::<serde_json::Value>(&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);
|
||||
|
||||
@@ -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<MagicMetadata>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
||||
@@ -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::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -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::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user