use std::path::PathBuf; 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::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> { let clap = command!() .propagate_version(true) .subcommand_required(true) .arg_required_else_help(true) .subcommand( Command::new("export") .about("Export all candidate data to a CSV file") .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( Command::new("portfolio") .about("Portfolio file operations") .arg( arg!( -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(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( Command::new("hash") .about("Hash operations") .arg( arg!( -i --input "Plaintext to hash" ) .required(true) ), ) .subcommand( Command::new("symmetric") .about("Symmetric encryption operations") .arg( arg!( -d --decrypt ... "Decrypt flag" ) .action(ArgAction::SetTrue) .required(false) ) .arg( arg!( -i --input "Plaintext to encrypt/decrypt" ) .required(true) ) .arg( arg!( -k --key "Key for encryption/decryption" ) .required(true), ) ) .subcommand( Command::new("asymmetric") .about("Asymmetric encryption operations") .arg( arg!( -d --decrypt ... "Decrypt flag" ) .action(ArgAction::SetTrue) .required(false) ) .arg( arg!( -i --input "Plaintext to encrypt/decrypt" ) .required(true) ) .arg( arg!( -k --key "Public key / Private key" ) .required(true), ) ) .get_matches(); match clap.subcommand() { Some(("export", sub_matches)) => { 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)) => { 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)) => { 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(); let hash = portfolio_core::crypto::hash_password(input.to_string()).await?; println!("{}", hash); } Some(("symmetric", sub_matches)) => { let decrypt = sub_matches.get_one::("decrypt").unwrap(); let input = sub_matches.get_one::("input").unwrap(); let key = sub_matches.get_one::("key").unwrap(); let result = if !*decrypt { portfolio_core::crypto::encrypt_password(input.to_string(), key.to_string()).await? } else { portfolio_core::crypto::decrypt_password(input.to_string(), key.to_string()).await? }; println!("{}", result); } Some(("asymmetric", sub_matches)) => { let decrypt = sub_matches.get_one::("decrypt").unwrap(); let input = sub_matches.get_one::("input").unwrap(); let key = sub_matches.get_one::("key").unwrap(); let result = if !*decrypt { portfolio_core::crypto::encrypt_password_with_recipients(input, &vec![key]).await? } else { portfolio_core::crypto::decrypt_password_with_private_key(input, key).await.map_err(|e| e.to_string())? }; println!("{}", result); } _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), } Ok(()) }