MoneyMgr/moneymgr_backend/src/controllers/backup_controller.rs
2025-05-05 20:38:54 +02:00

260 lines
8.6 KiB
Rust

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<E: Serialize>(
zip: &mut ZipWriter<File>,
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<E: DeserializeOwned, R: Read + Seek>(
zip: &mut ZipArchive<R>,
path: &str,
) -> anyhow::Result<E> {
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<Account> = unzip_json(&mut zip, constants::zip_export::ACCOUNTS_FILE)?;
let new_movements: Vec<Movement> = unzip_json(&mut zip, constants::zip_export::MOVEMENTS_FILE)?;
let new_files: Vec<files::File> = 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())
}