Can import ZIP

This commit is contained in:
Pierre HUBERT 2025-05-05 20:38:46 +02:00
parent f335b9d0c0
commit aac878a245
11 changed files with 193 additions and 17 deletions

View File

@ -6,7 +6,10 @@ use crate::converters::finances_manager_converter::{
}; };
use crate::extractors::auth_extractor::AuthExtractor; use crate::extractors::auth_extractor::AuthExtractor;
use crate::extractors::file_extractor::FileExtractor; 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::accounts_service::UpdateAccountQuery;
use crate::services::movements_service::UpdateMovementQuery; use crate::services::movements_service::UpdateMovementQuery;
use crate::services::{accounts_service, files_service, movements_service}; 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_files::NamedFile;
use actix_web::{HttpRequest, HttpResponse}; use actix_web::{HttpRequest, HttpResponse};
use serde::Serialize; use serde::Serialize;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::{Cursor, Read, Seek, Write};
use zip::ZipWriter;
use zip::write::SimpleFileOptions; 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 /// Generate export filename
fn export_filename(ext: &str) -> String { 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)) 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())
}

View File

@ -102,7 +102,7 @@ pub async fn serve_file(req: HttpRequest, file: &File, download_file: bool) -> H
/// Delete an uploaded file /// Delete an uploaded file
pub async fn delete(file_extractor: FileIdExtractor) -> HttpResult { 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(true) => Ok(HttpResponse::Accepted().finish()),
Ok(false) => Ok(HttpResponse::Conflict().finish()), Ok(false) => Ok(HttpResponse::Conflict().finish()),
Err(e) => { Err(e) => {

View File

@ -1,3 +1,4 @@
use crate::controllers::backup_controller::BackupControllerError;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError}; use actix_web::{HttpResponse, ResponseError};
use std::error::Error; use std::error::Error;
@ -35,6 +36,8 @@ pub enum HttpFailure {
ZipError(#[from] ZipError), ZipError(#[from] ZipError),
#[error("a standard I/O manipulation error occurred: {0}")] #[error("a standard I/O manipulation error occurred: {0}")]
StdIoError(#[from] std::io::Error), StdIoError(#[from] std::io::Error),
#[error("an error occurred while performing backup/recovery operation: {0}")]
BackupControllerError(#[from] BackupControllerError),
} }
impl ResponseError for HttpFailure { impl ResponseError for HttpFailure {

View File

@ -174,6 +174,10 @@ async fn main() -> std::io::Result<()> {
"/api/backup/zip/export", "/api/backup/zip/export",
web::get().to(backup_controller::zip_export), web::get().to(backup_controller::zip_export),
) )
.route(
"/api/backup/zip/import",
web::post().to(backup_controller::zip_import),
)
// Static assets // Static assets
.route("/", web::get().to(static_controller::root_index)) .route("/", web::get().to(static_controller::root_index))
.route( .route(

View File

@ -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 { pub struct Account {
id: i32, id: i32,
pub name: String, pub name: String,

View File

@ -2,10 +2,12 @@ use crate::models::users::UserID;
use crate::schema::*; use crate::schema::*;
use diesel::prelude::*; 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); pub struct FileID(pub i32);
#[derive(Queryable, Debug, serde::Serialize)] #[derive(Queryable, Debug, serde::Serialize, serde::Deserialize)]
pub struct File { pub struct File {
id: i32, id: i32,
pub time_create: i64, pub time_create: i64,

View File

@ -7,7 +7,7 @@ use diesel::{Insertable, Queryable, QueryableByName};
pub struct MovementID(pub i32); pub struct MovementID(pub i32);
/// Single movement information /// Single movement information
#[derive(Queryable, QueryableByName, Debug, Clone, serde::Serialize)] #[derive(Queryable, QueryableByName, Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Movement { pub struct Movement {
/// The ID of the movement /// The ID of the movement
id: i32, id: i32,

View File

@ -97,3 +97,11 @@ pub async fn delete(id: AccountID) -> anyhow::Result<()> {
Ok(()) 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(())
}

View File

@ -13,6 +13,8 @@ use mime_guess::Mime;
enum FilesServiceError { enum FilesServiceError {
#[error("UnknownMimeType!")] #[error("UnknownMimeType!")]
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( pub async fn create_file_with_file_name(
@ -84,11 +86,11 @@ pub async fn get_file_content(file: &File) -> anyhow::Result<Vec<u8>> {
/// Delete the file if it is not referenced anymore in the database. Returns true /// Delete the file if it is not referenced anymore in the database. Returns true
/// if the file was actually deleted, false otherwise /// if the file was actually deleted, false otherwise
pub async fn delete_file_if_unused(id: FileID) -> anyhow::Result<bool> { pub async fn delete_file_if_unused(id: FileID, skip_age_check: bool) -> anyhow::Result<bool> {
let file = get_file_with_id(id)?; let file = get_file_with_id(id)?;
// Check if the file is old enough // 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); return Ok(false);
} }
@ -129,10 +131,21 @@ pub async fn run_garbage_collector() -> anyhow::Result<usize> {
let mut count_deleted = 0; let mut count_deleted = 0;
for file in get_entire_list().await? { 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; count_deleted += 1;
} }
} }
Ok(count_deleted) 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(())
}

View File

@ -20,4 +20,24 @@ export class BackupApi {
formData: fd, formData: fd,
}); });
} }
/**
* ZIP Export
*/
static get ZIPExportURL(): string {
return APIClient.backendURL() + "/backup/zip/export";
}
/**
* ZIP import
*/
static async ZIPImport(file: File): Promise<void> {
const fd = new FormData();
fd.append("file", file);
await APIClient.exec({
method: "POST",
uri: "/backup/zip/import",
formData: fd,
});
}
} }

View File

@ -1,4 +1,4 @@
import { mdiCash } from "@mdi/js"; import { mdiCash, mdiFolderZipOutline } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import UploadIcon from "@mui/icons-material/Upload"; import UploadIcon from "@mui/icons-material/Upload";
@ -24,7 +24,23 @@ import { RouterLink } from "../widgets/RouterLink";
export function BackupRoute(): React.ReactElement { export function BackupRoute(): React.ReactElement {
return ( return (
<MoneyMgrWebRouteContainer label={"Backup & Restore"}> <MoneyMgrWebRouteContainer label={"Backup & Restore"}>
<Grid container> <Grid container spacing={2}>
{/* ZIP */}
<ImportExportModal
icon={<Icon path={mdiFolderZipOutline} size={1} />}
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 */} {/* FinancesManager */}
<ImportExportModal <ImportExportModal
icon={<Icon path={mdiCash} size={1} />} icon={<Icon path={mdiCash} size={1} />}
@ -38,7 +54,7 @@ export function BackupRoute(): React.ReactElement {
rel="noreferrer" rel="noreferrer"
style={{ color: "inherit" }} style={{ color: "inherit" }}
> >
FinanceManager FinancesManager
</a>{" "} </a>{" "}
file format (does not support file attachments). file format (does not support file attachments).
</> </>
@ -59,6 +75,7 @@ function ImportExportModal(p: {
importWarning?: string; importWarning?: string;
exportURL: string; exportURL: string;
onImport?: (file: File) => Promise<void>; onImport?: (file: File) => Promise<void>;
acceptedFiles?: string;
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
const alert = useAlert(); const alert = useAlert();
@ -70,6 +87,7 @@ function ImportExportModal(p: {
try { try {
const fileEl = document.createElement("input"); const fileEl = document.createElement("input");
fileEl.type = "file"; fileEl.type = "file";
if (p.acceptedFiles) fileEl.accept = p.acceptedFiles;
fileEl.click(); fileEl.click();
// Wait for a file to be chosen // Wait for a file to be chosen
@ -108,10 +126,14 @@ function ImportExportModal(p: {
}; };
return ( return (
<Grid size={{ md: 6 }}> <Grid size={{ md: 6, lg: 3 }} style={{ height: "100%" }}>
<Card sx={{ maxWidth: 345 }} elevation={3} variant="outlined"> <Card
elevation={3}
variant="outlined"
style={{ height: "100%", display: "flex", flexDirection: "column" }}
>
<CardHeader avatar={p.icon} title={p.label} /> <CardHeader avatar={p.icon} title={p.label} />
<CardContent> <CardContent style={{ flex: 1 }}>
<Typography style={{ textAlign: "justify" }}> <Typography style={{ textAlign: "justify" }}>
{p.description} {p.description}
</Typography> </Typography>