From aac878a245a8a987dc88cf50cc2bda3473e810b1 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 5 May 2025 20:38:46 +0200 Subject: [PATCH] Can import ZIP --- .../src/controllers/backup_controller.rs | 110 +++++++++++++++++- .../src/controllers/files_controller.rs | 2 +- moneymgr_backend/src/controllers/mod.rs | 3 + moneymgr_backend/src/main.rs | 4 + moneymgr_backend/src/models/accounts.rs | 2 +- moneymgr_backend/src/models/files.rs | 6 +- moneymgr_backend/src/models/movements.rs | 2 +- .../src/services/accounts_service.rs | 8 ++ .../src/services/files_service.rs | 19 ++- moneymgr_web/src/api/BackupApi.ts | 20 ++++ moneymgr_web/src/routes/BackupRoute.tsx | 34 +++++- 11 files changed, 193 insertions(+), 17 deletions(-) diff --git a/moneymgr_backend/src/controllers/backup_controller.rs b/moneymgr_backend/src/controllers/backup_controller.rs index e2399f9..0901b71 100644 --- a/moneymgr_backend/src/controllers/backup_controller.rs +++ b/moneymgr_backend/src/controllers/backup_controller.rs @@ -6,7 +6,10 @@ use crate::converters::finances_manager_converter::{ }; use crate::extractors::auth_extractor::AuthExtractor; use crate::extractors::file_extractor::FileExtractor; -use crate::models::accounts::AccountType; +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}; @@ -14,10 +17,20 @@ 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::Write; -use zip::ZipWriter; +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 { @@ -153,3 +166,94 @@ pub async fn zip_export(req: HttpRequest, auth: AuthExtractor) -> HttpResult { 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()) +} diff --git a/moneymgr_backend/src/controllers/files_controller.rs b/moneymgr_backend/src/controllers/files_controller.rs index d0f657b..2cb804b 100644 --- a/moneymgr_backend/src/controllers/files_controller.rs +++ b/moneymgr_backend/src/controllers/files_controller.rs @@ -102,7 +102,7 @@ pub async fn serve_file(req: HttpRequest, file: &File, download_file: bool) -> H /// Delete an uploaded file pub async fn delete(file_extractor: FileIdExtractor) -> HttpResult { - match files_service::delete_file_if_unused(file_extractor.as_ref().id()).await { + match files_service::delete_file_if_unused(file_extractor.as_ref().id(), true).await { Ok(true) => Ok(HttpResponse::Accepted().finish()), Ok(false) => Ok(HttpResponse::Conflict().finish()), Err(e) => { diff --git a/moneymgr_backend/src/controllers/mod.rs b/moneymgr_backend/src/controllers/mod.rs index 0508847..61a8672 100644 --- a/moneymgr_backend/src/controllers/mod.rs +++ b/moneymgr_backend/src/controllers/mod.rs @@ -1,3 +1,4 @@ +use crate::controllers::backup_controller::BackupControllerError; use actix_web::http::StatusCode; use actix_web::{HttpResponse, ResponseError}; use std::error::Error; @@ -35,6 +36,8 @@ pub enum HttpFailure { ZipError(#[from] ZipError), #[error("a standard I/O manipulation error occurred: {0}")] StdIoError(#[from] std::io::Error), + #[error("an error occurred while performing backup/recovery operation: {0}")] + BackupControllerError(#[from] BackupControllerError), } impl ResponseError for HttpFailure { diff --git a/moneymgr_backend/src/main.rs b/moneymgr_backend/src/main.rs index f4529a8..a238189 100644 --- a/moneymgr_backend/src/main.rs +++ b/moneymgr_backend/src/main.rs @@ -174,6 +174,10 @@ async fn main() -> std::io::Result<()> { "/api/backup/zip/export", web::get().to(backup_controller::zip_export), ) + .route( + "/api/backup/zip/import", + web::post().to(backup_controller::zip_import), + ) // Static assets .route("/", web::get().to(static_controller::root_index)) .route( diff --git a/moneymgr_backend/src/models/accounts.rs b/moneymgr_backend/src/models/accounts.rs index a1ffac0..efe8909 100644 --- a/moneymgr_backend/src/models/accounts.rs +++ b/moneymgr_backend/src/models/accounts.rs @@ -28,7 +28,7 @@ impl Display for AccountType { } } -#[derive(Queryable, Debug, Clone, serde::Serialize)] +#[derive(Queryable, Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Account { id: i32, pub name: String, diff --git a/moneymgr_backend/src/models/files.rs b/moneymgr_backend/src/models/files.rs index 1df9643..b61b09e 100644 --- a/moneymgr_backend/src/models/files.rs +++ b/moneymgr_backend/src/models/files.rs @@ -2,10 +2,12 @@ use crate::models::users::UserID; use crate::schema::*; use diesel::prelude::*; -#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive( + Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord, +)] pub struct FileID(pub i32); -#[derive(Queryable, Debug, serde::Serialize)] +#[derive(Queryable, Debug, serde::Serialize, serde::Deserialize)] pub struct File { id: i32, pub time_create: i64, diff --git a/moneymgr_backend/src/models/movements.rs b/moneymgr_backend/src/models/movements.rs index 824d26f..0422416 100644 --- a/moneymgr_backend/src/models/movements.rs +++ b/moneymgr_backend/src/models/movements.rs @@ -7,7 +7,7 @@ use diesel::{Insertable, Queryable, QueryableByName}; pub struct MovementID(pub i32); /// Single movement information -#[derive(Queryable, QueryableByName, Debug, Clone, serde::Serialize)] +#[derive(Queryable, QueryableByName, Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Movement { /// The ID of the movement id: i32, diff --git a/moneymgr_backend/src/services/accounts_service.rs b/moneymgr_backend/src/services/accounts_service.rs index 1acd6be..3091cf7 100644 --- a/moneymgr_backend/src/services/accounts_service.rs +++ b/moneymgr_backend/src/services/accounts_service.rs @@ -97,3 +97,11 @@ pub async fn delete(id: AccountID) -> anyhow::Result<()> { Ok(()) } + +/// Delete all the accounts of a user +pub async fn delete_all_user(user: UserID) -> anyhow::Result<()> { + for account in get_list_user(user).await? { + delete(account.id()).await?; + } + Ok(()) +} diff --git a/moneymgr_backend/src/services/files_service.rs b/moneymgr_backend/src/services/files_service.rs index cc21939..918cef7 100644 --- a/moneymgr_backend/src/services/files_service.rs +++ b/moneymgr_backend/src/services/files_service.rs @@ -13,6 +13,8 @@ use mime_guess::Mime; enum FilesServiceError { #[error("UnknownMimeType!")] UnknownMimeType, + #[error("Could not delete all the files of the user: a file is still referenced!")] + DeleteAllUserFileStillReferenced, } pub async fn create_file_with_file_name( @@ -84,11 +86,11 @@ pub async fn get_file_content(file: &File) -> anyhow::Result> { /// Delete the file if it is not referenced anymore in the database. Returns true /// if the file was actually deleted, false otherwise -pub async fn delete_file_if_unused(id: FileID) -> anyhow::Result { +pub async fn delete_file_if_unused(id: FileID, skip_age_check: bool) -> anyhow::Result { let file = get_file_with_id(id)?; // Check if the file is old enough - if file.time_create as u64 + constants::MIN_FILE_LIFETIME > time() { + if file.time_create as u64 + constants::MIN_FILE_LIFETIME > time() && !skip_age_check { return Ok(false); } @@ -129,10 +131,21 @@ pub async fn run_garbage_collector() -> anyhow::Result { let mut count_deleted = 0; for file in get_entire_list().await? { - if delete_file_if_unused(file.id()).await? { + if delete_file_if_unused(file.id(), false).await? { count_deleted += 1; } } Ok(count_deleted) } + +/// Delete all the files of a given user +pub async fn delete_all_user(user_id: UserID) -> anyhow::Result<()> { + for file in get_all_files_user(user_id).await? { + if !delete_file_if_unused(file.id(), true).await? { + return Err(FilesServiceError::DeleteAllUserFileStillReferenced.into()); + } + } + + Ok(()) +} diff --git a/moneymgr_web/src/api/BackupApi.ts b/moneymgr_web/src/api/BackupApi.ts index ab1881e..ea0074e 100644 --- a/moneymgr_web/src/api/BackupApi.ts +++ b/moneymgr_web/src/api/BackupApi.ts @@ -20,4 +20,24 @@ export class BackupApi { formData: fd, }); } + + /** + * ZIP Export + */ + static get ZIPExportURL(): string { + return APIClient.backendURL() + "/backup/zip/export"; + } + + /** + * ZIP import + */ + static async ZIPImport(file: File): Promise { + const fd = new FormData(); + fd.append("file", file); + await APIClient.exec({ + method: "POST", + uri: "/backup/zip/import", + formData: fd, + }); + } } diff --git a/moneymgr_web/src/routes/BackupRoute.tsx b/moneymgr_web/src/routes/BackupRoute.tsx index 45520f1..5c5aa77 100644 --- a/moneymgr_web/src/routes/BackupRoute.tsx +++ b/moneymgr_web/src/routes/BackupRoute.tsx @@ -1,4 +1,4 @@ -import { mdiCash } from "@mdi/js"; +import { mdiCash, mdiFolderZipOutline } from "@mdi/js"; import Icon from "@mdi/react"; import DownloadIcon from "@mui/icons-material/Download"; import UploadIcon from "@mui/icons-material/Upload"; @@ -24,7 +24,23 @@ import { RouterLink } from "../widgets/RouterLink"; export function BackupRoute(): React.ReactElement { return ( - + + {/* ZIP */} + } + label="ZIP" + description={ + <> + Perform an exhaustive export or import (of accounts, inbox, + movements and files). + + } + importWarning="Existing data will be COMPLETELY ERASED, before starting import!" + exportURL={BackupApi.ZIPExportURL} + onImport={BackupApi.ZIPImport} + acceptedFiles={"application/zip"} + /> + {/* FinancesManager */} } @@ -38,7 +54,7 @@ export function BackupRoute(): React.ReactElement { rel="noreferrer" style={{ color: "inherit" }} > - FinanceManager + FinancesManager {" "} file format (does not support file attachments). @@ -59,6 +75,7 @@ function ImportExportModal(p: { importWarning?: string; exportURL: string; onImport?: (file: File) => Promise; + acceptedFiles?: string; }): React.ReactElement { const confirm = useConfirm(); const alert = useAlert(); @@ -70,6 +87,7 @@ function ImportExportModal(p: { try { const fileEl = document.createElement("input"); fileEl.type = "file"; + if (p.acceptedFiles) fileEl.accept = p.acceptedFiles; fileEl.click(); // Wait for a file to be chosen @@ -108,10 +126,14 @@ function ImportExportModal(p: { }; return ( - - + + - + {p.description}