From 6ae55d5cfc96e7fff5c589edeae71cc6a40b9224 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 18 May 2024 20:41:02 -0400 Subject: [PATCH 01/10] Add admin column to users table --- migrations/2024-05-18-231505_add_user_admin/down.sql | 1 + migrations/2024-05-18-231505_add_user_admin/up.sql | 1 + src/models.rs | 2 ++ src/pages/signup.rs | 1 + src/schema.rs | 1 + 5 files changed, 6 insertions(+) create mode 100644 migrations/2024-05-18-231505_add_user_admin/down.sql create mode 100644 migrations/2024-05-18-231505_add_user_admin/up.sql diff --git a/migrations/2024-05-18-231505_add_user_admin/down.sql b/migrations/2024-05-18-231505_add_user_admin/down.sql new file mode 100644 index 0000000..8c2de0f --- /dev/null +++ b/migrations/2024-05-18-231505_add_user_admin/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN admin; diff --git a/migrations/2024-05-18-231505_add_user_admin/up.sql b/migrations/2024-05-18-231505_add_user_admin/up.sql new file mode 100644 index 0000000..58a6e49 --- /dev/null +++ b/migrations/2024-05-18-231505_add_user_admin/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN admin BOOLEAN DEFAULT FALSE NOT NULL; diff --git a/src/models.rs b/src/models.rs index 442713e..349ed74 100644 --- a/src/models.rs +++ b/src/models.rs @@ -41,6 +41,8 @@ pub struct User { /// The time the user was created #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] pub created_at: Option, + /// Whether the user is an admin + pub admin: bool, } /// Model for an artist diff --git a/src/pages/signup.rs b/src/pages/signup.rs index 69f68c7..f02dfab 100644 --- a/src/pages/signup.rs +++ b/src/pages/signup.rs @@ -25,6 +25,7 @@ pub fn Signup() -> impl IntoView { email: email.get(), password: Some(password.get()), created_at: None, + admin: false, }; log!("new user: {:?}", new_user); diff --git a/src/schema.rs b/src/schema.rs index 97de324..e4964b9 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -50,6 +50,7 @@ diesel::table! { email -> Varchar, password -> Varchar, created_at -> Timestamp, + admin -> Bool, } } From c27ad19499c0180fcc16f850a398fc47794cce75 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sat, 18 May 2024 20:43:26 -0400 Subject: [PATCH 02/10] Prevent arbitrary admin user creation --- src/auth.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auth.rs b/src/auth.rs index 2eda7cf..20de5c3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -21,9 +21,10 @@ use crate::users::UserCredentials; pub async fn signup(new_user: User) -> Result<(), ServerFnError> { use crate::users::create_user; - // Ensure the user has no id + // Ensure the user has no id, and is not a self-proclaimed admin let new_user = User { id: None, + admin: false, ..new_user }; From fa26ee40ed1ecd3df5ee03cdca728a23489cf270 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sun, 19 May 2024 12:20:50 -0400 Subject: [PATCH 03/10] Add auth functions for checking admin status --- src/auth.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/auth.rs b/src/auth.rs index 20de5c3..8c33347 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -121,3 +121,37 @@ pub async fn require_auth() -> Result<(), ServerFnError> { } }) } + +/// Check if a user is an admin +/// Returns a Result with a boolean indicating if the user is logged in and an admin +#[server(endpoint = "check_admin")] +pub async fn check_admin() -> Result { + let auth_session = extract::>().await + .map_err(|e| ServerFnError::::ServerError(format!("Error getting auth session: {}", e)))?; + + Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false)) +} + +/// Require that a user is logged in and an admin +/// Returns a Result with the error message if the user is not logged in or is not an admin +/// Intended to be used at the start of a protected route, to ensure the user is logged in and an admin: +/// ```rust +/// use leptos::*; +/// use libretunes::auth::require_admin; +/// #[server(endpoint = "protected_admin_route")] +/// pub async fn protected_admin_route() -> Result<(), ServerFnError> { +/// require_admin().await?; +/// // Continue with protected route +/// Ok(()) +/// } +/// ``` +#[cfg(feature = "ssr")] +pub async fn require_admin() -> Result<(), ServerFnError> { + check_admin().await.and_then(|is_admin| { + if is_admin { + Ok(()) + } else { + Err(ServerFnError::::ServerError(format!("Unauthorized"))) + } + }) +} From 6b8c8d41e494b886ee25312c846e9da8f2b2b3c0 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Sun, 21 Jul 2024 13:15:55 -0400 Subject: [PATCH 04/10] Add music placeholder image --- assets/images/placeholders/MusicPlaceholder.svg | 1 + src/playbar.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 assets/images/placeholders/MusicPlaceholder.svg diff --git a/assets/images/placeholders/MusicPlaceholder.svg b/assets/images/placeholders/MusicPlaceholder.svg new file mode 100644 index 0000000..4a3917b --- /dev/null +++ b/assets/images/placeholders/MusicPlaceholder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/playbar.rs b/src/playbar.rs index 7bfc1b0..38754c7 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -261,8 +261,8 @@ fn MediaInfo(status: RwSignal) -> impl IntoView { let image = Signal::derive(move || { status.with(|status| { - // TODO Use some default / unknown image? - status.queue.front().map_or("".into(), |song| song.image_path.clone()) + status.queue.front().map_or("/assets/images/placeholders/MusicPlaceholder.svg".into(), + |song| song.image_path.clone()) }) }); From 6202b287f081414034ed70402f2a4eb4f206b902 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 23 Jul 2024 20:51:12 -0400 Subject: [PATCH 05/10] Fix music placeholder asset path --- src/playbar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/playbar.rs b/src/playbar.rs index 38754c7..c90df53 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -261,7 +261,7 @@ fn MediaInfo(status: RwSignal) -> impl IntoView { let image = Signal::derive(move || { status.with(|status| { - status.queue.front().map_or("/assets/images/placeholders/MusicPlaceholder.svg".into(), + status.queue.front().map_or("/images/placeholders/MusicPlaceholder.svg".into(), |song| song.image_path.clone()) }) }); From c72d4aee185f363443d0dcd02bba057123d27932 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 23 Jul 2024 22:57:24 -0400 Subject: [PATCH 06/10] Add Arist::display_list function --- src/models.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/models.rs b/src/models.rs index 349ed74..caa52ed 100644 --- a/src/models.rs +++ b/src/models.rs @@ -164,6 +164,26 @@ impl Artist { Ok(my_songs) } + + /// Display a list of artists as a string. + /// + /// For one artist, displays [artist1]. For two artists, displays [artist1] & [artist2]. + /// For three or more artists, displays [artist1], [artist2], & [artist3]. + pub fn display_list(artists: &Vec) -> String { + let mut artist_list = String::new(); + + for (i, artist) in artists.iter().enumerate() { + if i == 0 { + artist_list.push_str(&artist.name); + } else if i == artists.len() - 1 { + artist_list.push_str(&format!(" & {}", artist.name)); + } else { + artist_list.push_str(&format!(", {}", artist.name)); + } + } + + artist_list + } } /// Model for an album From ffad799f72d3453cab04be7ef1e109ddfad0f5d8 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 23 Jul 2024 23:30:15 -0400 Subject: [PATCH 07/10] Add function to get song album object --- src/models.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/models.rs b/src/models.rs index caa52ed..d3e4104 100644 --- a/src/models.rs +++ b/src/models.rs @@ -313,4 +313,30 @@ impl Song { Ok(my_artists) } + + /// Get the album for this song from the database + /// + /// # Arguments + /// + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result, Box>` - A result indicating success with an album, or None if + /// the song does not have an album, or an error + /// + #[cfg(feature = "ssr")] + pub fn get_album(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::albums::dsl::*; + + if let Some(album_id) = self.album_id { + let my_album = albums + .filter(id.eq(album_id)) + .first::(conn)?; + + Ok(Some(my_album)) + } else { + Ok(None) + } + } } From f8bbe319bd602ba5553947ca1dda0c54c760d72e Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 23 Jul 2024 23:31:48 -0400 Subject: [PATCH 08/10] Update SongData for frontend use --- src/playbar.rs | 12 +++++++----- src/queue.rs | 3 ++- src/songdata.rs | 24 +++++++++++++++++++----- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/playbar.rs b/src/playbar.rs index c90df53..7ff2458 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -1,3 +1,4 @@ +use crate::models::Artist; use crate::playstatus::PlayStatus; use leptos::ev::MouseEvent; use leptos::html::{Audio, Div}; @@ -243,19 +244,20 @@ fn PlayDuration(elapsed_secs: MaybeSignal, total_secs: MaybeSignal) -> fn MediaInfo(status: RwSignal) -> impl IntoView { let name = Signal::derive(move || { status.with(|status| { - status.queue.front().map_or("No media playing".into(), |song| song.name.clone()) + status.queue.front().map_or("No media playing".into(), |song| song.title.clone()) }) }); let artist = Signal::derive(move || { status.with(|status| { - status.queue.front().map_or("".into(), |song| song.artist.clone()) + status.queue.front().map_or("".into(), |song| format!("{}", Artist::display_list(&song.artists))) }) }); let album = Signal::derive(move || { status.with(|status| { - status.queue.front().map_or("".into(), |song| song.album.clone()) + status.queue.front().map_or("".into(), |song| + song.album.as_ref().map_or("".into(), |album| album.title.clone())) }) }); @@ -400,7 +402,7 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView { status.with_untracked(|status| { // Start playing the first song in the queue, if available if let Some(song) = status.queue.front() { - log!("Starting playing with song: {}", song.name); + log!("Starting playing with song: {}", song.title); // Don't use the set_play_src / set_playing helper function // here because we already have access to the audio element @@ -453,7 +455,7 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView { let prev_song = status.queue.pop_front(); if let Some(prev_song) = prev_song { - log!("Adding song to history: {}", prev_song.name); + log!("Adding song to history: {}", prev_song.title); status.history.push_back(prev_song); } else { log!("Queue empty, no previous song to add to history"); diff --git a/src/queue.rs b/src/queue.rs index 39f0ee0..8a819b9 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,3 +1,4 @@ +use crate::models::Artist; use crate::playstatus::PlayStatus; use crate::song::Song; use leptos::ev::MouseEvent; @@ -98,7 +99,7 @@ pub fn Queue(status: RwSignal) -> impl IntoView { on:dragenter=move |e: DragEvent| on_drag_enter(e, index) on:dragover=on_drag_over > - + , /// Song album - pub album: String, + pub album: Option, + /// The track number of the song on the album + pub track: Option, + /// The duration of the song in seconds + pub duration: i32, + /// The song's release date + pub release_date: Option, /// Path to song file, relative to the root of the web server. /// For example, `"/assets/audio/Song.mp3"` pub song_path: String, From 76631126de5f554d78aa85cf1090ffb28f32c053 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 23 Jul 2024 23:32:26 -0400 Subject: [PATCH 09/10] Add Song/SongData conversions --- src/songdata.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/songdata.rs b/src/songdata.rs index 52ed37a..6bd6f22 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -28,3 +28,70 @@ pub struct SongData { /// For example, `"/assets/images/Song.jpg"` pub image_path: String, } + +#[cfg(feature = "ssr")] +impl TryInto for Song { + type Error = Box; + + /// Convert a Song object into a SongData object + /// + /// This conversion is expensive, as it requires database queries to get the artist and album objects. + /// The SongData/Song conversions are also not truly reversible, + /// due to the way the image_path, album, and artist data is handled. + fn try_into(self) -> Result { + let mut db_con = database::get_db_conn(); + + let album = self.get_album(&mut db_con)?; + + // Use the song's image path if it exists, otherwise use the album's image path, or fallback to the placeholder + let image_path = self.image_path.clone().unwrap_or_else(|| { + album + .as_ref() + .and_then(|album| album.image_path.clone()) + .unwrap_or_else(|| "/assets/images/placeholder.jpg".to_string()) + }); + + Ok(SongData { + id: self.id.ok_or("Song id must be present (Some) to convert to SongData")?, + title: self.title.clone(), + artists: self.get_artists(&mut db_con)?, + album: album, + track: self.track, + duration: self.duration, + release_date: self.release_date, + // TODO https://gitlab.mregirouard.com/libretunes/libretunes/-/issues/35 + song_path: self.storage_path, + image_path: image_path, + }) + } +} + +impl TryInto for SongData { + type Error = Box; + + /// Convert a SongData object into a Song object + /// + /// The SongData/Song conversions are also not truly reversible, + /// due to the way the image_path, album, and and artist data is handled. + fn try_into(self) -> Result { + Ok(Song { + id: Some(self.id), + title: self.title, + album_id: self.album.map(|album| + album.id.ok_or("Album id must be present (Some) to convert to Song")).transpose()?, + track: self.track, + duration: self.duration, + release_date: self.release_date, + // TODO https://gitlab.mregirouard.com/libretunes/libretunes/-/issues/35 + storage_path: self.song_path, + + // Note that if the source of the image_path was the album, the image_path + // will be set to the album's image_path instead of None + image_path: if self.image_path == "/assets/images/placeholder.jpg" { + None + } else { + Some(self.image_path) + }, + }) + } +} From 21bb2d127fe116a09ab1274f46153c06c17fbe90 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Tue, 23 Jul 2024 23:37:48 -0400 Subject: [PATCH 10/10] Fix unused crate::database import --- src/songdata.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/songdata.rs b/src/songdata.rs index 6bd6f22..23ce415 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -1,4 +1,3 @@ -use crate::database; use crate::models::{Album, Artist, Song}; use time::Date; @@ -39,6 +38,7 @@ impl TryInto for Song { /// The SongData/Song conversions are also not truly reversible, /// due to the way the image_path, album, and artist data is handled. fn try_into(self) -> Result { + use crate::database; let mut db_con = database::get_db_conn(); let album = self.get_album(&mut db_con)?;