Merge branch 'main' into 1-add-queue

This commit is contained in:
2024-03-01 02:20:15 -05:00
34 changed files with 1807 additions and 22 deletions

View File

@ -4,6 +4,8 @@ use crate::queue::Queue;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use crate::pages::login::*;
use crate::pages::signup::*;
#[component]
pub fn App() -> impl IntoView {
@ -24,6 +26,8 @@ pub fn App() -> impl IntoView {
<Routes>
<Route path="" view=HomePage/>
<Route path="/*any" view=NotFound/>
<Route path="/login" view=Login />
<Route path="/signup" view=Signup />
</Routes>
</main>
</Router>

71
src/auth.rs Normal file
View 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(())
}

View File

@ -6,4 +6,6 @@ fn main() {
"cargo:rustc-cfg=target=\"{}\"",
std::env::var("TARGET").unwrap()
);
println!("cargo:rerun-if-changed=migrations");
}

View File

@ -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");
}
}
}

View File

@ -1,12 +1,23 @@
pub mod app;
pub mod auth;
pub mod songdata;
pub mod playstatus;
pub mod playbar;
pub mod database;
pub mod queue;
pub mod song;
pub mod models;
pub mod pages;
pub mod users;
pub mod search;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
pub mod schema;
}
}
cfg_if! {
if #[cfg(feature = "hydrate")] {

View File

@ -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)?

292
src/models.rs Normal file
View File

