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::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<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
|
||||
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) => {
|
||||
|
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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<Vec<u8>> {
|
||||
|
||||
/// 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<bool> {
|
||||
pub async fn delete_file_if_unused(id: FileID, skip_age_check: bool) -> anyhow::Result<bool> {
|
||||
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<usize> {
|
||||
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(())
|
||||
}
|
||||
|
@ -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<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 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 (
|
||||
<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 */}
|
||||
<ImportExportModal
|
||||
icon={<Icon path={mdiCash} size={1} />}
|
||||
@ -38,7 +54,7 @@ export function BackupRoute(): React.ReactElement {
|
||||
rel="noreferrer"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
FinanceManager
|
||||
FinancesManager
|
||||
</a>{" "}
|
||||
file format (does not support file attachments).
|
||||
</>
|
||||
@ -59,6 +75,7 @@ function ImportExportModal(p: {
|
||||
importWarning?: string;
|
||||
exportURL: string;
|
||||
onImport?: (file: File) => Promise<void>;
|
||||
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 (
|
||||
<Grid size={{ md: 6 }}>
|
||||
<Card sx={{ maxWidth: 345 }} elevation={3} variant="outlined">
|
||||
<Grid size={{ md: 6, lg: 3 }} style={{ height: "100%" }}>
|
||||
<Card
|
||||
elevation={3}
|
||||
variant="outlined"
|
||||
style={{ height: "100%", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<CardHeader avatar={p.icon} title={p.label} />
|
||||
<CardContent>
|
||||
<CardContent style={{ flex: 1 }}>
|
||||
<Typography style={{ textAlign: "justify" }}>
|
||||
{p.description}
|
||||
</Typography>
|
||||
|
Loading…
x
Reference in New Issue
Block a user