Merge branch 'main' into 15-create-database-tables-for-songs-artists-and-albums
This commit is contained in:
71
src/auth.rs
Normal file
71
src/auth.rs
Normal file
@ -0,0 +1,71 @@
|
||||
use leptos::*;
|
||||
use crate::models::User;
|
||||
|
||||
/// Create a new user and log them in
|
||||
/// Takes in a NewUser struct, with the password in plaintext
|
||||
/// Returns a Result with the error message if the user could not be created
|
||||
#[server(endpoint = "signup")]
|
||||
pub async fn signup(new_user: User) -> Result<(), ServerFnError> {
|
||||
use crate::users::create_user;
|
||||
|
||||
use leptos_actix::extract;
|
||||
use actix_web::{HttpMessage, HttpRequest};
|
||||
use actix_identity::Identity;
|
||||
|
||||
// Ensure the user has no id
|
||||
let new_user = User {
|
||||
id: None,
|
||||
..new_user
|
||||
};
|
||||
|
||||
create_user(&new_user).await
|
||||
.map_err(|e| ServerFnError::ServerError(format!("Error creating user: {}", e)))?;
|
||||
|
||||
extract(|request: HttpRequest| async move {
|
||||
Identity::login(&request.extensions(), new_user.username.clone())
|
||||
}).await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log a user in
|
||||
/// Takes in a username or email and a password in plaintext
|
||||
/// Returns a Result with a boolean indicating if the login was successful
|
||||
#[server(endpoint = "login")]
|
||||
pub async fn login(username_or_email: String, password: String) -> Result<bool, ServerFnError> {
|
||||
use crate::users::validate_user;
|
||||
use actix_web::{HttpMessage, HttpRequest};
|
||||
use actix_identity::Identity;
|
||||
use leptos_actix::extract;
|
||||
|
||||
let possible_user = validate_user(username_or_email, password).await
|
||||
.map_err(|e| ServerFnError::ServerError(format!("Error validating user: {}", e)))?;
|
||||
|
||||
let user = match possible_user {
|
||||
Some(user) => user,
|
||||
None => return Ok(false)
|
||||
};
|
||||
|
||||
extract(|request: HttpRequest| async move {
|
||||
Identity::login(&request.extensions(), user.username.clone())
|
||||
}).await??;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Log a user out
|
||||
/// Returns a Result with the error message if the user could not be logged out
|
||||
#[server(endpoint = "logout")]
|
||||
pub async fn logout() -> Result<(), ServerFnError> {
|
||||
use leptos_actix::extract;
|
||||
use actix_identity::Identity;
|
||||
|
||||
extract(|user: Option<Identity>| async move {
|
||||
if let Some(user) = user {
|
||||
user.logout();
|
||||
}
|
||||
}).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -6,4 +6,6 @@ fn main() {
|
||||
"cargo:rustc-cfg=target=\"{}\"",
|
||||
std::env::var("TARGET").unwrap()
|
||||
);
|
||||
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::logging::log;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
@ -12,6 +13,12 @@ use diesel::{
|
||||
r2d2::Pool,
|
||||
};
|
||||
|
||||
use diesel_migrations::{
|
||||
embed_migrations,
|
||||
EmbeddedMigrations,
|
||||
MigrationHarness,
|
||||
};
|
||||
|
||||
// See https://leward.eu/notes-on-diesel-a-rust-orm/
|
||||
|
||||
// Define some types to make it easier to work with Diesel
|
||||
@ -25,12 +32,59 @@ lazy_static! {
|
||||
|
||||
/// Initialize the database pool
|
||||
///
|
||||
/// Will panic if the DATABASE_URL environment variable is not set, or if there is an error creating the pool.
|
||||
/// Uses DATABASE_URL environment variable to connect to the database if set,
|
||||
/// otherwise builds a connection string from other environment variables.
|
||||
///
|
||||
/// Will panic if either the DATABASE_URL or POSTGRES_HOST environment variables
|
||||
/// are not set, or if there is an error creating the pool.
|
||||
///
|
||||
/// # Returns
|
||||
/// A database pool object, which can be used to get pooled connections
|
||||
fn init_db_pool() -> PgPool {
|
||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| {
|
||||
// Build the database URL from environment variables
|
||||
// Construct a separate log_url to avoid logging the password
|
||||
let mut log_url = "postgres://".to_string();
|
||||
let mut url = "postgres://".to_string();
|
||||
|
||||
if let Ok(user) = env::var("POSTGRES_USER") {
|
||||
url.push_str(&user);
|
||||
log_url.push_str(&user);
|
||||
|
||||
if let Ok(password) = env::var("POSTGRES_PASSWORD") {
|
||||
url.push_str(":");
|
||||
log_url.push_str(":");
|
||||
url.push_str(&password);
|
||||
log_url.push_str("********");
|
||||
}
|
||||
|
||||
url.push_str("@");
|
||||
log_url.push_str("@");
|
||||
}
|
||||
|
||||
let host = env::var("POSTGRES_HOST").expect("DATABASE_URL or POSTGRES_HOST must be set");
|
||||
|
||||
url.push_str(&host);
|
||||
log_url.push_str(&host);
|
||||
|
||||
if let Ok(port) = env::var("POSTGRES_PORT") {
|
||||
url.push_str(":");
|
||||
url.push_str(&port);
|
||||
log_url.push_str(":");
|
||||
log_url.push_str(&port);
|
||||
}
|
||||
|
||||
if let Ok(dbname) = env::var("POSTGRES_DB") {
|
||||
url.push_str("/");
|
||||
url.push_str(&dbname);
|
||||
log_url.push_str("/");
|
||||
log_url.push_str(&dbname);
|
||||
}
|
||||
|
||||
log!("Connecting to database: {}", log_url);
|
||||
url
|
||||
});
|
||||
|
||||
let manager = ConnectionManager::<PgConnection>::new(database_url);
|
||||
PgPool::builder()
|
||||
.build(manager)
|
||||
@ -47,5 +101,15 @@ pub fn get_db_conn() -> PgPooledConn {
|
||||
DB_POOL.get().expect("Failed to get a database connection from the pool.")
|
||||
}
|
||||
|
||||
/// Embedded database migrations into the binary
|
||||
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||
|
||||
/// Run any pending migrations in the database
|
||||
/// Always safe to call, as it will only run migrations that have not already been run
|
||||
pub fn migrate() {
|
||||
let db_con = &mut get_db_conn();
|
||||
db_con.run_pending_migrations(DB_MIGRATIONS).expect("Could not run database migrations");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod songdata;
|
||||
pub mod playstatus;
|
||||
pub mod playbar;
|
||||
pub mod database;
|
||||
pub mod models;
|
||||
pub mod users;
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
|
22
src/main.rs
22
src/main.rs
@ -8,12 +8,32 @@ extern crate openssl;
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
extern crate diesel_migrations;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
use actix_identity::IdentityMiddleware;
|
||||
use actix_session::storage::RedisSessionStore;
|
||||
use actix_session::SessionMiddleware;
|
||||
use actix_web::cookie::Key;
|
||||
|
||||
use dotenv::dotenv;
|
||||
dotenv().ok();
|
||||
|
||||
// Bring the database up to date
|
||||
libretunes::database::migrate();
|
||||
|
||||
let session_secret_key = if let Ok(key) = std::env::var("SESSION_SECRET_KEY") {
|
||||
Key::from(key.as_bytes())
|
||||
} else {
|
||||
Key::generate()
|
||||
};
|
||||
|
||||
let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||
let redis_store = RedisSessionStore::new(redis_url).await.unwrap();
|
||||
|
||||
use actix_files::Files;
|
||||
use actix_web::*;
|
||||
use leptos::*;
|
||||
@ -40,6 +60,8 @@ async fn main() -> std::io::Result<()> {
|
||||
.service(favicon)
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
|
||||
.app_data(web::Data::new(leptos_options.to_owned()))
|
||||
.wrap(IdentityMiddleware::default())
|
||||
.wrap(SessionMiddleware::new(redis_store.clone(), session_secret_key.clone()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
|
@ -19,37 +19,28 @@ cfg_if! {
|
||||
// diesel-specific attributes to the models when compiling for the server
|
||||
|
||||
/// Model for a "User", used for querying the database
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::users))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
/// A unique id for the user
|
||||
pub id: i32,
|
||||
/// The user's username
|
||||
pub username: String,
|
||||
/// The user's email
|
||||
pub email: String,
|
||||
/// The user's password, stored as a hash
|
||||
pub password: String,
|
||||
/// The time the user was created
|
||||
pub created_at: SystemTime,
|
||||
}
|
||||
|
||||
/// Model for a "New User", used for inserting into the database
|
||||
/// Note that this model does not have an id or created_at field, as those are automatically
|
||||
/// generated by the database and we don't want to deal with them ourselves
|
||||
#[cfg_attr(feature = "ssr", derive(Insertable))]
|
||||
/// Various fields are wrapped in Options, because they are not always wanted for inserts/retrieval
|
||||
/// Using deserialize_as makes Diesel use the specified type when deserializing from the database,
|
||||
/// and then call .into() to convert it into the Option
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::users))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct NewUser {
|
||||
pub struct User {
|
||||
/// A unique id for the user
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||
// #[cfg_attr(feature = "ssr", diesel(skip_insertion))] // This feature is not yet released
|
||||
pub id: Option<i32>,
|
||||
/// The user's username
|
||||
pub username: String,
|
||||
/// The user's email
|
||||
pub email: String,
|
||||
/// The user's password, stored as a hash
|
||||
pub password: String,
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
|
||||
pub password: Option<String>,
|
||||
/// The time the user was created
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))]
|
||||
pub created_at: Option<SystemTime>,
|
||||
}
|
||||
|
||||
/// Model for an artist
|
||||
|
104
src/users.rs
Normal file
104
src/users.rs
Normal file
@ -0,0 +1,104 @@
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use diesel::prelude::*;
|
||||
use crate::database::get_db_conn;
|
||||
|
||||
use pbkdf2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng,
|
||||
PasswordHasher, PasswordHash, SaltString, PasswordVerifier, Error
|
||||
},
|
||||
Pbkdf2
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
use leptos::*;
|
||||
use crate::models::User;
|
||||
|
||||
/// Get a user from the database by username or email
|
||||
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn find_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
// Look for either a username or email that matches the input, and return an option with None if no user is found
|
||||
let db_con = &mut get_db_conn();
|
||||
let user = users.filter(username.eq(username_or_email.clone())).or_filter(email.eq(username_or_email))
|
||||
.first::<User>(db_con).optional()
|
||||
.map_err(|e| ServerFnError::ServerError(format!("Error getting user from database: {}", e)))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Create a new user in the database
|
||||
/// Returns an empty Result if successful, or an error if there was a problem
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn create_user(new_user: &User) -> Result<(), ServerFnError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
|
||||
let new_password = new_user.password.clone()
|
||||
.ok_or(ServerFnError::ServerError(format!("No password provided for user {}", new_user.username)))?;
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password_hash = Pbkdf2.hash_password(new_password.as_bytes(), &salt)
|
||||
.map_err(|_| ServerFnError::ServerError("Error hashing password".to_string()))?.to_string();
|
||||
|
||||
let new_user = User {
|
||||
password: Some(password_hash),
|
||||
..new_user.clone()
|
||||
};
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
diesel::insert_into(users).values(&new_user).execute(db_con)
|
||||
.map_err(|e| ServerFnError::ServerError(format!("Error creating user: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a user's credentials
|
||||
/// Returns a Result with the user if the credentials are valid, None if not valid, or an error if there was a problem
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn validate_user(username_or_email: String, password: String) -> Result<Option<User>, ServerFnError> {
|
||||
let db_user = find_user(username_or_email.clone()).await
|
||||
.map_err(|e| ServerFnError::ServerError(format!("Error getting user from database: {}", e)))?;
|
||||
|
||||
// If the user is not found, return None
|
||||
let db_user = match db_user {
|
||||
Some(user) => user,
|
||||
None => return Ok(None)
|
||||
};
|
||||
|
||||
let db_password = db_user.password.clone()
|
||||
.ok_or(ServerFnError::ServerError(format!("No password found for user {}", db_user.username)))?;
|
||||
|
||||
let password_hash = PasswordHash::new(&db_password)
|
||||
.map_err(|e| ServerFnError::ServerError(format!("Error hashing supplied password: {}", e)))?;
|
||||
|
||||
match Pbkdf2.verify_password(password.as_bytes(), &password_hash) {
|
||||
Ok(()) => {},
|
||||
Err(Error::Password) => {
|
||||
return Ok(None);
|
||||
},
|
||||
Err(e) => {
|
||||
return Err(ServerFnError::ServerError(format!("Error verifying password: {}", e)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(db_user))
|
||||
}
|
||||
|
||||
/// Get a user from the database by username or email
|
||||
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
|
||||
#[server(endpoint = "get_user")]
|
||||
pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
|
||||
let mut user = find_user(username_or_email).await?;
|
||||
|
||||
// Remove the password hash before returning the user
|
||||
if let Some(user) = user.as_mut() {
|
||||
user.password = None;
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
Reference in New Issue
Block a user