Can import ZIP
This commit is contained in:
parent
f335b9d0c0
commit
aac878a245
@ -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())
|
||||||
|
}
|
||||||
|
@ -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) => {
|
||||||
|
@ -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 {
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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(())
|
||||||
|
}
|
||||||
|
@ -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(())
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user