@ -0,0 +1,292 @@
use std::time::SystemTime;
use std::error::Error;
use time::Date;
use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::database::PgPooledConn;
}
}
// These "models" are used to represent the data in the database
// Diesel uses these models to generate the SQL queries that are used to interact with the database.
// These types are also used for API endpoints, for consistency. Because the file must be compiled
// for both the server and the client, we use the `cfg_attr` attribute to conditionally add
// diesel-specific attributes to the models when compiling for the server
/// Model for a "User", used for querying the database
/// 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 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
#[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
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct Artist {
/// A unique id for the artist
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The artist's name
pub name: String,
}
impl Artist {
/// Add an album to this artist in the database
///
/// # Arguments
///
/// * `new_album_id` - The id of the album to add to this artist
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_album(self: &Self, new_album_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
use crate::schema::album_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?;
diesel::insert_into(album_artists)
.values((album_id.eq(new_album_id), artist_id.eq(my_id)))
.execute(conn)?;
Ok(())
}
/// Get albums by artist from the database
///
/// The `id` field of this artist must be present (Some) to get albums
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Album>, Box<dyn Error>>` - A result indicating success with a vector of albums, or an error
///
#[cfg(feature = "ssr")]
pub fn get_albums(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Album>, Box<dyn Error>> {
use crate::schema::albums::dsl::*;
use crate::schema::album_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get albums")?;
let my_albums = albums
.inner_join(album_artists)
.filter(artist_id.eq(my_id))
.select(albums::all_columns())
.load(conn)?;
Ok(my_albums)
}
/// Add a song to this artist in the database
///
/// The `id` field of this artist must be present (Some) to add a song
///
/// # Arguments
///
/// * `new_song_id` - The id of the song to add to this artist
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_song(self: &Self, new_song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?;
diesel::insert_into(song_artists)
.values((song_id.eq(new_song_id), artist_id.eq(my_id)))
.execute(conn)?;
Ok(())
}
/// Get songs by this artist from the database
///
/// The `id` field of this artist must be present (Some) to get songs
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Song>, Box<dyn Error>>` - A result indicating success with a vector of songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get songs")?;
let my_songs = songs
.inner_join(song_artists)
.filter(artist_id.eq(my_id))
.select(songs::all_columns())
.load(conn)?;
Ok(my_songs)
}
}
/// Model for an album
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::albums))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct Album {
/// A unique id for the album
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The album's title
pub title: String,
/// The album's release date
pub release_date: Option<Date>,
}
impl Album {
/// Add an artist to this album in the database
///
/// The `id` field of this album must be present (Some) to add an artist
///
/// # Arguments
///
/// * `new_artist_id` - The id of the artist to add to this album
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_artist(self: &Self, new_artist_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
use crate::schema::album_artists::dsl::*;
let my_id = self.id.ok_or("Album id must be present (Some) to add an artist")?;
diesel::insert_into(album_artists)
.values((album_id.eq(my_id), artist_id.eq(new_artist_id)))
.execute(conn)?;
Ok(())
}
/// Get songs by this artist from the database
///
/// The `id` field of this album must be present (Some) to get songs
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Song>, Box<dyn Error>>` - A result indicating success with a vector of songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Album id must be present (Some) to get songs")?;
let my_songs = songs
.inner_join(song_artists)
.filter(album_id.eq(my_id))
.select(songs::all_columns())
.load(conn)?;
Ok(my_songs)
}
}
/// Model for a song
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct Song {
/// A unique id for the song
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The song's title
pub title: String,
/// The album the song is from
pub album_id: Option<i32>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<Date>,
/// The path to the song's audio file
pub storage_path: String,
/// The path to the song's image file
pub image_path: Option<String>,
}
impl Song {
/// Add an artist to this song in the database
///
/// The `id` field of this song must be present (Some) to add an artist
///
/// # Arguments
///
/// * `new_artist_id` - The id of the artist to add to this song
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Artist>, Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn get_artists(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Artist>, Box<dyn Error>> {
use crate::schema::artists::dsl::*;
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Song id must be present (Some) to get artists")?;
let my_artists = artists
.inner_join(song_artists)
.filter(song_id.eq(my_id))
.select(artists::all_columns())
.load(conn)?;
Ok(my_artists)
}
}

2
src/pages.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod login;
pub mod signup;

91
src/pages/login.rs Normal file
View File

@ -0,0 +1,91 @@
use crate::auth::login;
use leptos::leptos_dom::*;
use leptos::*;
use leptos_icons::AiIcon::*;
use leptos_icons::IoIcon::*;
use leptos_icons::*;
#[component]
pub fn Login() -> impl IntoView {
let (username_or_email, set_username_or_email) = create_signal("".to_string());
let (password, set_password) = create_signal("".to_string());
let (show_password, set_show_password) = create_signal(false);
let toggle_password = move |_| {
set_show_password.update(|show_password| *show_password = !*show_password);
log!("showing password");
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let username_or_email1 = username_or_email.get();
let password1 = password.get();
spawn_local(async move {
let login_result = login(username_or_email1, password1).await;
if let Err(err) = login_result {
// Handle the error here, e.g., log it or display to the user
log!("Error logging in: {:?}", err);
} else if let Ok(true) = login_result {
// Redirect to the login page
log!("Logged in Successfully!");
leptos_router::use_navigate()("/", Default::default());
log!("Navigated to home page after login");
} else if let Ok(false) = login_result {
log!("Invalid username or password");
}
});
};
view! {
<div class="auth-page-container">
<div class="login-container">
<a class="return" href="/"><Icon icon=Icon::from(IoReturnUpBackSharp) /></a>
<div class="header">
<h1>LibreTunes</h1>
</div>
<form class="login-form" action="POST" on:submit=on_submit>
<div class="input-box">
<input class="login-info" type="text" required
on:input = move |ev| {
set_username_or_email(event_target_value(&ev));
log!("username/email changed to: {}", username_or_email.get());
}
prop:value=username_or_email
/>
<span>Username/Email</span>
<i></i>
</div>
<div class="input-box">
<input class="login-password" type={move || if show_password() { "text" } else { "password"} } required
on:input = move |ev| {
set_password(event_target_value(&ev));
log!("password changed to: {}", password.get());
}
/>
<span>Password</span>
<i></i>
<Show
when=move || {show_password() == false}
fallback=move || view!{ <button on:click=toggle_password class="login-password-visibility">
<Icon icon=Icon::from(AiEyeInvisibleFilled) />
</button> /> }
>
<button on:click=toggle_password class="login-password-visibility">
<Icon icon=Icon::from(AiEyeFilled) />
</button>
</Show>
</div>
<a href="" class="forgot-pw">Forgot Password?</a>
<input type="submit" value="Login" />
<span class="go-to-signup">
New here? <a href="/signup">Create an Account</a>
</span>
</form>
</div>
</div>
}
}

104
src/pages/signup.rs Normal file
View File

@ -0,0 +1,104 @@
use crate::auth::signup;
use crate::models::User;
use leptos::ev::input;
use leptos::leptos_dom::*;
use leptos::*;
use leptos_icons::AiIcon::*;
use leptos_icons::IoIcon::*;
use leptos_icons::*;
#[component]
pub fn Signup() -> impl IntoView {
let (username, set_username) = create_signal("".to_string());
let (email, set_email) = create_signal("".to_string());
let (password, set_password) = create_signal("".to_string());
let (show_password, set_show_password) = create_signal(false);
let navigate = leptos_router::use_navigate();
let toggle_password = move |_| {
set_show_password.update(|show_password| *show_password = !*show_password);
log!("showing password");
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let new_user = User {
id: None,
username: username.get(),
email: email.get(),
password: Some(password.get()),
created_at: None,
};
log!("new user: {:?}", new_user);
spawn_local(async move {
if let Err(err) = signup(new_user).await {
// Handle the error here, e.g., log it or display to the user
log!("Error signing up: {:?}", err);
} else {
// Redirect to the login page
log!("Signed up successfully!");
leptos_router::use_navigate()("/", Default::default());
log!("Navigated to home page after signup")
}
});
};
view! {
<div class="auth-page-container">
<div class="signup-container">
<a class="return" href="/"><Icon icon=Icon::from(IoReturnUpBackSharp) /></a>
<div class="header">
<h1>LibreTunes</h1>
</div>
<form class="signup-form" action="POST" on:submit=on_submit>
<div class="input-box">
<input class="signup-email" type="text" required
on:input = move |ev| {
set_email(event_target_value(&ev));
log!("email changed to: {}", email.get());
}
prop:value=email
/>
<span>Email</span>
<i></i>
</div>
<div class="input-box">
<input class="signup-username" type="text" required
on:input = move |ev| {
set_username(event_target_value(&ev));
log!("username changed to: {}", username.get());
}
/>
<span>Username</span>
<i></i>
</div>
<div class="input-box">
<input class="signup-password" type={move || if show_password() { "text" } else { "password"} } required style="width: 90%;"
on:input = move |ev| {
set_password(event_target_value(&ev));
log!("password changed to: {}", password.get());
}
/>
<span>Password</span>
<i></i>
<Show
when=move || {show_password() == false}
fallback=move || view!{ <button on:click=toggle_password class="password-visibility"> <Icon icon=Icon::from(AiEyeInvisibleFilled) /></button> /> }
>
<button on:click=toggle_password class="password-visibility">
<Icon icon=Icon::from(AiEyeFilled) />
</button>
</Show>
</div>
<input type="submit" value="Sign Up" />
<span class="go-to-login">
Already Have an Account? <a href="/login" class="link" >Go to Login</a>
</span>
</form>
</div>
</div>
}
}

View File

@ -1,5 +1,47 @@
// @generated automatically by Diesel CLI.
diesel::table! {
album_artists (album_id, artist_id) {
album_id -> Int4,
artist_id -> Int4,
}
}
diesel::table! {
albums (id) {
id -> Int4,
title -> Varchar,
release_date -> Nullable<Date>,
}
}
diesel::table! {
artists (id) {
id -> Int4,
name -> Varchar,
}
}
diesel::table! {
song_artists (song_id, artist_id) {
song_id -> Int4,
artist_id -> Int4,
}
}
diesel::table! {
songs (id) {
id -> Int4,
title -> Varchar,
album_id -> Nullable<Int4>,
track -> Nullable<Int4>,
duration -> Int4,
release_date -> Nullable<Date>,
storage_path -> Varchar,
image_path -> Nullable<Varchar>,
}
}
diesel::table! {
users (id) {
id -> Int4,
@ -9,3 +51,18 @@ diesel::table! {
created_at -> Timestamp,
}
}
diesel::joinable!(album_artists -> albums (album_id));
diesel::joinable!(album_artists -> artists (artist_id));
diesel::joinable!(song_artists -> artists (artist_id));
diesel::joinable!(song_artists -> songs (song_id));
diesel::joinable!(songs -> albums (album_id));
diesel::allow_tables_to_appear_in_same_query!(
album_artists,
albums,
artists,
song_artists,
songs,
users,
);

109
src/search.rs Normal file
View File

@ -0,0 +1,109 @@
use leptos::*;
use crate::models::{Artist, Album, Song};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::sql_types::*;
use diesel::*;
use diesel::pg::Pg;
use diesel::expression::AsExpression;
use crate::database::get_db_conn;
// Define pg_trgm operators
// Functions do not use indices for queries, so we need to use operators
diesel::infix_operator!(Similarity, " % ", backend: Pg);
diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg);
// Create functions to make use of the operators in queries
fn trgm_similar<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Similarity<T::Expression, U::Expression> {
Similarity::new(left.as_expression(), right.as_expression())
}
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Distance<T::Expression, U::Expression> {
Distance::new(left.as_expression(), right.as_expression())
}
}
}
/// Search for albums by title
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the album titles
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
#[server(endpoint = "search_albums")]
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, ServerFnError> {
use crate::schema::albums::dsl::*;
Ok(albums
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for artists by name
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the artist names
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
#[server(endpoint = "search_artists")]
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, ServerFnError> {
use crate::schema::artists::dsl::*;
Ok(artists
.filter(trgm_similar(name, query.clone()))
.order_by(trgm_distance(name, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for songs by title
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the song titles
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
#[server(endpoint = "search_songs")]
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, ServerFnError> {
use crate::schema::songs::dsl::*;
Ok(songs
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for songs, albums, and artists by title or name
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the
/// song titles, album titles, and artist names
/// `limit` - The maximum number of results to return for each type
///
/// # Returns
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
#[server(endpoint = "search")]
pub async fn search(query: String, limit: i64) -> Result<(Vec<Album>, Vec<Artist>, Vec<Song>), ServerFnError> {
let albums = search_albums(query.clone(), limit);
let artists = search_artists(query.clone(), limit);
let songs = search_songs(query.clone(), limit);
use futures::join;
let (albums, artists, songs) = join!(albums, artists, songs);
Ok((albums?, artists?, songs?))
}

104
src/users.rs Normal file
View 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)
}