use crate::app_config::AppConfig; use crate::constants; use crate::controllers::HttpResult; use crate::converters::finances_manager_converter::{ FinancesManagerAccount, FinancesManagerFile, FinancesManagerMovement, }; use crate::extractors::auth_extractor::AuthExtractor; use crate::extractors::file_extractor::FileExtractor; use crate::models::accounts::{Account, AccountID, AccountType}; use crate::models::files; use crate::models::files::FileID; use crate::models::movements::Movement; use crate::services::accounts_service::UpdateAccountQuery; use crate::services::movements_service::UpdateMovementQuery; use crate::services::{accounts_service, files_service, movements_service}; use crate::utils::time_utils::{format_date, time}; use actix_files::NamedFile; use actix_web::{HttpRequest, HttpResponse}; use serde::Serialize; use serde::de::DeserializeOwned; use std::collections::HashMap; use std::fs::File; use std::io::{Cursor, Read, Seek, Write}; use zip::write::SimpleFileOptions; use zip::{ZipArchive, ZipWriter}; #[derive(thiserror::Error, Debug)] pub enum BackupControllerError { #[error("The account with id {0:?} does not exists!")] NonexistentAccountId(AccountID), #[error("The file with id {0:?} does not exists!")] NonexistentFileId(FileID), } /// Generate export filename fn export_filename(ext: &str) -> String { format!( "export_{}.{ext}", format_date(time() as i64).unwrap().replace('/', "-") ) } /// Import data from a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file pub async fn finances_manager_import(auth: AuthExtractor, file: FileExtractor) -> HttpResult { let file = FinancesManagerFile::parse(&String::from_utf8_lossy(&file.buff))?; // Create each account & push the movements independently for file_account in file.accounts { let account = accounts_service::create( auth.user_id(), &UpdateAccountQuery { name: file_account.name, r#type: AccountType::Cash, }, ) .await?; for file_movement in file_account.movements { movements_service::create(&UpdateMovementQuery { account_id: account.id(), time: file_movement.time, label: file_movement.label, file_id: None, amount: file_movement.amount, checked: false, }) .await?; } } Ok(HttpResponse::Accepted().finish()) } /// Export data to a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file pub async fn finances_manager_export(auth: AuthExtractor) -> HttpResult { let accounts = accounts_service::get_list_user(auth.user_id()).await?; let mut out = FinancesManagerFile { accounts: vec![] }; for account in accounts { let movements = movements_service::get_list_account(account.id()).await?; let mut file_account = FinancesManagerAccount { name: account.name, movements: Vec::with_capacity(movements.len()), }; for movement in movements { file_account.movements.push(FinancesManagerMovement { label: movement.label, time: movement.time as u64, amount: movement.amount, }); } out.accounts.push(file_account); } Ok(HttpResponse::Ok() .insert_header(( "Content-Disposition", format!("attachment; filename={}", export_filename("finance")), )) .body(out.encode())) } /// Add JSON file to ZIP fn zip_json( zip: &mut ZipWriter, path: &str, content: &E, ) -> anyhow::Result<()> { let file_encoded = serde_json::to_string(&content)?; let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated) .unix_permissions(0o750); zip.start_file(path, options)?; zip.write_all(file_encoded.as_bytes())?; Ok(()) } /// Export all user data as ZIP pub async fn zip_export(req: HttpRequest, auth: AuthExtractor) -> HttpResult { let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; let zip_path = dest_dir.path().join("export.zip"); let file = File::create(&zip_path)?; let mut zip = ZipWriter::new(file); // Process all JSON documents zip_json( &mut zip, constants::zip_export::ACCOUNTS_FILE, &accounts_service::get_list_user(auth.user_id()).await?, )?; zip_json( &mut zip, constants::zip_export::MOVEMENTS_FILE, &movements_service::get_all_movements_user(auth.user_id()).await?, )?; let files_list = files_service::get_all_files_user(auth.user_id()).await?; zip_json(&mut zip, constants::zip_export::FILES_FILE, &files_list)?; // TODO : inbox // Process all files for file in files_list { let buff = files_service::get_file_content(&file).await?; let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated) .unix_permissions(0o750); zip.start_file( format!("{}{}", constants::zip_export::FILES_DIR, file.sha512), options, )?; zip.write_all(&buff)?; } // Finalize ZIP and return response zip.finish()?; let file = File::open(zip_path)?; let file = NamedFile::from_file(file, export_filename("zip"))?; Ok(file.into_response(&req)) } /// Read JSON from archive fn unzip_json( zip: &mut ZipArchive, path: &str, ) -> anyhow::Result { let mut file = zip.by_name(path)?; let mut content = String::with_capacity(file.size() as usize); file.read_to_string(&mut content)?; Ok(serde_json::from_str(&content)?) } /// Replace all data with data included in ZIP file pub async fn zip_import(auth: AuthExtractor, file: FileExtractor) -> HttpResult { // Parse provided files let zip_cursor = Cursor::new(file.buff); let mut zip = ZipArchive::new(zip_cursor)?; let new_accounts: Vec = unzip_json(&mut zip, constants::zip_export::ACCOUNTS_FILE)?; let new_movements: Vec = unzip_json(&mut zip, constants::zip_export::MOVEMENTS_FILE)?; let new_files: Vec = unzip_json(&mut zip, constants::zip_export::FILES_FILE)?; // TODO : inbox // Delete all data accounts_service::delete_all_user(auth.user_id()).await?; files_service::delete_all_user(auth.user_id()).await?; // Create the files let mut files_mapping = HashMap::new(); for file in new_files { let mut zip_file = zip .by_name(&format!( "{}{}", constants::zip_export::FILES_DIR, file.sha512 )) .map_err(|e| anyhow::anyhow!("Could not find file with hash {}: {}", file.sha512, e))?; let mut file_buff = Vec::with_capacity(zip_file.size() as usize); zip_file.read_to_end(&mut file_buff)?; let created_file = files_service::create_file_with_file_name(auth.user_id(), &file.file_name, &file_buff) .await?; files_mapping.insert(file.id(), created_file); } // Create the accounts let mut accounts_mapping = HashMap::new(); for account in new_accounts { let created_account = accounts_service::create( auth.user_id(), &UpdateAccountQuery { name: account.name.to_string(), r#type: account.account_type(), }, ) .await?; if account.default_account { accounts_service::set_default(auth.user_id(), created_account.id()).await?; } accounts_mapping.insert(account.id(), created_account); } // Create the movements for movement in new_movements { movements_service::create(&UpdateMovementQuery { account_id: accounts_mapping .get(&movement.account_id()) .ok_or_else(|| BackupControllerError::NonexistentAccountId(movement.account_id()))? .id(), time: movement.time as u64, label: movement.label.to_string(), file_id: movement .file_id() .map(|file_id| { files_mapping .get(&file_id) .ok_or(BackupControllerError::NonexistentFileId(file_id)) }) .transpose()? .map(|f| f.id()), amount: movement.amount, checked: movement.checked, }) .await?; } Ok(HttpResponse::Accepted().finish()) }