mirror of
https://github.com/danbulant/Portfolio
synced 2026-07-04 02:20:50 +00:00
feat: package cli function
- exports all personal data into csv file - exports all portfolios into specified directory - exports PostgreSQL database if specified
This commit is contained in:
parent
be61af2b05
commit
13d66c719f
3 changed files with 192 additions and 38 deletions
|
|
@ -14,6 +14,7 @@ portfolio-core = { path = "../core" }
|
||||||
[dependencies.tokio]
|
[dependencies.tokio]
|
||||||
version = "^1.21"
|
version = "^1.21"
|
||||||
features = [
|
features = [
|
||||||
|
"process",
|
||||||
"macros",
|
"macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
207
cli/src/main.rs
207
cli/src/main.rs
|
|
@ -1,12 +1,47 @@
|
||||||
use std::error::Error;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use clap::{arg, ArgAction, command, Command, value_parser};
|
use clap::{arg, ArgAction, ArgMatches, command, Command, value_parser};
|
||||||
use sea_orm::{Database, DatabaseConnection};
|
use sea_orm::{Database, DatabaseConnection, DbConn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use portfolio_core::{crypto, Query};
|
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<String, Box<dyn std::error::Error>> {
|
||||||
|
Ok(match (sub_matches.get_one::<String>("key"), sub_matches.get_one::<String>("password")) {
|
||||||
|
(Some(key), _) => {
|
||||||
|
key.to_string()
|
||||||
|
},
|
||||||
|
(_, Some(password)) => {
|
||||||
|
let admin_id = if let Some(s) = sub_matches.get_one::<String>("admin_id") {
|
||||||
|
s.parse::<i32>().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<DbConn, Box<dyn std::error::Error>> {
|
||||||
|
let db_url = sub_matches.get_one::<Url>("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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
@ -53,19 +88,95 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("portfolio")
|
Command::new("portfolio")
|
||||||
.about("Database & Portfolio operations")
|
.about("Portfolio file operations")
|
||||||
.arg(
|
.arg(
|
||||||
arg!(
|
arg!(
|
||||||
-p --portfolio <PATH> "Path to the portfolio root"
|
-f --file <PATH> "Age file path"
|
||||||
)
|
)
|
||||||
.required(true)
|
.required(true)
|
||||||
.value_parser(value_parser!(PathBuf)),
|
.value_parser(value_parser!(PathBuf)),
|
||||||
)
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-o --output <PATH> "Output file path"
|
||||||
|
)
|
||||||
|
.required(true)
|
||||||
|
.value_parser(value_parser!(PathBuf)),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-d --database <URL> "URL to the database or sql file with postgres:// or sqlite://"
|
||||||
|
)
|
||||||
|
.alias("url")
|
||||||
|
.required(true)
|
||||||
|
.value_parser(value_parser!(Url)),
|
||||||
|
)
|
||||||
.arg(
|
.arg(
|
||||||
arg!(
|
arg!(
|
||||||
-k --key <KEY> "AGE private key for decryption"
|
-k --key <KEY> "AGE private key for decryption"
|
||||||
)
|
)
|
||||||
.required(true),
|
.required(false),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-p --password <PASSWORD> "Password for decryption"
|
||||||
|
)
|
||||||
|
.required(false),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-a --admin_id <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 <PATH> "Portfolio root directory"
|
||||||
|
)
|
||||||
|
.required(true)
|
||||||
|
.value_parser(value_parser!(PathBuf)),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-o --output <PATH> "Output file path"
|
||||||
|
)
|
||||||
|
.required(true)
|
||||||
|
.value_parser(value_parser!(PathBuf)),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-d --database <URL> "URL to the database or sql file with postgres:// or sqlite://"
|
||||||
|
)
|
||||||
|
.alias("url")
|
||||||
|
.required(true)
|
||||||
|
.value_parser(value_parser!(Url)),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-k --key <KEY> "AGE private key for decryption"
|
||||||
|
)
|
||||||
|
.required(false),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-p --password <PASSWORD> "Password for decryption"
|
||||||
|
)
|
||||||
|
.required(false),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
arg!(
|
||||||
|
-a --admin_id <ADMIN_ID> "Admin ID"
|
||||||
|
)
|
||||||
|
.required(false),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
|
|
@ -128,43 +239,63 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
match clap.subcommand() {
|
match clap.subcommand() {
|
||||||
Some(("export", sub_matches)) => {
|
Some(("export", sub_matches)) => {
|
||||||
let db_url = sub_matches.get_one::<Url>("database").unwrap();
|
let db = get_db_conn(sub_matches).await?;
|
||||||
if db_url.scheme() != "sqlite" && db_url.scheme() != "postgres" {
|
let key = get_admin_private_key(&db, sub_matches).await?;
|
||||||
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::<String>("key"), sub_matches.get_one::<String>("password")) {
|
|
||||||
(Some(key), _) => {
|
|
||||||
key.to_string()
|
|
||||||
},
|
|
||||||
(_, Some(password)) => {
|
|
||||||
let admin_id = if let Some(s) = sub_matches.get_one::<String>("admin_id") {
|
|
||||||
s.parse::<i32>().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 output = sub_matches.get_one::<PathBuf>("output").unwrap();
|
let output = sub_matches.get_one::<PathBuf>("output").unwrap();
|
||||||
let csv = portfolio_core::utils::csv::export(&db, key).await?;
|
let csv = portfolio_core::utils::csv::export(&db, key).await?;
|
||||||
tokio::fs::write(output, csv).await?;
|
tokio::fs::write(output, csv).await?;
|
||||||
},
|
},
|
||||||
Some(("portfolio", sub_matches)) => {
|
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::<PathBuf>("file").unwrap();
|
||||||
|
|
||||||
|
let decrypted = crypto::decrypt_file_with_private_key_as_buffer(age_file_path, &key).await?;
|
||||||
|
|
||||||
|
let output = sub_matches.get_one::<PathBuf>("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::<Url>("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::<PathBuf>("root_dir").unwrap();
|
||||||
|
let output = sub_matches.get_one::<PathBuf>("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<i32> = 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::<bool>("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)) => {
|
Some(("hash", sub_matches)) => {
|
||||||
let input = sub_matches.get_one::<String>("input").unwrap();
|
let input = sub_matches.get_one::<String>("input").unwrap();
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,17 @@ pub struct CandidateWithParent { // TODO: use this instead of (Candidate, Parent
|
||||||
pub parent_email: Option<String>,
|
pub parent_email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(FromQueryResult)]
|
||||||
|
pub struct ApplicationId {
|
||||||
|
application: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationId {
|
||||||
|
pub fn to_i32(&self) -> i32 {
|
||||||
|
self.application
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Query {
|
impl Query {
|
||||||
pub async fn find_candidate_by_id(
|
pub async fn find_candidate_by_id(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
|
|
@ -90,6 +101,17 @@ impl Query {
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_all_candidate_ids(
|
||||||
|
db: &DbConn,
|
||||||
|
) -> Result<Vec<ApplicationId>, DbErr> {
|
||||||
|
Candidate::find()
|
||||||
|
.order_by(candidate::Column::Application, Order::Asc)
|
||||||
|
.column(candidate::Column::Application)
|
||||||
|
.into_model::<ApplicationId>()
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue