use std::sync::Arc; use actix::Actor; use actix_session::{storage::RedisActorSessionStore, SessionMiddleware}; use actix_web::cookie::Key; use actix_web::middleware::Logger; use actix_web::web::Data; use actix_web::{web, App, HttpResponse, HttpServer}; use gumdrop::Options; use password_hash::SaltString; use validator::{validate_email, validate_length}; use crate::actors::database; use crate::logic::hash_pass; use crate::model::{Email, Login, PassHash, Role}; pub mod actors; pub mod logic; pub mod model; pub mod routes; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Failed to boot. {0:?}")] Boot(std::io::Error), #[error("Unable to read password file. {0:?}")] PassFile(std::io::Error), #[error("Unable to read password from STDIN. {0:?}")] ReadPass(std::io::Error), #[error("{0}")] Database(#[from] database::Error), } pub type Result = std::result::Result; #[derive(Options, Debug)] struct Opts { help: bool, #[options(command)] cmd: Option, } #[derive(Options, Debug)] enum Command { #[options(help = "Run server")] Server(ServerOpts), #[options(help = "Migrate database")] Migrate(MigrateOpts), #[options(help = "Generate new salt for passwords")] GenerateHash(GenerateHashOpts), #[options(help = "Create new account")] CreateAccount(CreateAccountOpts), } impl Default for Command { fn default() -> Self { Command::Server(ServerOpts::default()) } } #[derive(Options, Debug)] struct GenerateHashOpts { help: bool, } #[derive(Options, Debug)] struct ServerOpts { help: bool, bind: String, port: u16, db_url: Option, } impl Default for ServerOpts { fn default() -> Self { Self { help: false, bind: "0.0.0.0".to_string(), port: 8080, db_url: None } } } impl ServerOpts { fn db_url(&self) -> String { self.db_url .as_deref() .map(String::from) .or_else(|| std::env::var("DATABASE_URL").ok()) .unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar")) } } #[derive(Options, Debug)] struct MigrateOpts { help: bool, db_url: Option, } impl MigrateOpts { fn db_url(&self) -> String { self.db_url .as_deref() .map(String::from) .or_else(|| std::env::var("DATABASE_URL").ok()) .unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar")) } } #[derive(Debug, Options)] struct CreateAccountOpts { help: bool, #[options(command)] cmd: Option, } #[derive(Debug, Options)] enum CreateAccountCmd { Admin(CreateAccountDefinition), User(CreateAccountDefinition), } #[derive(Debug, Options)] struct CreateAccountDefinition { help: bool, #[options(free)] login: String, #[options(free)] email: String, #[options(free)] pass_file: Option, #[options(help = "Database url, it will also look for DATABASE_URL env")] db_url: Option, } impl CreateAccountDefinition { fn db_url(&self) -> String { self.db_url .as_deref() .map(String::from) .or_else(|| std::env::var("DATABASE_URL").ok()) .unwrap_or_else(|| String::from("postgres://postgres@localhost/bazzar")) } } pub struct Config { pass_salt: SaltString, } impl Config { fn load() -> Self { let pass_salt = SaltString::new(&std::env::var("PASS_SALT").expect("PASS_SALT is required")) .expect("Invalid password salt"); Self { pass_salt } } } async fn server(opts: ServerOpts) -> Result<()> { let secret_key = Key::generate(); let redis_connection_string = "127.0.0.1:6379"; let config = Arc::new(Config::load()); let db = database::Database::build(&opts.db_url()).await?.start(); HttpServer::new(move || { App::new() .wrap(Logger::default()) .wrap(actix_web::middleware::Compress::default()) .wrap(SessionMiddleware::new( RedisActorSessionStore::new(redis_connection_string), secret_key.clone(), )) .app_data(Data::new(config.clone())) .app_data(Data::new(db.clone())) .configure(routes::configure) .default_service(web::to(HttpResponse::Ok)) }) .bind((opts.bind, opts.port)) .map_err(Error::Boot)? .run() .await .map_err(Error::Boot) } async fn migrate(opts: MigrateOpts) -> Result<()> { let db = database::Database::build(&opts.db_url()).await?; sqlx::migrate!("../db/migrate").run(db.pool()).await.expect("Failed to migrate"); Ok(()) } async fn generate_hash(_opts: GenerateHashOpts) -> Result<()> { use argon2::password_hash::rand_core::OsRng; let salt = SaltString::generate(&mut OsRng); println!("{salt}"); Ok(()) } async fn create_account(opts: CreateAccountOpts) -> Result<()> { let (role, opts) = match opts.cmd.expect("Account type is mandatory") { CreateAccountCmd::Admin(opts) => (Role::Admin, opts), CreateAccountCmd::User(opts) => (Role::User, opts), }; if !validate_email(&opts.email) { panic!("Invalid email address"); } if !validate_length(&opts.login, Some(4), Some(100), None) { panic!("Login must have at least 4 characters and no more than 100"); } let db = database::Database::build(&opts.db_url()).await?.start(); let pass = match opts.pass_file { Some(path) => std::fs::read_to_string(path).map_err(Error::PassFile)?, None => { let mut s = String::with_capacity(100); std::io::stdin().read_line(&mut s).map_err(Error::ReadPass)?; s } }; let config = Config::load(); let hash = hash_pass(&pass, &config.pass_salt).unwrap(); db.send(database::CreateAccount { email: Email(opts.email), login: Login(opts.login), pass_hash: PassHash(hash), role, }) .await .unwrap() .unwrap(); Ok(()) } #[actix_web::main] async fn main() -> Result<()> { dotenv::dotenv().ok(); pretty_env_logger::init(); let opts: Opts = gumdrop::Options::parse_args_default_or_exit(); match opts.cmd.unwrap_or_default() { Command::Migrate(opts) => migrate(opts).await, Command::Server(opts) => server(opts).await, Command::GenerateHash(opts) => generate_hash(opts).await, Command::CreateAccount(opts) => create_account(opts).await, } }