886 lines
30 KiB
Rust
886 lines
30 KiB
Rust
use crate::Result;
|
|
use crate::api::client::ApiClient;
|
|
use crate::api::methods::ApiMethods;
|
|
use crate::crypto::{decrypt_file_data, decrypt_stream, init as crypto_init, secret_box_open};
|
|
use crate::models::{
|
|
account::Account,
|
|
export_metadata::{AlbumMetadata, DiskFileMetadata},
|
|
filter::ExportFilter,
|
|
metadata::FileMetadata,
|
|
};
|
|
use crate::storage::Storage;
|
|
use crate::sync::SyncEngine;
|
|
use base64::Engine;
|
|
use std::collections::HashMap;
|
|
use std::io::Cursor;
|
|
use std::path::{Path, PathBuf};
|
|
use tokio::fs;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
pub async fn run_export(account_email: Option<String>, filter: ExportFilter) -> Result<()> {
|
|
// Initialize crypto
|
|
crypto_init()?;
|
|
|
|
// Open database
|
|
let config_dir = crate::utils::get_cli_config_dir()?;
|
|
let db_path = config_dir.join("ente.db");
|
|
let storage = Storage::new(&db_path)?;
|
|
|
|
// Get accounts to export
|
|
let accounts = if let Some(email) = account_email {
|
|
// Export specific account - try to find it with any app
|
|
let all_accounts = storage.accounts().list()?;
|
|
log::debug!("Found {} total accounts", all_accounts.len());
|
|
let matching: Vec<Account> = all_accounts
|
|
.into_iter()
|
|
.filter(|a| a.email == email)
|
|
.collect();
|
|
|
|
if matching.is_empty() {
|
|
return Err(crate::Error::NotFound(format!(
|
|
"Account not found: {email}"
|
|
)));
|
|
}
|
|
matching
|
|
} else {
|
|
// Export all accounts
|
|
storage.accounts().list()?
|
|
};
|
|
|
|
if accounts.is_empty() {
|
|
println!("No accounts configured. Use 'ente-rs account add' first.");
|
|
return Ok(());
|
|
}
|
|
|
|
// Export each account
|
|
for account in accounts {
|
|
println!("\n=== Exporting account: {} ===", account.email);
|
|
|
|
// First sync the account (like Go implementation does)
|
|
println!("Syncing account data...");
|
|
if let Err(e) = sync_account_before_export(&storage, &account).await {
|
|
log::error!("Failed to sync account {}: {}", account.email, e);
|
|
println!("❌ Sync failed: {e}");
|
|
continue;
|
|
}
|
|
println!("✅ Sync completed!");
|
|
|
|
if let Err(e) = export_account(&storage, &account, &filter).await {
|
|
log::error!("Failed to export account {}: {}", account.email, e);
|
|
println!("❌ Export failed: {e}");
|
|
} else {
|
|
println!("✅ Export completed successfully!");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn sync_account_before_export(storage: &Storage, account: &Account) -> Result<()> {
|
|
// Get stored secrets
|
|
let secrets = storage
|
|
.accounts()
|
|
.get_secrets(account.user_id, account.app)?
|
|
.ok_or_else(|| crate::Error::NotFound("Account secrets not found".into()))?;
|
|
|
|
// Create API client with account's endpoint
|
|
let api_client = ApiClient::new(Some(account.endpoint.clone()))?;
|
|
|
|
// Store token for this account
|
|
let token = base64::engine::general_purpose::URL_SAFE.encode(&secrets.token);
|
|
api_client.add_token(&account.email, &token);
|
|
|
|
// Get the database path to create a new Storage instance
|
|
let db_path = storage
|
|
.db_path()
|
|
.ok_or_else(|| crate::Error::Generic("Database path not available".into()))?;
|
|
|
|
// Create new Storage instance for sync engine (needed for ownership)
|
|
let sync_storage = Storage::new(db_path)?;
|
|
|
|
// Create sync engine
|
|
let sync_engine = SyncEngine::new(api_client, sync_storage, account.clone());
|
|
|
|
// Run sync for this account
|
|
let stats = sync_engine.sync().await?;
|
|
|
|
log::info!(
|
|
"Sync completed: {} new collections, {} new files",
|
|
stats.collections.new,
|
|
stats.files.new
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn export_account(storage: &Storage, account: &Account, filter: &ExportFilter) -> Result<()> {
|
|
// Get export directory
|
|
let export_dir = account
|
|
.export_dir
|
|
.as_ref()
|
|
.ok_or_else(|| crate::Error::InvalidInput("No export directory configured".into()))?;
|
|
let export_path = Path::new(export_dir);
|
|
|
|
println!("Export directory: {export_dir}");
|
|
|
|
// Create export directory if needed
|
|
fs::create_dir_all(export_path).await?;
|
|
|
|
// Track exported hashes for deduplication within this export session
|
|
let mut exported_hashes: HashMap<String, PathBuf> = HashMap::new();
|
|
|
|
// Track albums that have metadata written
|
|
let mut albums_with_metadata: HashMap<String, bool> = HashMap::new();
|
|
|
|
// Track file indices per album for unique metadata filenames
|
|
let mut album_file_indices: HashMap<String, usize> = HashMap::new();
|
|
|
|
// Get stored secrets
|
|
let secrets = storage
|
|
.accounts()
|
|
.get_secrets(account.user_id, account.app)?
|
|
.ok_or_else(|| crate::Error::NotFound("Account secrets not found".into()))?;
|
|
|
|
// Create API client with account's endpoint
|
|
let api_client = ApiClient::new(Some(account.endpoint.clone()))?;
|
|
|
|
// Store token for this account
|
|
// Token is stored as raw bytes from sealed_box_open
|
|
// The Go CLI encodes it as base64 URL-encoded string WITH padding for the API
|
|
let token = base64::engine::general_purpose::URL_SAFE.encode(&secrets.token);
|
|
api_client.add_token(&account.email, &token);
|
|
|
|
let api = ApiMethods::new(&api_client);
|
|
|
|
// Master key is already raw bytes, no need to decode
|
|
let master_key = &secrets.master_key;
|
|
|
|
// We'll also need the secret key for decrypting collection keys
|
|
let secret_key = &secrets.secret_key;
|
|
|
|
// Step 1: Fetch all collections and create a map of collection IDs to collections
|
|
println!("\nFetching collections...");
|
|
let collections = api.get_collections(&account.email, 0).await?;
|
|
println!("Found {} collections", collections.len());
|
|
|
|
// Create collection ID to collection map and decrypt collection keys
|
|
let mut collection_map: HashMap<i64, (crate::api::models::Collection, Vec<u8>)> =
|
|
HashMap::new();
|
|
|
|
for mut collection in collections {
|
|
// Skip deleted collections
|
|
if collection.is_deleted {
|
|
continue;
|
|
}
|
|
|
|
log::debug!(
|
|
"Processing collection {}: name={:?}, encrypted_name={:?}",
|
|
collection.id,
|
|
collection.name,
|
|
collection.encrypted_name
|
|
);
|
|
|
|
// Decrypt collection key
|
|
// Shared collections don't have key_decryption_nonce - they use a different mechanism
|
|
let Some(ref key_nonce) = collection.key_decryption_nonce else {
|
|
log::warn!(
|
|
"Collection {} appears to be shared (no key_decryption_nonce), skipping for now",
|
|
collection.id
|
|
);
|
|
continue;
|
|
};
|
|
|
|
let collection_key = match decrypt_collection_key(
|
|
&collection.encrypted_key,
|
|
key_nonce,
|
|
master_key,
|
|
secret_key,
|
|
) {
|
|
Ok(key) => key,
|
|
Err(e) => {
|
|
log::error!(
|
|
"Failed to decrypt collection key for {}: {e}",
|
|
collection.id
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Decrypt collection name if it's encrypted
|
|
if collection.name.as_ref().is_none_or(|n| n.is_empty())
|
|
&& let Some(ref encrypted_name) = collection.encrypted_name
|
|
&& let Some(ref nonce) = collection.name_decryption_nonce
|
|
{
|
|
match decrypt_collection_name(encrypted_name, nonce, &collection_key) {
|
|
Ok(name) => {
|
|
log::debug!("Decrypted collection {} name: {}", collection.id, name);
|
|
collection.name = Some(name);
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Failed to decrypt collection {} name: {}", collection.id, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
collection_map.insert(collection.id, (collection, collection_key));
|
|
}
|
|
|
|
// Step 2: Fetch all files from all collections (like Go CLI does)
|
|
println!("\nFetching all files...");
|
|
let mut all_files = Vec::new();
|
|
|
|
// Iterate through each collection and fetch its files
|
|
for collection_id in collection_map.keys() {
|
|
let mut has_more = true;
|
|
let mut since_time = 0i64;
|
|
|
|
while has_more {
|
|
let (files, more) = api
|
|
.get_collection_files(&account.email, *collection_id, since_time)
|
|
.await?;
|
|
has_more = more;
|
|
|
|
if files.is_empty() {
|
|
break;
|
|
}
|
|
|
|
// Update since_time for next batch
|
|
for file in &files {
|
|
if file.updation_time > since_time {
|
|
since_time = file.updation_time;
|
|
}
|
|
}
|
|
|
|
all_files.extend(files);
|
|
}
|
|
}
|
|
|
|
println!("Found {} total files", all_files.len());
|
|
|
|
// Step 3: Process each file and export to the correct album folder
|
|
let mut total_files = 0;
|
|
let mut exported_files = 0;
|
|
let mut skipped_files = 0;
|
|
let mut deleted_files = 0;
|
|
|
|
for file in all_files {
|
|
// Skip deleted files
|
|
if file.is_deleted {
|
|
deleted_files += 1;
|
|
continue;
|
|
}
|
|
|
|
// Count non-deleted files
|
|
total_files += 1;
|
|
|
|
// Find the collection this file belongs to
|
|
// Files have a collection_id field that indicates which album they belong to
|
|
let collection_info = match collection_map.get(&file.collection_id) {
|
|
Some(info) => info,
|
|
None => {
|
|
log::debug!(
|
|
"File {} belongs to unknown/deleted collection {}",
|
|
file.id,
|
|
file.collection_id
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let (collection, collection_key) = collection_info;
|
|
|
|
// Apply collection filters
|
|
let collection_name = collection.name.as_deref().unwrap_or("Unnamed");
|
|
|
|
// Determine if collection is shared
|
|
let is_shared = collection.sharees.as_ref().is_some_and(|s| !s.is_empty())
|
|
|| collection.shared_magic_metadata.is_some()
|
|
|| collection.owner.id != account.user_id;
|
|
|
|
// Determine if collection is hidden from metadata
|
|
let is_hidden = check_collection_visibility(collection, collection_key);
|
|
|
|
// Log collection visibility for debugging
|
|
log::debug!(
|
|
"Collection {}: name={:?}, is_hidden={}, is_shared={}",
|
|
collection.id,
|
|
collection.name,
|
|
is_hidden,
|
|
is_shared
|
|
);
|
|
|
|
if !filter.should_include_collection(collection_name, is_shared, is_hidden) {
|
|
log::debug!("Skipping file in filtered collection: {}", collection_name);
|
|
continue;
|
|
}
|
|
|
|
// Decrypt the file key using the collection key
|
|
let file_key = match decrypt_file_key(
|
|
&file.encrypted_key,
|
|
&file.key_decryption_nonce,
|
|
collection_key,
|
|
) {
|
|
Ok(key) => key,
|
|
Err(e) => {
|
|
log::error!("Failed to decrypt key for file {}: {}", file.id, e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Decrypt metadata to get original filename and hash
|
|
let metadata = match decrypt_file_metadata(&file, &file_key) {
|
|
Ok(meta) => meta,
|
|
Err(e) => {
|
|
log::warn!("Failed to decrypt metadata for file {}: {}", file.id, e);
|
|
None
|
|
}
|
|
};
|
|
|
|
// 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
|
|
};
|
|
|
|
// Check for deduplication by hash
|
|
let content_hash = if let Some(ref meta) = metadata {
|
|
let hash = match meta.get_file_type() {
|
|
crate::models::metadata::FileType::Image => {
|
|
meta.image_hash.as_ref().or(meta.hash.as_ref())
|
|
}
|
|
crate::models::metadata::FileType::Video => {
|
|
meta.video_hash.as_ref().or(meta.hash.as_ref())
|
|
}
|
|
_ => meta.hash.as_ref(),
|
|
};
|
|
if let Some(h) = hash {
|
|
log::debug!("File {} has hash: {}", file.id, h);
|
|
} else {
|
|
log::debug!("File {} has no hash in metadata", file.id);
|
|
}
|
|
hash
|
|
} else {
|
|
log::debug!("File {} has no metadata", file.id);
|
|
None
|
|
};
|
|
|
|
// Check if we've already exported a file with this hash
|
|
if let Some(hash) = content_hash
|
|
&& let Some(existing_path) = exported_hashes.get(hash)
|
|
{
|
|
log::info!(
|
|
"Skipping duplicate file {} (same hash as {})",
|
|
file.id,
|
|
existing_path.display()
|
|
);
|
|
skipped_files += 1;
|
|
continue;
|
|
}
|
|
|
|
// Generate export path with original filename from metadata
|
|
let file_path = generate_export_path(
|
|
export_path,
|
|
&file,
|
|
Some(collection),
|
|
metadata.as_ref(),
|
|
pub_magic_metadata.as_ref(),
|
|
)?;
|
|
|
|
// Skip if file already exists on disk
|
|
if file_path.exists() {
|
|
log::debug!("File already exists: {file_path:?}");
|
|
skipped_files += 1;
|
|
|
|
// Add to hash map even for existing files to prevent duplicates
|
|
if let Some(hash) = content_hash {
|
|
exported_hashes.insert(hash.clone(), file_path.clone());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Download and save file
|
|
log::debug!("Downloading file {} to {:?}", file.id, file_path);
|
|
|
|
// Ensure directory exists
|
|
if let Some(parent) = file_path.parent() {
|
|
fs::create_dir_all(parent).await?;
|
|
}
|
|
|
|
// Download encrypted file
|
|
let encrypted_data = api.download_file(&account.email, file.id).await?;
|
|
|
|
// The file nonce/header is stored separately in the API response
|
|
let file_nonce =
|
|
match base64::engine::general_purpose::STANDARD.decode(&file.file.decryption_header) {
|
|
Ok(nonce) => nonce,
|
|
Err(e) => {
|
|
log::error!("Failed to decode file nonce for file {}: {}", file.id, e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Decrypt the file data using streaming XChaCha20-Poly1305
|
|
// Use chunked decryption for large files
|
|
let decrypted = match decrypt_file_data(&encrypted_data, &file_nonce, &file_key) {
|
|
Ok(data) => data,
|
|
Err(e) => {
|
|
log::error!("Failed to decrypt file {}: {}", file.id, e);
|
|
log::debug!(
|
|
"File size: {}, header length: {}",
|
|
encrypted_data.len(),
|
|
file_nonce.len()
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Check if this is a live photo that needs extraction
|
|
let is_live_photo = metadata
|
|
.as_ref()
|
|
.map(|m| m.is_live_photo())
|
|
.unwrap_or(false);
|
|
|
|
if is_live_photo {
|
|
// Extract live photo components from ZIP
|
|
if let Err(e) = extract_live_photo(&decrypted, &file_path).await {
|
|
log::error!("Failed to extract live photo {}: {}", file.id, e);
|
|
// Fall back to saving as ZIP
|
|
let mut file_handle = fs::File::create(&file_path).await?;
|
|
file_handle.write_all(&decrypted).await?;
|
|
file_handle.sync_all().await?;
|
|
}
|
|
} else {
|
|
// Write regular file
|
|
let mut file_handle = fs::File::create(&file_path).await?;
|
|
file_handle.write_all(&decrypted).await?;
|
|
file_handle.sync_all().await?;
|
|
}
|
|
|
|
exported_files += 1;
|
|
|
|
// Add hash to deduplication map
|
|
if let Some(hash) = content_hash {
|
|
exported_hashes.insert(hash.clone(), file_path.clone());
|
|
}
|
|
|
|
// Determine album folder for metadata
|
|
let album_folder = if let Some(ref name) = collection.name
|
|
&& !name.is_empty()
|
|
{
|
|
sanitize_album_name(name)
|
|
} else {
|
|
"Uncategorized".to_string()
|
|
};
|
|
|
|
// Write album metadata if not already written for this album
|
|
if !albums_with_metadata.contains_key(&album_folder) {
|
|
write_album_metadata(export_path, &album_folder, collection, account.user_id).await?;
|
|
albums_with_metadata.insert(album_folder.clone(), true);
|
|
}
|
|
|
|
// Get and increment file index for this album
|
|
let file_index = album_file_indices.entry(album_folder.clone()).or_insert(0);
|
|
|
|
// Write file metadata
|
|
let filename = file_path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("file");
|
|
write_file_metadata(
|
|
export_path,
|
|
&album_folder,
|
|
&file,
|
|
metadata.as_ref(),
|
|
filename,
|
|
*file_index,
|
|
)
|
|
.await?;
|
|
|
|
*file_index += 1;
|
|
|
|
// Progress indicator - show every 10 files for better UX
|
|
if exported_files % 10 == 0 || exported_files == 1 {
|
|
println!(" [{}/{}] Exported files...", exported_files, total_files);
|
|
}
|
|
}
|
|
|
|
println!("\n{}", "=".repeat(50));
|
|
println!("Export Summary:");
|
|
println!("{}", "=".repeat(50));
|
|
println!(" 📁 Total files (non-deleted): {total_files}");
|
|
println!(" ✅ Successfully exported: {exported_files}");
|
|
|
|
if skipped_files > 0 {
|
|
println!(" ⏭️ Skipped (already exists): {skipped_files}");
|
|
}
|
|
|
|
if deleted_files > 0 {
|
|
println!(" 🗑️ Deleted files (skipped): {deleted_files}");
|
|
}
|
|
|
|
let failed = total_files - exported_files - skipped_files;
|
|
if failed > 0 {
|
|
println!(" ❌ Failed to export: {failed}");
|
|
}
|
|
|
|
if exported_files == total_files {
|
|
println!("\n🎉 All files exported successfully!");
|
|
} else if exported_files > 0 {
|
|
println!("\n✨ Export completed with {exported_files} new files!");
|
|
} else if skipped_files == total_files {
|
|
println!("\n✨ All files already exported!");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_export_path(
|
|
export_dir: &Path,
|
|
file: &crate::api::models::File,
|
|
collection: Option<&crate::api::models::Collection>,
|
|
metadata: Option<&FileMetadata>,
|
|
pub_magic_metadata: Option<&serde_json::Value>,
|
|
) -> Result<PathBuf> {
|
|
// Start with export directory
|
|
let mut path = export_dir.to_path_buf();
|
|
|
|
// Match Go CLI structure: export_dir/AlbumName/filename
|
|
// Use collection/album name as folder, or "Uncategorized" if none
|
|
let album_folder = if let Some(col) = collection
|
|
&& let Some(ref name) = col.name
|
|
&& !name.is_empty()
|
|
{
|
|
// Sanitize collection name for filesystem (matching Go's approach)
|
|
sanitize_album_name(name)
|
|
} else {
|
|
// Files without a collection go to "Uncategorized" (matching Go CLI)
|
|
"Uncategorized".to_string()
|
|
};
|
|
|
|
path.push(album_folder);
|
|
|
|
// 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, None)
|
|
}
|
|
};
|
|
path.push(filename);
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
/// Decrypt a collection key using the master key
|
|
fn decrypt_collection_key(
|
|
encrypted_key: &str,
|
|
nonce: &str,
|
|
master_key: &[u8],
|
|
_secret_key: &[u8],
|
|
) -> Result<Vec<u8>> {
|
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
|
|
let encrypted_bytes = BASE64.decode(encrypted_key)?;
|
|
let nonce_bytes = BASE64.decode(nonce)?;
|
|
|
|
// Collection keys are encrypted with secret_box (XSalsa20-Poly1305) using master key
|
|
secret_box_open(&encrypted_bytes, &nonce_bytes, master_key)
|
|
}
|
|
|
|
/// Decrypt a collection name using the collection key
|
|
fn decrypt_collection_name(
|
|
encrypted_name: &str,
|
|
nonce: &str,
|
|
collection_key: &[u8],
|
|
) -> Result<String> {
|
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
|
|
let encrypted_bytes = BASE64.decode(encrypted_name)?;
|
|
let nonce_bytes = BASE64.decode(nonce)?;
|
|
|
|
// Collection names are encrypted with secret_box using the collection key
|
|
let decrypted = secret_box_open(&encrypted_bytes, &nonce_bytes, collection_key)?;
|
|
|
|
// Convert to string
|
|
String::from_utf8(decrypted)
|
|
.map_err(|e| crate::Error::Generic(format!("Invalid UTF-8 in collection name: {}", e)))
|
|
}
|
|
|
|
/// Decrypt a file key using the collection key
|
|
fn decrypt_file_key(encrypted_key: &str, nonce: &str, collection_key: &[u8]) -> Result<Vec<u8>> {
|
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
|
|
let encrypted_bytes = BASE64.decode(encrypted_key)?;
|
|
let nonce_bytes = BASE64.decode(nonce)?;
|
|
|
|
// File keys are encrypted with secret_box (XSalsa20-Poly1305) using collection key
|
|
secret_box_open(&encrypted_bytes, &nonce_bytes, collection_key)
|
|
}
|
|
|
|
/// Generate a fallback filename when metadata is not available
|
|
fn generate_fallback_filename(
|
|
file: &crate::api::models::File,
|
|
metadata: Option<&FileMetadata>,
|
|
) -> String {
|
|
let extension = if let Some(meta) = metadata {
|
|
match meta.get_file_type() {
|
|
crate::models::metadata::FileType::Image => ".jpg",
|
|
crate::models::metadata::FileType::Video => ".mp4",
|
|
crate::models::metadata::FileType::LivePhoto => ".zip",
|
|
crate::models::metadata::FileType::Unknown => ".bin",
|
|
}
|
|
} else if file.thumbnail.size.unwrap_or(0) > 0 {
|
|
".jpg" // Has thumbnail, likely an image
|
|
} else {
|
|
".bin"
|
|
};
|
|
|
|
format!("file_{}{}", file.id, extension)
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// Sanitize an album/collection name for filesystem (matching Go CLI's logic)
|
|
fn sanitize_album_name(name: &str) -> String {
|
|
// Go CLI replaces : and / with _ in album names
|
|
name.chars()
|
|
.map(|c| match c {
|
|
':' | '/' => '_',
|
|
c => c,
|
|
})
|
|
.collect::<String>()
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
/// Decrypt file metadata
|
|
fn decrypt_file_metadata(
|
|
file: &crate::api::models::File,
|
|
file_key: &[u8],
|
|
) -> Result<Option<FileMetadata>> {
|
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
|
|
// Check if metadata exists
|
|
if file.metadata.encrypted_data.is_none() || file.metadata.decryption_header.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let encrypted_data = file.metadata.encrypted_data.as_ref().unwrap();
|
|
let encrypted_bytes = BASE64.decode(encrypted_data)?;
|
|
let header_bytes = BASE64.decode(&file.metadata.decryption_header)?;
|
|
|
|
// Decrypt the metadata using streaming XChaCha20-Poly1305
|
|
let decrypted = decrypt_stream(&encrypted_bytes, &header_bytes, file_key)?;
|
|
|
|
// Parse JSON 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))
|
|
}
|
|
|
|
/// Extract live photo components from a ZIP file
|
|
async fn extract_live_photo(zip_data: &[u8], output_path: &Path) -> Result<()> {
|
|
use zip::ZipArchive;
|
|
|
|
// Parse the ZIP archive
|
|
let cursor = Cursor::new(zip_data);
|
|
let mut archive = ZipArchive::new(cursor)?;
|
|
|
|
// Get the parent directory and base name
|
|
let parent_dir = output_path
|
|
.parent()
|
|
.ok_or_else(|| crate::Error::Generic("Invalid output path".into()))?;
|
|
|
|
let base_name = output_path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.ok_or_else(|| crate::Error::Generic("Invalid filename".into()))?;
|
|
|
|
// Extract each file from the ZIP
|
|
for i in 0..archive.len() {
|
|
let mut file = archive.by_index(i)?;
|
|
let file_name = file.name().to_string();
|
|
|
|
// Determine the output filename based on the content
|
|
let output_file_path = if file_name.to_lowercase().ends_with(".heic")
|
|
|| file_name.to_lowercase().ends_with(".jpg")
|
|
|| file_name.to_lowercase().ends_with(".jpeg")
|
|
{
|
|
// Image component
|
|
parent_dir.join(format!("{}.jpg", base_name))
|
|
} else if file_name.to_lowercase().ends_with(".mov")
|
|
|| file_name.to_lowercase().ends_with(".mp4")
|
|
{
|
|
// Video component
|
|
parent_dir.join(format!("{}.mov", base_name))
|
|
} else {
|
|
// Unknown component - use original extension
|
|
let ext = std::path::Path::new(&file_name)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("bin");
|
|
parent_dir.join(format!("{}.{}", base_name, ext))
|
|
};
|
|
|
|
// Read the file contents
|
|
let mut contents = Vec::new();
|
|
use std::io::Read;
|
|
file.read_to_end(&mut contents)?;
|
|
|
|
// Write to disk
|
|
let mut output_file = fs::File::create(&output_file_path).await?;
|
|
output_file.write_all(&contents).await?;
|
|
output_file.sync_all().await?;
|
|
|
|
log::debug!("Extracted live photo component: {:?}", output_file_path);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if a collection is hidden based on its metadata
|
|
fn check_collection_visibility(
|
|
collection: &crate::api::models::Collection,
|
|
collection_key: &[u8],
|
|
) -> bool {
|
|
// Try encrypted magic metadata (private metadata) - this is where visibility is stored per Go CLI
|
|
if let Some(ref magic_metadata) = collection.magic_metadata {
|
|
// Try to decrypt and parse the magic metadata
|
|
if let Ok(Some(decrypted_json)) = decrypt_magic_metadata(magic_metadata, collection_key) {
|
|
// Check for visibility field - value of 2 means hidden (matching Go CLI logic)
|
|
if let Some(visibility) = decrypted_json.get("visibility").and_then(|v| v.as_i64()) {
|
|
log::debug!(
|
|
"Collection {} has visibility: {} (hidden={})",
|
|
collection.id,
|
|
visibility,
|
|
visibility == 2
|
|
);
|
|
return visibility == 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default to not hidden if we can't determine visibility
|
|
false
|
|
}
|
|
|
|
/// Write album metadata to .meta/album_meta.json
|
|
async fn write_album_metadata(
|
|
export_path: &Path,
|
|
album_folder: &str,
|
|
collection: &crate::api::models::Collection,
|
|
account_id: i64,
|
|
) -> Result<()> {
|
|
let meta_dir = export_path.join(album_folder).join(".meta");
|
|
fs::create_dir_all(&meta_dir).await?;
|
|
|
|
let album_meta = AlbumMetadata::new(
|
|
collection.id,
|
|
collection.owner.id,
|
|
collection
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "Unnamed".to_string()),
|
|
account_id,
|
|
);
|
|
|
|
let meta_path = meta_dir.join("album_meta.json");
|
|
let json = serde_json::to_string_pretty(&album_meta)?;
|
|
fs::write(meta_path, json).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Write file metadata to .meta folder
|
|
async fn write_file_metadata(
|
|
export_path: &Path,
|
|
album_folder: &str,
|
|
file: &crate::api::models::File,
|
|
metadata: Option<&FileMetadata>,
|
|
filename: &str,
|
|
file_index: usize,
|
|
) -> Result<()> {
|
|
let meta_dir = export_path.join(album_folder).join(".meta");
|
|
fs::create_dir_all(&meta_dir).await?;
|
|
|
|
// Generate unique metadata filename
|
|
let base_name = Path::new(filename)
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("file");
|
|
let extension = Path::new(filename)
|
|
.extension()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("bin");
|
|
|
|
// Create metadata filename like "IMG_1234.jpg_0.json"
|
|
let meta_filename = format!("{}_{}.{}.json", base_name, file_index, extension);
|
|
|
|
let mut disk_metadata = DiskFileMetadata::from_file(file, metadata, filename.to_string());
|
|
disk_metadata.meta_file_name = meta_filename.clone();
|
|
|
|
let meta_path = meta_dir.join(meta_filename);
|
|
let json = serde_json::to_string_pretty(&disk_metadata)?;
|
|
fs::write(meta_path, json).await?;
|
|
|
|
Ok(())
|
|
}
|