From 13d66c719f451ca0954463cba02fa4bf2067265b Mon Sep 17 00:00:00 2001 From: Sebastian Pravda Date: Wed, 23 Nov 2022 18:00:59 +0100 Subject: [PATCH] feat: package cli function - exports all personal data into csv file - exports all portfolios into specified directory - exports PostgreSQL database if specified --- cli/Cargo.toml | 1 + cli/src/main.rs | 207 ++++++++++++++++++++++----- core/src/database/query/candidate.rs | 22 +++ 3 files changed, 192 insertions(+), 38 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2a327db..8d5a64e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -14,6 +14,7 @@ portfolio-core = { path = "../core" } [dependencies.tokio] version = "^1.21" features = [ + "process", "macros", ] diff --git a/cli/src/main.rs b/cli/src/main.rs index 867a206..34ae161 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,12 +1,47 @@ -use std::error::Error; use std::path::PathBuf; -use clap::{arg, ArgAction, command, Command, value_parser}; -use sea_orm::{Database, DatabaseConnection}; +use clap::{arg, ArgAction, ArgMatches, command, Command, value_parser}; +use sea_orm::{Database, DatabaseConnection, DbConn}; use url::Url; use portfolio_core::{crypto, Query}; -use portfolio_core::candidate_details::{ApplicationDetails, EncryptedApplicationDetails}; +use portfolio_core::services::portfolio_service::{FileType}; + +async fn get_admin_private_key(db: &DbConn, sub_matches: &ArgMatches) -> Result> { + Ok(match (sub_matches.get_one::("key"), sub_matches.get_one::("password")) { + (Some(key), _) => { + key.to_string() + }, + (_, Some(password)) => { + let admin_id = if let Some(s) = sub_matches.get_one::("admin_id") { + s.parse::().unwrap() + } else { + return Err("Admin ID required")?; + }; + let admin = Query::find_admin_by_id(&db, admin_id) + .await + .map_err(|e| format!("Admin {} not found: {}", admin_id, e))? + .ok_or("Admin not found")?; + crypto::decrypt_password( + admin.private_key, + password.to_string() + ).await? + + }, + _ => { + return Err("Either key or password must be provided")?; + } + }) +} + +async fn get_db_conn(sub_matches: &ArgMatches) -> Result> { + let db_url = sub_matches.get_one::("database").unwrap(); + if db_url.scheme() != "sqlite" && db_url.scheme() != "postgres" { + return Err("URL scheme postgres:// or sqlite:// required")?; + } + let db: DatabaseConnection = Database::connect(db_url.as_str()).await?; + Ok(db) +} #[tokio::main] async fn main() -> Result<(), Box> { @@ -53,19 +88,95 @@ async fn main() -> Result<(), Box> { ) .subcommand( Command::new("portfolio") - .about("Database & Portfolio operations") + .about("Portfolio file operations") .arg( arg!( - -p --portfolio "Path to the portfolio root" + -f --file "Age file path" ) .required(true) .value_parser(value_parser!(PathBuf)), ) + .arg( + arg!( + -o --output "Output file path" + ) + .required(true) + .value_parser(value_parser!(PathBuf)), + ) + .arg( + arg!( + -d --database "URL to the database or sql file with postgres:// or sqlite://" + ) + .alias("url") + .required(true) + .value_parser(value_parser!(Url)), + ) .arg( arg!( -k --key "AGE private key for decryption" ) - .required(true), + .required(false), + ) + .arg( + arg!( + -p --password "Password for decryption" + ) + .required(false), + ) + .arg( + arg!( + -a --admin_id "Admin ID" + ) + .required(false), + ) + ) + .subcommand( + Command::new("package") + .about("Package all data into one zip") + .arg( + arg!( + -s --pg_dump "Backup SQL database with pg_dump (PostgreSQL only)" + ) + ) + .arg( + arg!( + -r --root_dir "Portfolio root directory" + ) + .required(true) + .value_parser(value_parser!(PathBuf)), + ) + .arg( + arg!( + -o --output "Output file path" + ) + .required(true) + .value_parser(value_parser!(PathBuf)), + ) + .arg( + arg!( + -d --database "URL to the database or sql file with postgres:// or sqlite://" + ) + .alias("url") + .required(true) + .value_parser(value_parser!(Url)), + ) + .arg( + arg!( + -k --key "AGE private key for decryption" + ) + .required(false), + ) + .arg( + arg!( + -p --password "Password for decryption" + ) + .required(false), + ) + .arg( + arg!( + -a --admin_id "Admin ID" + ) + .required(false), ) ) .subcommand( @@ -128,43 +239,63 @@ async fn main() -> Result<(), Box> { match clap.subcommand() { Some(("export", sub_matches)) => { - let db_url = sub_matches.get_one::("database").unwrap(); - if db_url.scheme() != "sqlite" && db_url.scheme() != "postgres" { - return Err("URL scheme postgres:// or sqlite:// required")?; - } - let db: DatabaseConnection = Database::connect(db_url.as_str()).await?; - - let key = match (sub_matches.get_one::("key"), sub_matches.get_one::("password")) { - (Some(key), _) => { - key.to_string() - }, - (_, Some(password)) => { - let admin_id = if let Some(s) = sub_matches.get_one::("admin_id") { - s.parse::().unwrap() - } else { - return Err("Admin ID required")?; - }; - let admin = Query::find_admin_by_id(&db, admin_id) - .await - .map_err(|e| format!("Admin {} not found: {}", admin_id, e))? - .ok_or("Admin not found")?; - crypto::decrypt_password( - admin.private_key, - password.to_string() - ).await? - - }, - _ => { - return Err("Either key or password must be provided")?; - } - }; + let db = get_db_conn(sub_matches).await?; + let key = get_admin_private_key(&db, sub_matches).await?; let output = sub_matches.get_one::("output").unwrap(); let csv = portfolio_core::utils::csv::export(&db, key).await?; tokio::fs::write(output, csv).await?; }, Some(("portfolio", sub_matches)) => { - todo!() + let db = get_db_conn(sub_matches).await?; + let key = get_admin_private_key(&db, sub_matches).await?; + + let age_file_path = sub_matches.get_one::("file").unwrap(); + + let decrypted = crypto::decrypt_file_with_private_key_as_buffer(age_file_path, &key).await?; + + let output = sub_matches.get_one::("output").unwrap(); + tokio::fs::write(output, decrypted).await?; + }, + Some(("package", sub_matches)) => { // TODO: compress the output directory into one file??? + let db_url = sub_matches.get_one::("database").unwrap(); + let db = get_db_conn(sub_matches).await?; + let key = get_admin_private_key(&db, sub_matches).await?; + + let portfolio_root_dir = sub_matches.get_one::("root_dir").unwrap(); + let output = sub_matches.get_one::("output").unwrap(); + tokio::fs::create_dir_all(&output).await?; + + let csv = portfolio_core::utils::csv::export(&db, key.to_string()).await?; + tokio::fs::write(output.join("personal_data.csv"), csv).await?; + println!("Exported personal data to personal_data.csv"); + + let ids: Vec = Query::list_all_candidate_ids(&db) + .await? + .iter() + .map(|application_id| application_id.to_i32()) + .collect(); + for id in ids { + let file_path = portfolio_root_dir.join(&id.to_string()).join(FileType::Age.as_str()); + println!("{}", file_path.display()); + let output_path = output.join(&id.to_string()); + if let Ok(portfolio) = crypto::decrypt_file_with_private_key_as_buffer(file_path, &key).await { + tokio::fs::create_dir_all(&output_path).await?; + tokio::fs::write(&output_path.join(FileType::PortfolioZip.as_str()), portfolio).await?; + }; + } + println!("Exported all portfolios"); + + if *sub_matches.get_one::("pg_dump").unwrap_or(&false) { + let file = std::fs::File::create(&output.join("pg_dump.sql"))?; + tokio::process::Command::new("pg_dump") + .args(&[db_url.as_str()]) + .stdout(file) + .spawn() + .expect("failed to start pg_dump"); + } + println!("Exported database"); + } Some(("hash", sub_matches)) => { let input = sub_matches.get_one::("input").unwrap(); diff --git a/core/src/database/query/candidate.rs b/core/src/database/query/candidate.rs index ff5427f..4a696dd 100644 --- a/core/src/database/query/candidate.rs +++ b/core/src/database/query/candidate.rs @@ -40,6 +40,17 @@ pub struct CandidateWithParent { // TODO: use this instead of (Candidate, Parent pub parent_email: Option, } +#[derive(FromQueryResult)] +pub struct ApplicationId { + application: i32, +} + +impl ApplicationId { + pub fn to_i32(&self) -> i32 { + self.application + } +} + impl Query { pub async fn find_candidate_by_id( db: &DbConn, @@ -90,6 +101,17 @@ impl Query { .all(db) .await } + + pub async fn list_all_candidate_ids( + db: &DbConn, + ) -> Result, DbErr> { + Candidate::find() + .order_by(candidate::Column::Application, Order::Asc) + .column(candidate::Column::Application) + .into_model::() + .all(db) + .await + } }