228-create-unified-config-system #229

Merged
eta357 merged 33 commits from 228-create-unified-config-system into main 2025-06-28 01:13:23 +00:00
33 changed files with 1757 additions and 1157 deletions

542
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -28,34 +28,33 @@ required-features = ["health_check"]
console_error_panic_hook = { version = "0.1", optional = true }
cfg-if = "1"
http = { version = "1.0", default-features = false }
leptos = { version = "0.7.8", default-features = false, features = ["nightly"] }
leptos_meta = { version = "0.7.8" }
leptos_axum = { version = "0.7.8", optional = true }
leptos_router = { version = "0.7.8", features = ["nightly"] }
leptos = { version = "0.8.2", default-features = false, features = ["nightly"] }
leptos_meta = { version = "0.8.2" }
leptos_axum = { version = "0.8.2", optional = true }
leptos_router = { version = "0.8.2", features = ["nightly"] }
wasm-bindgen = { version = "=0.2.100", default-features = false, optional = true }
leptos_icons = { version = "0.4.0" }
leptos_icons = { version = "0.6.1" }
icondata = { version = "0.5.0" }
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], default-features = false, optional = true }
lazy_static = { version = "1.4.0", optional = true }
serde = { version = "1.0.195", features = ["derive"], default-features = false }
openssl = { version = "0.10.63", optional = true }
diesel_migrations = { version = "2.1.0", optional = true }
pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true }
tokio = { version = "1", optional = true, features = ["rt-multi-thread"] }
axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true }
axum = { version = "0.8.4", features = ["tokio", "http1"], default-features = false, optional = true }
tower = { version = "0.5.1", optional = true, features = ["util"] }
tower-http = { version = "0.6.1", optional = true, features = ["fs"] }
thiserror = "1.0.57"
tower-sessions-redis-store = { version = "0.15", optional = true }
tower-sessions-redis-store = { version = "0.16", optional = true }
async-trait = { version = "0.1.79", optional = true }
axum-login = { version = "0.16.0", optional = true }
server_fn = { version = "0.7.7", features = ["multipart"] }
axum-login = { version = "0.17.0", optional = true }
server_fn = { version = "0.8.2", features = ["multipart"] }
symphonia = { version = "0.5.4", default-features = false, features = ["mp3"], optional = true }
multer = { version = "3.1.0", optional = true }
log = { version = "0.4.21", optional = true }
flexi_logger = { version = "0.28.0", optional = true, default-features = false }
web-sys = "0.3.69"
leptos-use = "0.15.0"
leptos-use = "0.16.0-beta2"
image-convert = { version = "0.18.0", optional = true, default-features = false }
chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
dotenvy = { version = "0.15.7", optional = true }
@ -63,6 +62,8 @@ reqwest = { version = "0.12.9", default-features = false, optional = true }
futures = { version = "0.3.25", default-features = false, optional = true }
once_cell = { version = "1.20", default-features = false, optional = true }
libretunes_macro = { git = "https://git.libretunes.xyz/LibreTunes/LibreTunes-Macro.git", branch = "main" }
clap = { version = "4.5.39", features = ["derive", "env"] }
tokio-tungstenite = { version = "0.26.2", optional = true }
[features]
hydrate = [
@ -78,7 +79,6 @@ ssr = [
"leptos_router/ssr",
"dotenvy",
"diesel",
"lazy_static",
"openssl",
"diesel_migrations",
"pbkdf2",
@ -111,6 +111,7 @@ health_check = [
"tokio",
"tokio/rt",
"tokio/macros",
"tokio-tungstenite",
]
# Defines a size-optimized profile for the WASM bundle in release mode

View File

@ -1,4 +1,5 @@
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
@ -7,26 +8,23 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use leptos::server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use crate::models::backend;
use crate::util::backend_state::BackendState;
}
}
#[server(endpoint = "album/get", client = Client)]
pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError> {
pub async fn get_album(id: i32) -> BackendResult<Option<frontend::Album>> {
use crate::models::backend::Album;
use crate::schema::*;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let album = albums::table
.find(id)
.first::<Album>(db_con)
.first::<Album>(&mut db_conn)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting album: {e}"))
})?;
.context("Error loading album from database")?;
let Some(album) = album else { return Ok(None) };
@ -34,7 +32,8 @@ pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError
.filter(album_artists::album_id.eq(id))
.inner_join(artists::table.on(album_artists::artist_id.eq(artists::id)))
.select(artists::all_columns)
.load(db_con)?;
.load(&mut db_conn)
.context("Error loading album artists from database")?;
let img = album
.image_path
@ -52,14 +51,16 @@ pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError
}
#[server(endpoint = "album/get_songs", client = Client)]
pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
pub async fn get_songs(id: i32) -> BackendResult<Vec<frontend::Song>> {
use crate::api::auth::get_logged_in_user;
use crate::schema::*;
use std::collections::HashMap;
let user = get_logged_in_user().await?;
let user = get_logged_in_user()
.await
.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let song_list = if let Some(user) = user {
let song_list: Vec<(
@ -94,7 +95,8 @@ pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
song_dislikes::all_columns.nullable(),
))
.order(songs::track.asc())
.load(db_con)?;
.load(&mut db_conn)
.context("Error loading album songs from database")?;
song_list
} else {
let song_list: Vec<(
@ -115,7 +117,8 @@ pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
artists::all_columns.nullable(),
))
.order(songs::track.asc())
.load(db_con)?;
.load(&mut db_conn)
.context("Error loading album songs from database")?;
let song_list: Vec<(
backend::Album,

View File

@ -1,11 +1,11 @@
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use cfg_if::cfg_if;
use leptos::prelude::*;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::util::database::get_db_conn;
use crate::util::backend_state::BackendState;
use diesel::prelude::*;
use chrono::NaiveDate;
}
@ -27,18 +27,17 @@ pub async fn add_album(
album_title: String,
release_date: Option<String>,
image_path: Option<String>,
) -> Result<(), ServerFnError> {
) -> BackendResult<()> {
use crate::models::backend::NewAlbum;
use crate::schema::albums::{self};
use leptos::server_fn::error::NoCustomError;
let parsed_release_date = match release_date {
Some(date) => match NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d") {
Ok(parsed_date) => Some(parsed_date),
Err(_e) => {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Invalid release date".to_string(),
))
Err(e) => {
return Err(
InputError::InvalidInput(format!("Error parsing release date: {e}")).into(),
);
}
},
None => None,
@ -52,13 +51,12 @@ pub async fn add_album(
image_path: image_path_arg,
};
let db = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(albums::table)
.values(&new_album)
.execute(db)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding album: {e}"))
})?;
.execute(&mut db_conn)
.context("Error inserting new album into database")?;
Ok(())
}

View File

@ -4,15 +4,16 @@ use cfg_if::cfg_if;
use crate::models::backend::Artist;
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::util::database::get_db_conn;
use diesel::prelude::*;
use std::collections::HashMap;
use crate::models::backend::Album;
use crate::models::backend::NewArtist;
use crate::util::backend_state::BackendState;
}
}
@ -26,36 +27,32 @@ cfg_if! {
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
#[server(endpoint = "artists/add-artist", client = Client)]
pub async fn add_artist(artist_name: String) -> Result<(), ServerFnError> {
pub async fn add_artist(artist_name: String) -> BackendResult<()> {
use crate::schema::artists::dsl::*;
use leptos::server_fn::error::NoCustomError;
let new_artist = NewArtist { name: artist_name };
let db = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(artists)
.values(&new_artist)
.execute(db)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding artist: {e}"))
})?;
.execute(&mut db_conn)
.context("Error inserting new artist into database")?;
Ok(())
}
#[server(endpoint = "artists/get", client = Client)]
pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, ServerFnError> {
pub async fn get_artist_by_id(artist_id: i32) -> BackendResult<Option<Artist>> {
use crate::schema::artists::dsl::*;
use leptos::server_fn::error::NoCustomError;
let db = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let artist = artists
.filter(id.eq(artist_id))
.first::<Artist>(db)
.first::<Artist>(&mut db_conn)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting artist: {e}"))
})?;
.context("Error loading artist from database")?;
Ok(artist)
}
@ -64,20 +61,15 @@ pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, ServerFn
pub async fn top_songs_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> Result<Vec<(frontend::Song, i64)>, ServerFnError> {
) -> BackendResult<Vec<(frontend::Song, i64)>> {
use crate::api::auth::get_user;
use crate::models::backend::Song;
use crate::schema::*;
use leptos::server_fn::error::NoCustomError;
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::ServerError::<NoCustomError>(format!("Error getting user: {e}"))
})?
.id;
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let db = &mut get_db_conn();
let song_play_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
song_history::table
.group_by(song_history::song_id)
@ -87,7 +79,7 @@ pub async fn top_songs_by_artist(
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.limit(limit)
.load(db)?
.load(&mut db_conn)?
} else {
song_history::table
.group_by(song_history::song_id)
@ -96,7 +88,7 @@ pub async fn top_songs_by_artist(
.filter(song_artists::artist_id.eq(artist_id))
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.load(db)?
.load(&mut db_conn)?
};
let song_play_counts: HashMap<i32, i64> = song_play_counts.into_iter().collect();
@ -133,7 +125,7 @@ pub async fn top_songs_by_artist(
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(db)?;
.load(&mut db_conn)?;
let mut top_songs_map: HashMap<i32, (frontend::Song, i64)> =
HashMap::with_capacity(top_songs.len());
@ -175,8 +167,8 @@ pub async fn top_songs_by_artist(
let plays = song_play_counts
.get(&song.id)
.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in history counts".to_string(),
.ok_or(BackendError::InternalError(
"Song id not found in history counts",
))?;
top_songs_map.insert(song.id, (songdata, *plays));
@ -192,10 +184,10 @@ pub async fn top_songs_by_artist(
pub async fn albums_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> Result<Vec<frontend::Album>, ServerFnError> {
) -> BackendResult<Vec<frontend::Album>> {
use crate::schema::*;
let db = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let album_ids = albums::table
.left_join(album_artists::table)
@ -219,7 +211,8 @@ pub async fn albums_by_artist(
.on(albums::id.eq(album_artists::album_id)),
)
.select((albums::all_columns, artists::all_columns))
.load(db)?;
.load(&mut db_conn)
.context("Error loading album artists from database")?;
for (album, artist) in album_artists {
if let Some(stored_album) = albums_map.get_mut(&album.id) {

View File

@ -4,27 +4,26 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use leptos_axum::extract;
use axum_login::AuthSession;
use crate::util::auth_backend::AuthBackend;
use crate::util::backend_state::BackendState;
}
}
use crate::api::users::UserCredentials;
use crate::models::backend::{NewUser, User};
use crate::util::error::*;
use crate::util::serverfn_client::Client;
/// 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", client = Client)]
pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
pub async fn signup(new_user: NewUser) -> BackendResult<()> {
// Check LIBRETUNES_DISABLE_SIGNUP env var
if std::env::var("LIBRETUNES_DISABLE_SIGNUP").is_ok_and(|v| v == "true") {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Signup is disabled".to_string(),
));
return Err(AuthError::SignupDisabled.into());
}
use crate::api::users::create_user;
@ -35,13 +34,13 @@ pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
..new_user
};
create_user(&new_user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
})?;
create_user(&new_user)
.await
.context("Error creating user")?;
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let credentials = UserCredentials {
username_or_email: new_user.username.clone(),
@ -49,15 +48,12 @@ pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
};
match auth_session.authenticate(credentials).await {
Ok(Some(user)) => auth_session.login(&user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}"))
}),
Ok(None) => Err(ServerFnError::<NoCustomError>::ServerError(
"Error authenticating user: User not found".to_string(),
)),
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error authenticating user: {e}"
))),
Ok(Some(user)) => auth_session
.login(&user)
.await
.map_err(|e| AuthError::AuthError(format!("Error logging in user: {e}")).into()),
Ok(None) => Err(AuthError::InvalidCredentials.into()),
Err(e) => Err(AuthError::AuthError(format!("Error authenticating user: {e}")).into()),
}
}
@ -65,21 +61,24 @@ pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
/// 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", client = Client)]
pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
pub async fn login(credentials: UserCredentials) -> BackendResult<Option<User>> {
use crate::api::users::validate_user;
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let user = validate_user(credentials).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {e}"))
})?;
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let user = validate_user(credentials, &mut db_conn)
.await
.context("Error validating user credentials")?;
if let Some(mut user) = user {
auth_session.login(&user).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}"))
})?;
auth_session
.login(&user)
.await
.map_err(|e| AuthError::AuthError(format!("Error logging in user: {e}")))?;
user.password = None;
Ok(Some(user))
@ -91,14 +90,15 @@ pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerF
/// Log a user out
/// Returns a Result with the error message if the user could not be logged out
#[server(endpoint = "logout", client = Client)]
pub async fn logout() -> Result<(), ServerFnError> {
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn logout() -> BackendResult<()> {
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
auth_session.logout().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
auth_session
.logout()
.await
.map_err(|e| AuthError::AuthError(format!("Error logging out user: {e}")))?;
leptos_axum::redirect("/login");
Ok(())
@ -107,10 +107,10 @@ pub async fn logout() -> Result<(), ServerFnError> {
/// Check if a user is logged in
/// Returns a Result with a boolean indicating if the user is logged in
#[server(endpoint = "check_auth", client = Client)]
pub async fn check_auth() -> Result<bool, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn check_auth() -> BackendResult<bool> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
Ok(auth_session.user.is_some())
}
@ -121,24 +121,26 @@ pub async fn check_auth() -> Result<bool, ServerFnError> {
/// ```rust
/// use leptos::prelude::*;
/// use libretunes::api::auth::require_auth;
/// use libretunes::util::error::*;
/// #[server(endpoint = "protected_route")]
/// pub async fn protected_route() -> Result<(), ServerFnError> {
/// pub async fn protected_route() -> BackendResult<()> {
/// require_auth().await?;
/// // Continue with protected route
/// Ok(())
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_auth() -> Result<(), ServerFnError> {
check_auth().await.and_then(|logged_in| {
if logged_in {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Unauthorized".to_string(),
))
}
})
pub async fn require_auth() -> BackendResult<()> {
check_auth()
.await
.context("Error checking authentication")
.and_then(|logged_in| {
if logged_in {
Ok(())
} else {
Err(AuthError::Unauthorized.into())
}
})
}
/// Get the current logged-in user
@ -148,8 +150,9 @@ pub async fn require_auth() -> Result<(), ServerFnError> {
/// ```rust
/// use leptos::prelude::*;
/// use libretunes::api::auth::get_user;
/// use libretunes::util::error::*;
/// #[server(endpoint = "user_route")]
/// pub async fn user_route() -> Result<(), ServerFnError> {
/// pub async fn user_route() -> BackendResult<()> {
/// let user = get_user().await?;
/// println!("Logged in as: {}", user.username);
/// // Do something with the user
@ -157,23 +160,19 @@ pub async fn require_auth() -> Result<(), ServerFnError> {
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn get_user() -> Result<User, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn get_user() -> BackendResult<User> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
auth_session
.user
.ok_or(ServerFnError::<NoCustomError>::ServerError(
"User not logged in".to_string(),
))
auth_session.user.ok_or(AuthError::Unauthorized.into())
}
#[server(endpoint = "get_logged_in_user", client = Client)]
pub async fn get_logged_in_user() -> Result<Option<User>, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn get_logged_in_user() -> BackendResult<Option<User>> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let user = auth_session.user.map(|mut user| {
user.password = None;
@ -186,10 +185,10 @@ pub async fn get_logged_in_user() -> Result<Option<User>, 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", client = Client)]
pub async fn check_admin() -> Result<bool, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
})?;
pub async fn check_admin() -> BackendResult<bool> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
}
@ -200,22 +199,21 @@ pub async fn check_admin() -> Result<bool, ServerFnError> {
/// ```rust
/// use leptos::prelude::*;
/// use libretunes::api::auth::require_admin;
/// use libretunes::util::error::*;
/// #[server(endpoint = "protected_admin_route")]
/// pub async fn protected_admin_route() -> Result<(), ServerFnError> {
/// pub async fn protected_admin_route() -> BackendResult<()> {
/// require_admin().await?;
/// // Continue with protected route
/// Ok(())
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_admin() -> Result<(), ServerFnError> {
pub async fn require_admin() -> BackendResult<()> {
check_admin().await.and_then(|is_admin| {
if is_admin {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Unauthorized".to_string(),
))
Err(AuthError::AdminRequired.into())
}
})
}

View File

@ -1,23 +1,26 @@
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use crate::util::serverfn_client::Client;
#[server(endpoint = "health", client = Client)]
pub async fn health() -> Result<String, ServerFnError> {
use crate::util::database::get_db_conn;
use crate::util::redis::get_redis_conn;
pub async fn health() -> BackendResult<String> {
use crate::util::backend_state::BackendState;
use diesel::connection::SimpleConnection;
use server_fn::error::NoCustomError;
use tower_sessions_redis_store::fred::interfaces::ClientLike;
get_db_conn()
.batch_execute("SELECT 1")
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Database error: {e}")))?;
let backend_state = BackendState::get().await?;
get_redis_conn()
backend_state
.get_db_conn()?
.batch_execute("SELECT 1")
.context("Failed to execute database health check query")?;
backend_state
.get_redis_conn()
.ping::<()>(None)
.await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Redis error: {e}")))?;
.map_err(|e| BackendError::InternalError(format!("{e}")))
.context("Failed to execute Redis health check ping")?;
Ok("ok".to_string())
}

View File

@ -1,5 +1,6 @@
use crate::models::backend::HistoryEntry;
use crate::models::backend::Song;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use chrono::NaiveDateTime;
use leptos::prelude::*;
@ -8,43 +9,48 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use crate::util::backend_state::BackendState;
use crate::api::auth::get_user;
}
}
/// Get the history of the current user.
#[server(endpoint = "history/get", client = Client)]
pub async fn get_history(limit: Option<i64>) -> Result<Vec<HistoryEntry>, ServerFnError> {
let user = get_user().await?;
let db_con = &mut get_db_conn();
let history = user.get_history(limit, db_con).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting history: {e}"))
})?;
pub async fn get_history(limit: Option<i64>) -> BackendResult<Vec<HistoryEntry>> {
let user = get_user().await.context("Error getting logged-in user")?;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let history = user
.get_history(limit, &mut db_conn)
.context("Error getting history")?;
Ok(history)
}
/// Get the listen dates and songs of the current user.
#[server(endpoint = "history/get_songs", client = Client)]
pub async fn get_history_songs(
limit: Option<i64>,
) -> Result<Vec<(NaiveDateTime, Song)>, ServerFnError> {
let user = get_user().await?;
let db_con = &mut get_db_conn();
let songs = user.get_history_songs(limit, db_con).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting history songs: {e}"))
})?;
pub async fn get_history_songs(limit: Option<i64>) -> BackendResult<Vec<(NaiveDateTime, Song)>> {
let user = get_user().await.context("Error getting logged-in user")?;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let songs = user
.get_history_songs(limit, &mut db_conn)
.context("Error getting history songs")?;
Ok(songs)
}
/// Add a song to the history of the current user.
#[server(endpoint = "history/add", client = Client)]
pub async fn add_history(song_id: i32) -> Result<(), ServerFnError> {
let user = get_user().await?;
let db_con = &mut get_db_conn();
user.add_history(song_id, db_con).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error adding history: {e}"))
})?;
pub async fn add_history(song_id: i32) -> BackendResult<()> {
let user = get_user().await.context("Error getting logged-in user")?;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
user.add_history(song_id, &mut db_conn)
.context("Error adding song to history")?;
Ok(())
}

View File

@ -1,4 +1,5 @@
use crate::models::{backend, frontend};
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use cfg_if::cfg_if;
use leptos::prelude::*;
@ -8,18 +9,17 @@ cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::api::auth::get_user;
use diesel::prelude::*;
use crate::util::database::get_db_conn;
use crate::util::extract_field::extract_field;
use crate::util::backend_state::BackendState;
use std::collections::HashMap;
use server_fn::error::NoCustomError;
use log::*;
use crate::schema::*;
}
}
#[cfg(feature = "ssr")]
async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> Result<bool, ServerFnError> {
let mut db_conn = get_db_conn();
async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> BackendResult<bool> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let exists = playlists::table
.find(playlist_id)
@ -27,82 +27,58 @@ async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> Result<bool, Serv
.select(playlists::id)
.first::<i32>(&mut db_conn)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?
.context("Error loading playlist from database")?
.is_some();
Ok(exists)
}
#[server(endpoint = "playlists/get_all", client = Client)]
pub async fn get_playlists() -> Result<Vec<backend::Playlist>, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_playlists() -> BackendResult<Vec<backend::Playlist>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let playlists = playlists::table
.filter(playlists::owner_id.eq(user_id))
.select(playlists::all_columns)
.load::<backend::Playlist>(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting playlists: {e}"))
})?;
.context("Error loading playlists from database")?;
Ok(playlists)
}
#[server(endpoint = "playlists/get", client = Client)]
pub async fn get_playlist(playlist_id: i32) -> Result<backend::Playlist, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_playlist(playlist_id: i32) -> BackendResult<backend::Playlist> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let playlist: backend::Playlist = playlists::table
.find(playlist_id)
.filter(playlists::owner_id.eq(user_id))
.select(playlists::all_columns)
.first(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting playlist: {e}"))
})?;
.context("Error loading playlist from database")?;
Ok(playlist)
}
#[server(endpoint = "playlists/get_songs", client = Client)]
pub async fn get_playlist_songs(playlist_id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_playlist_songs(playlist_id: i32) -> BackendResult<Vec<frontend::Song>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user_id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let songs: Vec<(
backend::Song,
@ -139,7 +115,8 @@ pub async fn get_playlist_songs(playlist_id: i32) -> Result<Vec<frontend::Song>,
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading playlist songs from database")?;
let mut playlist_songs: HashMap<i32, frontend::Song> = HashMap::new();
@ -185,27 +162,22 @@ pub async fn get_playlist_songs(playlist_id: i32) -> Result<Vec<frontend::Song>,
}
#[server(endpoint = "playlists/add_song", client = Client)]
pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> Result<(), ServerFnError> {
pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(crate::schema::playlist_songs::table)
.values((
@ -213,23 +185,17 @@ pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> Result<(),
playlist_songs::song_id.eq(song_id),
))
.execute(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error adding song to playlist: {e}"
))
})?;
.context("Error adding song to playlist in database")?;
Ok(())
}
#[server(input = MultipartFormData, endpoint = "playlists/create")]
pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn create_playlist(data: MultipartData) -> BackendResult<()> {
use crate::models::backend::NewPlaylist;
use image_convert::{to_webp, ImageResource, WEBPConfig};
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
@ -246,20 +212,17 @@ pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
}
"picture" => {
// Read the image
let bytes = field.bytes().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting field bytes: {e}"
))
})?;
let bytes = field
.bytes()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")))
.context("Error reading bytes of the picture field")?;
// Check if the image is empty
if !bytes.is_empty() {
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error creating image resource: {e}"
))
})?;
let image_source = ImageResource::from_reader(reader)
.context("Error creating image resource from reader")?;
picture_data = Some(image_source);
}
@ -272,7 +235,7 @@ pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
// Unwrap mandatory fields
let name = playlist_name.ok_or_else(|| {
ServerFnError::<NoCustomError>::ServerError("Missing playlist name".to_string())
InputError::MissingField("name".to_string()).context("Missing playlist name")
})?;
let new_playlist = NewPlaylist {
@ -280,7 +243,7 @@ pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
owner_id: user.id,
};
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Create a transaction to create the playlist
// If saving the image fails, the playlist will not be created
@ -288,33 +251,27 @@ pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
let playlist = diesel::insert_into(playlists::table)
.values(&new_playlist)
.get_result::<backend::Playlist>(db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating playlist: {e}"))
})?;
.context("Error creating playlist in database")?;
// If a picture was provided, save it to the database
if let Some(image_source) = picture_data {
let image_path = format!("assets/images/playlist/{}.webp", playlist.id);
let mut image_target = ImageResource::from_path(&image_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error converting image to webp: {e}"
))
})?;
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("{e}")))
.context("Error converting image to webp")?;
}
Ok::<(), ServerFnError>(())
Ok::<(), BackendError>(())
})
}
#[server(input = MultipartFormData, endpoint = "playlists/edit_image")]
pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn edit_playlist_image(data: MultipartData) -> BackendResult<()> {
use image_convert::{to_webp, ImageResource, WEBPConfig};
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
@ -331,20 +288,17 @@ pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnErro
}
"picture" => {
// Read the image
let bytes = field.bytes().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting field bytes: {e}"
))
})?;
let bytes = field
.bytes()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")))
.context("Error reading bytes of the picture field")?;
// Check if the image is empty
if !bytes.is_empty() {
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error creating image resource: {e}"
))
})?;
let image_source = ImageResource::from_reader(reader)
.context("Error creating image resource from reader")?;
picture_data = Some(image_source);
}
@ -356,25 +310,22 @@ pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnErro
}
// Unwrap mandatory fields
let playlist_id = playlist_id.ok_or_else(|| {
ServerFnError::<NoCustomError>::ServerError("Missing playlist name".to_string())
})?;
let playlist_id = playlist_id
.ok_or_else(|| InputError::MissingField("id".to_string()).context("Missing playlist ID"))?;
let playlist_id: i32 = playlist_id.parse().map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Invalid playlist ID: {e}"))
})?;
let playlist_id: i32 = playlist_id
.parse()
.map_err(|e| InputError::InvalidInput(format!("Invalid playlist ID: {e}")))
.context("Error parsing playlist ID from string")?;
// Make sure the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
// If a picture was provided, save it to the database
@ -382,75 +333,60 @@ pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnErro
let image_path = format!("assets/images/playlist/{playlist_id}.webp");
let mut image_target = ImageResource::from_path(&image_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error converting image to webp: {e}"
))
})?;
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("{e}")))
.context("Error converting image to webp")?;
}
Ok(())
}
#[server(endpoint = "playlists/delete", client = Client)]
pub async fn delete_playlist(playlist_id: i32) -> Result<(), ServerFnError> {
pub async fn delete_playlist(playlist_id: i32) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::delete(playlists::table.find(playlist_id))
.execute(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error deleting playlist: {e}"))
})?;
.context("Error deleting playlist from database")?;
Ok(())
}
#[server(endpoint = "playlists/rename", client = Client)]
pub async fn rename_playlist(id: i32, new_name: String) -> Result<(), ServerFnError> {
pub async fn rename_playlist(id: i32, new_name: String) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, id).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
})?;
let valid_playlist = user_owns_playlist(user.id, id)
.await
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(ServerFnError::<NoCustomError>::ServerError(
"Playlist does not exist or does not belong to the user".to_string(),
));
return Err(AccessError::NotFoundOrUnauthorized.into());
}
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::update(playlists::table.find(id))
.set(playlists::name.eq(new_name))
.execute(&mut db_conn)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error renaming playlist: {e}"))
})?;
.context("Error renaming playlist in database")?;
Ok(())
}

View File

@ -1,8 +1,8 @@
use crate::util::error::*;
use cfg_if::cfg_if;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
use cfg_if::cfg_if;
use crate::models::frontend;
use crate::util::serverfn_client::Client;
use chrono::NaiveDateTime;
@ -10,14 +10,13 @@ use chrono::NaiveDateTime;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::api::auth::get_user;
use server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use diesel::prelude::*;
use diesel::dsl::count;
use crate::models::backend::{Album, Artist, Song, HistoryEntry};
use crate::models::backend;
use crate::schema::*;
use crate::util::backend_state::BackendState;
use std::collections::HashMap;
}
@ -25,46 +24,48 @@ cfg_if! {
/// Handle a user uploading a profile picture. Converts the image to webp and saves it to the server.
#[server(input = MultipartFormData, endpoint = "/profile/upload_picture")]
pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn upload_picture(data: MultipartData) -> BackendResult<()> {
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
let field = data
.next_field()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting field: {e}"))
})?
.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("No field found".to_string()))?;
.map_err(|e| InputError::InvalidInput(format!("Error reading multipart data: {e}")))
.context("Error getting next field from multipart data")?
.ok_or_else(|| {
InputError::InvalidInput("Expected a field in the multipart data".to_string())
})?;
if field.name() != Some("picture") {
return Err(ServerFnError::ServerError(
"Field name is not 'picture'".to_string(),
));
return Err(InputError::InvalidInput(format!(
"Expected field 'picture', got '{:?}'",
field.name()
))
.into());
}
// Get user id from session
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
let user = get_user().await.context("Error getting logged-in user")?;
// Read the image, and convert it to webp
use image_convert::{to_webp, ImageResource, WEBPConfig};
let bytes = field.bytes().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting field bytes: {e}"))
})?;
let bytes = field
.bytes()
.await
.map_err(|e| InputError::InvalidInput(format!("Error reading bytes from field: {e}")))
.context("Error reading bytes of the picture field")?;
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating image resource: {e}"))
})?;
let image_source = ImageResource::from_reader(reader)
.map_err(|e| InputError::InvalidInput(format!("Error creating image resource: {e}")))
.context("Error creating image resource from reader")?;
let profile_picture_path = format!("assets/images/profile/{}.webp", user.id);
let mut image_target = ImageResource::from_path(&profile_picture_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error converting image to webp: {e}"))
})?;
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("Error converting image to webp: {e}")))?;
Ok(())
}
@ -78,15 +79,10 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn recent_songs(
for_user_id: i32,
limit: Option<i64>,
) -> Result<Vec<(NaiveDateTime, frontend::Song)>, ServerFnError> {
let viewing_user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
) -> BackendResult<Vec<(NaiveDateTime, frontend::Song)>> {
let viewing_user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_con = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Create an alias for the table so it can be referenced twice in the query
let history2 = diesel::alias!(song_history as history2);
@ -138,7 +134,8 @@ pub async fn recent_songs(
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
.load(&mut db_conn)
.context("Error loading recent songs from database")?;
// Process the history data into a map of song ids to song data
let mut history_songs: HashMap<i32, (NaiveDateTime, frontend::Song)> = HashMap::new();
@ -201,15 +198,10 @@ pub async fn top_songs(
start_date: NaiveDateTime,
end_date: NaiveDateTime,
limit: Option<i64>,
) -> Result<Vec<(i64, frontend::Song)>, ServerFnError> {
let viewing_user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
) -> BackendResult<Vec<(i64, frontend::Song)>> {
let viewing_user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_con = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Get the play count and ids of the songs listened to in the date range
let history_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
@ -220,14 +212,16 @@ pub async fn top_songs(
.select((song_history::song_id, count(song_history::song_id)))
.order(count(song_history::song_id).desc())
.limit(limit)
.load(&mut db_con)?
.load(&mut db_conn)
.context("Error loading top song ids and counts from database")?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.group_by(song_history::song_id)
.select((song_history::song_id, count(song_history::song_id)))
.load(&mut db_con)?
.load(&mut db_conn)
.context("Error loading top song ids and counts from database")?
};
let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect();
@ -265,7 +259,8 @@ pub async fn top_songs(
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
.load(&mut db_conn)
.context("Error loading top songs from database")?;
// Process the history data into a map of song ids to song data
let mut history_songs_map: HashMap<i32, (i64, frontend::Song)> =
@ -308,8 +303,8 @@ pub async fn top_songs(
let plays = history_counts
.get(&song.id)
.ok_or(ServerFnError::ServerError::<NoCustomError>(
"Song id not found in history counts".to_string(),
.ok_or(BackendError::InternalError(
"Song id not found in history counts",
))?;
history_songs_map.insert(song.id, (*plays, songdata));
@ -332,8 +327,8 @@ pub async fn top_artists(
start_date: NaiveDateTime,
end_date: NaiveDateTime,
limit: Option<i64>,
) -> Result<Vec<(i64, frontend::Artist)>, ServerFnError> {
let mut db_con = get_db_conn();
) -> BackendResult<Vec<(i64, frontend::Artist)>> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let artist_counts: Vec<(i64, Artist)> = if let Some(limit) = limit {
song_history::table
@ -345,7 +340,8 @@ pub async fn top_artists(
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.limit(limit)
.load(&mut db_con)?
.load(&mut db_conn)
.context("Error loading top artists from database")?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
@ -355,7 +351,8 @@ pub async fn top_artists(
.group_by(artists::id)
.select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc())
.load(&mut db_con)?
.load(&mut db_conn)
.context("Error loading top artists from database")?
};
let artist_data: Vec<(i64, frontend::Artist)> = artist_counts
@ -376,15 +373,10 @@ pub async fn top_artists(
}
#[server(endpoint = "/profile/liked_songs", client = Client)]
pub async fn get_liked_songs() -> Result<Vec<frontend::Song>, ServerFnError> {
let user_id = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
pub async fn get_liked_songs() -> BackendResult<Vec<frontend::Song>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let songs: Vec<(
backend::Song,
@ -407,7 +399,8 @@ pub async fn get_liked_songs() -> Result<Vec<frontend::Song>, ServerFnError> {
albums::all_columns.nullable(),
artists::all_columns.nullable(),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading liked songs from database")?;
let mut liked_songs: HashMap<i32, frontend::Song> = HashMap::new();

View File

@ -1,4 +1,5 @@
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
@ -12,8 +13,7 @@ if #[cfg(feature = "ssr")] {
use diesel::expression::AsExpression;
use std::collections::HashMap;
use crate::models::backend;
use crate::util::database::get_db_conn;
use crate::util::backend_state::BackendState;
// Define pg_trgm operators
// Functions do not use indices for queries, so we need to use operators
@ -49,17 +49,18 @@ pub type SearchResults<T> = Vec<(T, f32)>;
pub async fn search_albums(
query: String,
limit: i64,
) -> Result<SearchResults<frontend::Album>, ServerFnError> {
) -> BackendResult<SearchResults<frontend::Album>> {
use crate::schema::*;
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let album_ids = albums::table
.filter(trgm_similar(albums::title, query.clone()))
.order_by(trgm_distance(albums::title, query.clone()).desc())
.limit(limit)
.select(albums::id)
.load::<i32>(&mut db_conn)?;
.load::<i32>(&mut db_conn)
.context("Error loading album ids from database")?;
let mut albums_map: HashMap<i32, (frontend::Album, f32)> = HashMap::new();
@ -75,7 +76,8 @@ pub async fn search_albums(
artists::all_columns,
trgm_distance(albums::title, query.clone()),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading album artists from database")?;
for (album, artist, score) in album_artists {
if let Some((stored_album, _score)) = albums_map.get_mut(&album.id) {
@ -113,17 +115,18 @@ pub async fn search_albums(
pub async fn search_artists(
query: String,
limit: i64,
) -> Result<SearchResults<frontend::Artist>, ServerFnError> {
) -> BackendResult<SearchResults<frontend::Artist>> {
use crate::schema::*;
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let artist_list = artists::table
.filter(trgm_similar(artists::name, query.clone()))
.order_by(trgm_distance(artists::name, query.clone()).desc())
.limit(limit)
.select((artists::all_columns, trgm_distance(artists::name, query)))
.load::<(backend::Artist, f32)>(&mut db_conn)?;
.load::<(backend::Artist, f32)>(&mut db_conn)
.context("Error loading artists from database")?;
let artist_data = artist_list
.into_iter()
@ -154,13 +157,15 @@ pub async fn search_artists(
pub async fn search_songs(
query: String,
limit: i64,
) -> Result<SearchResults<frontend::Song>, ServerFnError> {
) -> BackendResult<SearchResults<frontend::Song>> {
use crate::api::auth::get_logged_in_user;
use crate::schema::*;
let user = get_logged_in_user().await?;
let user = get_logged_in_user()
.await
.context("Error getting logged-in user")?;
let mut db_conn = get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let song_list = if let Some(user) = user {
let song_list: Vec<(
@ -198,7 +203,8 @@ pub async fn search_songs(
song_dislikes::all_columns.nullable(),
trgm_distance(songs::title, query.clone()),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading songs from database")?;
song_list
} else {
@ -223,7 +229,8 @@ pub async fn search_songs(
artists::all_columns.nullable(),
trgm_distance(songs::title, query.clone()),
))
.load(&mut db_conn)?;
.load(&mut db_conn)
.context("Error loading songs from database")?;
song_list
.into_iter()
@ -293,14 +300,11 @@ pub async fn search_songs(
pub async fn search(
query: String,
limit: i64,
) -> Result<
(
SearchResults<frontend::Album>,
SearchResults<frontend::Artist>,
SearchResults<frontend::Song>,
),
ServerFnError,
> {
) -> BackendResult<(
SearchResults<frontend::Album>,
SearchResults<frontend::Artist>,
SearchResults<frontend::Song>,
)> {
let albums = search_albums(query.clone(), limit);
let artists = search_artists(query.clone(), limit);
let songs = search_songs(query, limit);
@ -308,5 +312,10 @@ pub async fn search(
use tokio::join;
let (albums, artists, songs) = join!(albums, artists, songs);
Ok((albums?, artists?, songs?))
let albums = albums.context("Error searching for albums")?;
let artists = artists.context("Error searching for artists")?;
let songs = songs.context("Error searching for songs")?;
Ok((albums, artists, songs))
}

View File

@ -3,12 +3,12 @@ use leptos::prelude::*;
use cfg_if::cfg_if;
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use crate::util::database::get_db_conn;
use crate::util::backend_state::BackendState;
use crate::api::auth::get_user;
use crate::models::backend::{Song, Album, Artist};
use diesel::prelude::*;
@ -17,68 +17,58 @@ cfg_if! {
/// Like or unlike a song
#[server(endpoint = "songs/set_like", client = Client)]
pub async fn set_like_song(song_id: i32, like: bool) -> Result<(), ServerFnError> {
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
pub async fn set_like_song(song_id: i32, like: bool) -> BackendResult<()> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
user.set_like_song(song_id, like, db_con)
user.set_like_song(song_id, like, &mut db_conn)
.await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error liking song: {e}")))
.context("Error setting like status for song")
}
/// Dislike or remove dislike from a song
#[server(endpoint = "songs/set_dislike", client = Client)]
pub async fn set_dislike_song(song_id: i32, dislike: bool) -> Result<(), ServerFnError> {
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
pub async fn set_dislike_song(song_id: i32, dislike: bool) -> BackendResult<()> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
user.set_dislike_song(song_id, dislike, db_con)
user.set_dislike_song(song_id, dislike, &mut db_conn)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error disliking song: {e}"))
})
.context("Error setting dislike status for song")
}
/// Get the like and dislike status of a song
#[server(endpoint = "songs/get_like_dislike", client = Client)]
pub async fn get_like_dislike_song(song_id: i32) -> Result<(bool, bool), ServerFnError> {
let user = get_user().await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?;
pub async fn get_like_dislike_song(song_id: i32) -> BackendResult<(bool, bool)> {
let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// TODO this could probably be done more efficiently with a tokio::try_join, but
// doing so is much more complicated than it would initially seem
let like = user.get_like_song(song_id, db_con).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song liked: {e}"))
})?;
let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song disliked: {e}"))
})?;
let like = user
.get_like_song(song_id, &mut db_conn)
.await
.context("Error getting song like status")?;
let dislike = user
.get_dislike_song(song_id, &mut db_conn)
.await
.context("Error getting song dislike status")?;
Ok((like, dislike))
}
#[server(endpoint = "songs/get", client = Client)]
pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, ServerFnError> {
pub async fn get_song_by_id(song_id: i32) -> BackendResult<Option<frontend::Song>> {
use crate::schema::*;
let user_id: i32 = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
let user_id: i32 = get_user().await.context("Error getting logged-in user")?.id;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let song_parts: Vec<(
Song,
@ -111,7 +101,8 @@ pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, Serv
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(db_con)?;
.load(&mut db_conn)
.context("Error loading song from database")?;
let song = song_parts.first().cloned();
let artists = song_parts
@ -148,34 +139,27 @@ pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, Serv
}
#[server(endpoint = "songs/plays", client = Client)]
pub async fn get_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
pub async fn get_song_plays(song_id: i32) -> BackendResult<i64> {
use crate::schema::*;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let plays = song_history::table
.filter(song_history::song_id.eq(song_id))
.count()
.get_result::<i64>(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song plays: {e}"))
})?;
.get_result::<i64>(&mut db_conn)
.context("Error getting song plays")?;
Ok(plays)
}
#[server(endpoint = "songs/my-plays", client = Client)]
pub async fn get_my_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
pub async fn get_my_song_plays(song_id: i32) -> BackendResult<i64> {
use crate::schema::*;
let user_id: i32 = get_user()
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
})?
.id;
let user_id: i32 = get_user().await.context("Error getting logged-in user")?.id;
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let plays = song_history::table
.filter(
@ -184,10 +168,8 @@ pub async fn get_my_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
.and(song_history::user_id.eq(user_id)),
)
.count()
.get_result::<i64>(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song plays: {e}"))
})?;
.get_result::<i64>(&mut db_conn)
.context("Error getting song plays for user")?;
Ok(plays)
}

View File

@ -1,3 +1,4 @@
use crate::util::error::*;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
@ -6,11 +7,10 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use multer::Field;
use crate::util::database::get_db_conn;
use crate::util::backend_state::BackendState;
use crate::util::extract_field::extract_field;
use diesel::prelude::*;
use log::*;
use server_fn::error::NoCustomError;
use chrono::NaiveDate;
}
}
@ -18,7 +18,7 @@ cfg_if! {
/// Validate the artist ids in a multipart field
/// Expects a field with a comma-separated list of artist ids, and ensures each is a valid artist id in the database
#[cfg(feature = "ssr")]
async fn validate_artist_ids(artist_ids: Field<'static>) -> Result<Vec<i32>, ServerFnError> {
async fn validate_artist_ids(artist_ids: Field<'static>) -> BackendResult<Vec<i32>> {
use crate::models::backend::Artist;
use diesel::result::Error::NotFound;
@ -27,45 +27,38 @@ async fn validate_artist_ids(artist_ids: Field<'static>) -> Result<Vec<i32>, Ser
Ok(artist_ids) => {
let artist_ids = artist_ids.trim_end_matches(',').split(',');
let mut db_conn = BackendState::get().await?.get_db_conn()?;
artist_ids
.filter(|artist_id| !artist_id.is_empty())
.map(|artist_id| {
// Parse the artist id as an integer
if let Ok(artist_id) = artist_id.parse::<i32>() {
// Check if the artist exists
let db_con = &mut get_db_conn();
let artist = crate::schema::artists::dsl::artists
.find(artist_id)
.first::<Artist>(db_con);
.first::<Artist>(&mut db_conn);
match artist {
Ok(_) => Ok(artist_id),
Err(NotFound) => Err(ServerFnError::<NoCustomError>::ServerError(
"Artist does not exist".to_string(),
)),
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error finding artist id: {e}"
))),
Err(NotFound) => Err(AccessError::NotFound.context("Artist not found")),
Err(e) => Err(e.context("Error finding artist id")),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing artist id".to_string(),
))
Err(InputError::InvalidInput("Error parsing artist id".to_string()).into())
}
})
.collect()
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading artist id: {e}"
))),
Err(e) => Err(InputError::FieldReadError(format!("Error reading artist ids: {e}")).into()),
}
}
/// Validate the album id in a multipart field
/// Expects a field with an album id, and ensures it is a valid album id in the database
#[cfg(feature = "ssr")]
async fn validate_album_id(album_id: Field<'static>) -> Result<Option<i32>, ServerFnError> {
async fn validate_album_id(album_id: Field<'static>) -> BackendResult<Option<i32>> {
use crate::models::backend::Album;
use diesel::result::Error::NotFound;
@ -78,37 +71,30 @@ async fn validate_album_id(album_id: Field<'static>) -> Result<Option<i32>, Serv
// Parse the album id as an integer
if let Ok(album_id) = album_id.parse::<i32>() {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Check if the album exists
let db_con = &mut get_db_conn();
let album = crate::schema::albums::dsl::albums
.find(album_id)
.first::<Album>(db_con);
.first::<Album>(&mut db_conn);
match album {
Ok(_) => Ok(Some(album_id)),
Err(NotFound) => Err(ServerFnError::<NoCustomError>::ServerError(
"Album does not exist".to_string(),
)),
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error finding album id: {e}"
))),
Err(NotFound) => Err(AccessError::NotFound.context("Album not found")),
Err(e) => Err(e.context("Error finding album id")),
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing album id".to_string(),
))
Err(InputError::InvalidInput("Error parsing album id".to_string()).into())
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading album id: {e}"
))),
Err(e) => Err(InputError::FieldReadError(format!("Error reading album id: {e}")).into()),
}
}
/// Validate the track number in a multipart field
/// Expects a field with a track number, and ensures it is a valid track number (non-negative integer)
#[cfg(feature = "ssr")]
async fn validate_track_number(track_number: Field<'static>) -> Result<Option<i32>, ServerFnError> {
async fn validate_track_number(track_number: Field<'static>) -> BackendResult<Option<i32>> {
match track_number.text().await {
Ok(track_number) => {
if track_number.is_empty() {
@ -117,30 +103,27 @@ async fn validate_track_number(track_number: Field<'static>) -> Result<Option<i3
if let Ok(track_number) = track_number.parse::<i32>() {
if track_number < 0 {
Err(ServerFnError::<NoCustomError>::ServerError(
"Track number must be positive or 0".to_string(),
))
Err(
InputError::InvalidInput("Track number must be positive or 0".to_string())
.into(),
)
} else {
Ok(Some(track_number))
}
} else {
Err(ServerFnError::<NoCustomError>::ServerError(
"Error parsing track number".to_string(),
))
Err(InputError::InvalidInput("Error parsing track number".to_string()).into())
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading track number: {e}"
)))?,
Err(e) => {
Err(InputError::FieldReadError(format!("Error reading track number: {e}")).into())
}
}
}
/// Validate the release date in a multipart field
/// Expects a field with a release date, and ensures it is a valid date in the format [year]-[month]-[day]
#[cfg(feature = "ssr")]
async fn validate_release_date(
release_date: Field<'static>,
) -> Result<Option<NaiveDate>, ServerFnError> {
async fn validate_release_date(release_date: Field<'static>) -> BackendResult<Option<NaiveDate>> {
match release_date.text().await {
Ok(release_date) => {
if release_date.trim().is_empty() {
@ -151,20 +134,19 @@ async fn validate_release_date(
match release_date {
Ok(release_date) => Ok(Some(release_date)),
Err(_) => Err(ServerFnError::<NoCustomError>::ServerError(
"Invalid release date".to_string(),
)),
Err(_) => Err(InputError::InvalidInput(
"Invalid release date format, expected YYYY-MM-DD".to_string(),
)
.into()),
}
}
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading release date: {e}"
))),
Err(e) => Err(InputError::InvalidInput(format!("Error reading release date: {e}")).into()),
}
}
/// Handle the file upload form
#[server(input = MultipartFormData, endpoint = "/upload")]
pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
pub async fn upload(data: MultipartData) -> BackendResult<()> {
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
@ -182,19 +164,39 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
match name.as_str() {
"title" => {
title = Some(extract_field(field).await?);
title = Some(
extract_field(field)
.await
.context("Error extracting title field")?,
);
}
"artist_ids" => {
artist_ids = Some(validate_artist_ids(field).await?);
artist_ids = Some(
validate_artist_ids(field)
.await
.context("Error validating artist ids")?,
);
}
"album_id" => {
album_id = Some(validate_album_id(field).await?);
album_id = Some(
validate_album_id(field)
.await
.context("Error validating album id")?,
);
}
"track_number" => {
track = Some(validate_track_number(field).await?);
track = Some(
validate_track_number(field)
.await
.context("Error validating track number")?,
);
}
"release_date" => {
release_date = Some(validate_release_date(field).await?);
release_date = Some(
validate_release_date(field)
.await
.context("Error validating release date")?,
);
}
"file" => {
use crate::util::audio::extract_metadata;
@ -206,11 +208,9 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
// or behavior that we may wish to change in the future
// Create file name
let title = title
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Title field required and must precede file field".to_string(),
))?;
let title = title.clone().ok_or(InputError::InvalidInput(
"Title field must be present and must precede file field".to_string(),
))?;
let clean_title = title.replace(" ", "_").replace("/", "_");
let date_str = chrono::Utc::now().format("%Y-%m-%d_%H:%M:%S").to_string();
@ -226,28 +226,30 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
.write(true)
.create(true)
.truncate(true)
.open(upload_path.clone())?;
.open(upload_path.clone())
.context("Error opening file for upload")?;
while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?;
while let Some(chunk) = field.chunk().await.map_err(|e| {
InputError::FieldReadError(format!("Error reading file chunk: {e}"))
})? {
file.write_all(&chunk)
.context("Error writing field chunk to file")?;
}
file.flush()?;
file.flush().context("Error flusing file")?;
// Rewind the file so the duration can be measured
file.rewind()?;
file.rewind().context("Error rewinding file")?;
// Get the codec and duration of the file
let (file_codec, file_duration) = extract_metadata(file).map_err(|e| {
let msg = format!("Error measuring duration of audio file {upload_path}: {e}");
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
let (file_codec, file_duration) = extract_metadata(file)
.context("Error extracting metadata from uploaded file")?;
if file_codec != CODEC_TYPE_MP3 {
let msg = format!("Invalid uploaded audio file codec: {file_codec}");
warn!("{}", msg);
return Err(ServerFnError::<NoCustomError>::ServerError(msg));
return Err(InputError::InvalidInput(format!(
"Invalid uploaded audio file codec: {file_codec}"
))
.into());
}
duration = Some(file_duration);
@ -259,30 +261,23 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
}
// Unwrap mandatory fields
let title = title.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Missing title".to_string(),
))?;
let title = title.ok_or(InputError::MissingField("title".to_string()))?;
let artist_ids = artist_ids.unwrap_or(vec![]);
let file_name = file_name.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Missing file".to_string(),
))?;
let duration = duration.ok_or(ServerFnError::<NoCustomError>::ServerError(
"Missing duration".to_string(),
))?;
let duration = i32::try_from(duration).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error converting duration to i32: {e}"
))
})?;
let file_name = file_name.ok_or(InputError::MissingField("file".to_string()))?;
let duration = duration.ok_or(InputError::MissingField("duration".to_string()))?;
let duration = i32::try_from(duration)
.map_err(|e| InputError::InvalidInput(format!("Error parsing duration: {e}")))
.context("Error converting duration to i32")?;
let album_id = album_id.unwrap_or(None);
let track = track.unwrap_or(None);
let release_date = release_date.unwrap_or(None);
if album_id.is_some() != track.is_some() {
return Err(ServerFnError::<NoCustomError>::ServerError(
return Err(InputError::InvalidInput(
"Album id and track number must both be present or both be absent".to_string(),
));
)
.into());
}
// Create the song
@ -297,16 +292,13 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
image_path: None,
};
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Save the song to the database
let db_con = &mut get_db_conn();
let song = song
.insert_into(crate::schema::songs::table)
.get_result::<Song>(db_con)
.map_err(|e| {
let msg = format!("Error saving song to database: {e}");
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
.get_result::<Song>(&mut db_conn)
.context("Error adding song to database")?;
// Save the song's artists to the database
use crate::schema::song_artists;
@ -324,12 +316,8 @@ pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> {
diesel::insert_into(crate::schema::song_artists::table)
.values(&artist_ids)
.execute(db_con)
.map_err(|e| {
let msg = format!("Error saving song artists to database: {e}");
warn!("{}", msg);
ServerFnError::<NoCustomError>::ServerError(msg)
})?;
.execute(&mut db_conn)
.context("Error saving song artists to database")?;
Ok(())
}

View File

@ -1,7 +1,6 @@
cfg_if::cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::get_db_conn;
use pbkdf2::{
password_hash::{
@ -12,10 +11,13 @@ cfg_if::cfg_if! {
};
use crate::models::backend::NewUser;
use crate::util::backend_state::BackendState;
use crate::util::database::PgPooledConn;
}
}
use crate::models::backend::User;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
@ -29,22 +31,19 @@ pub struct UserCredentials {
/// 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> {
pub async fn find_user(
username_or_email: String,
db_conn: &mut PgPooledConn,
) -> BackendResult<Option<User>> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
// 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)
.first::<User>(db_conn)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting user from database: {e}"
))
})?;
.context("Error loading user from database")?;
Ok(user)
}
@ -52,20 +51,17 @@ pub async fn find_user(username_or_email: String) -> Result<Option<User>, Server
/// Get a user from the database by ID
/// 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_by_id(user_id: i32) -> Result<Option<User>, ServerFnError> {
pub async fn find_user_by_id(
user_id: i32,
db_conn: &mut PgPooledConn,
) -> BackendResult<Option<User>> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
let db_con = &mut get_db_conn();
let user = users
.filter(id.eq(user_id))
.first::<User>(db_con)
.first::<User>(db_conn)
.optional()
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting user from database: {e}"
))
})?;
.context("Error loading user from database")?;
Ok(user)
}
@ -73,25 +69,21 @@ pub async fn find_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError
/// 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: &NewUser) -> Result<(), ServerFnError> {
pub async fn create_user(new_user: &NewUser) -> BackendResult<()> {
use crate::schema::users::dsl::*;
use leptos::server_fn::error::NoCustomError;
let new_password =
new_user
.password
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(format!(
"No password provided for user {}",
new_user.username
.ok_or(BackendError::InputError(InputError::MissingField(
"password".to_string(),
)))?;
let salt = SaltString::generate(&mut OsRng);
let password_hash = Pbkdf2
.hash_password(new_password.as_bytes(), &salt)
.map_err(|_| {
ServerFnError::<NoCustomError>::ServerError("Error hashing password".to_string())
})?
.map_err(|e| AuthError::AuthError(format!("Error hashing password: {e}")))?
.to_string();
let new_user = NewUser {
@ -99,14 +91,12 @@ pub async fn create_user(new_user: &NewUser) -> Result<(), ServerFnError> {
..new_user.clone()
};
let db_con = &mut get_db_conn();
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(users)
.values(&new_user)
.execute(db_con)
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
})?;
.execute(&mut db_conn)
.context("Error inserting new user into database")?;
Ok(())
}
@ -114,16 +104,13 @@ pub async fn create_user(new_user: &NewUser) -> Result<(), ServerFnError> {
/// 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(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
use leptos::server_fn::error::NoCustomError;
let db_user = find_user(credentials.username_or_email.clone())
pub async fn validate_user(
credentials: UserCredentials,
db_conn: &mut PgPooledConn,
) -> BackendResult<Option<User>> {
let db_user = find_user(credentials.username_or_email.clone(), db_conn)
.await
.map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!(
"Error getting user from database: {e}"
))
})?;
.context("Error finding user in database")?;
// If the user is not found, return None
let db_user = match db_user {
@ -131,18 +118,13 @@ pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>,
None => return Ok(None),
};
let db_password =
db_user
.password
.clone()
.ok_or(ServerFnError::<NoCustomError>::ServerError(format!(
"No password found for user {}",
db_user.username
)))?;
let db_password = db_user.password.clone().ok_or(AuthError::AuthError(
"No password stored for user".to_string(),
))?;
let password_hash = PasswordHash::new(&db_password).map_err(|e| {
ServerFnError::<NoCustomError>::ServerError(format!("Error hashing supplied password: {e}"))
})?;
let password_hash = PasswordHash::new(&db_password)
.map_err(|e| AuthError::AuthError(format!("{e}")))
.context("Error parsing password hash from database")?;
match Pbkdf2.verify_password(credentials.password.as_bytes(), &password_hash) {
Ok(()) => {}
@ -150,9 +132,9 @@ pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>,
return Ok(None);
}
Err(e) => {
return Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error verifying password: {e}"
)));
return Err(
AuthError::AuthError(format!("{e}")).context("Error verifying password hash")
);
}
}
@ -162,8 +144,12 @@ pub async fn validate_user(credentials: UserCredentials) -> Result<Option<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 = "find_user", client = Client)]
pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
let mut user = find_user(username_or_email).await?;
pub async fn get_user(username_or_email: String) -> BackendResult<Option<User>> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let mut user = find_user(username_or_email, &mut db_conn)
.await
.context("Error finding user by username or email")?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
@ -174,8 +160,12 @@ pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerF
}
#[server(endpoint = "get_user_by_id", client = Client)]
pub async fn get_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError> {
let mut user = find_user_by_id(user_id).await?;
pub async fn get_user_by_id(user_id: i32) -> BackendResult<Option<User>> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let mut user = find_user_by_id(user_id, &mut db_conn)
.await
.context("Error finding user by ID")?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {

View File

@ -2,6 +2,7 @@ use crate::components::error::Error;
use crate::components::loading::*;
use crate::components::menu::*;
use crate::util::state::GlobalState;
use leptos::either::*;
use leptos::html::Div;
use leptos::prelude::*;
use leptos_icons::*;
@ -121,49 +122,38 @@ pub fn Playlists() -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| {
errors.get().into_iter().map(|(_id, error)| {
view! {
<Error<String>
message=error.to_string()
/>
}
}).collect::<Vec<_>>()
<A href={"/liked".to_string()} {..}
style={move || if liked_songs_active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1"
>
<img class="w-15 h-15 rounded-xl p-2"
src="/assets/images/placeholders/MusicPlaceholder.svg" />
<h2 class="pr-3 my-2">"Liked Songs"</h2>
</A>
{move || GlobalState::playlists().get().map(|playlists| {
match playlists {
Ok(playlists) => Either::Left(view! {
{playlists.into_iter().map(|playlist| {
let active = Signal::derive(move || {
location.pathname.get().ends_with(&format!("/playlist/{}", playlist.id))
});
view! {
<A href={format!("/playlist/{}", playlist.id)} {..}
style={move || if active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1" >
<img class="w-15 h-15 rounded-xl p-2 object-cover"
src={format!("/assets/images/playlist/{}.webp", playlist.id)}
onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
<h2 class="pr-3 my-2">{playlist.name}</h2>
</A>
}
}).collect::<Vec<_>>()}
}),
Err(error) => Either::Right(error.to_component()),
}
>
<A href={"/liked".to_string()} {..}
style={move || if liked_songs_active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1"
>
<img class="w-15 h-15 rounded-xl p-2"
src="/assets/images/placeholders/MusicPlaceholder.svg" />
<h2 class="pr-3 my-2">"Liked Songs"</h2>
</A>
{move || GlobalState::playlists().get().map(|playlists| {
playlists.map(|playlists| {
view! {
{playlists.into_iter().map(|playlist| {
let active = Signal::derive(move || {
location.pathname.get().ends_with(&format!("/playlist/{}", playlist.id))
});
view! {
<A href={format!("/playlist/{}", playlist.id)} {..}
style={move || if active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1" >
<img class="w-15 h-15 rounded-xl p-2 object-cover"
src={format!("/assets/images/playlist/{}.webp", playlist.id)}
onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
<h2 class="pr-3 my-2">{playlist.name}</h2>
</A>
}
}).collect::<Vec<_>>()}
}
})
})}
</ErrorBoundary>
})}
</Transition>
</div>
</div>

View File

@ -6,17 +6,20 @@ extern crate diesel_migrations;
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::{body::Body, extract::Request, http::Response, middleware::Next};
use axum::{extract::Path, middleware::from_fn, routing::get, Router};
use axum_login::tower_sessions::SessionManagerLayer;
use axum_login::AuthManagerLayerBuilder;
use http::StatusCode;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use libretunes::app::*;
use libretunes::util::auth_backend::AuthBackend;
use libretunes::util::backend_state::BackendState;
use libretunes::util::config::load_config;
use libretunes::util::fileserv::{
file_and_error_handler, get_asset_file, get_static_file, AssetType,
};
use libretunes::util::redis::get_redis_pool;
use libretunes::util::require_auth::require_auth_middleware;
use log::*;
use tower_sessions_redis_store::RedisStore;
@ -28,6 +31,19 @@ async fn main() {
.unwrap();
info!("\n{}", include_str!("../ascii_art.txt"));
let config = load_config().unwrap_or_else(|err| {
error!("Failed to load configuration: {}", err);
std::process::exit(1);
});
let state = BackendState::from_config(config)
.await
.unwrap_or_else(|err| {
error!("Failed to initialize backend state: {}", err);
std::process::exit(1);
});
info!("Starting Leptos server...");
use dotenvy::dotenv;
@ -35,17 +51,38 @@ async fn main() {
debug!("Running database migrations...");
// Bring the database up to date
libretunes::util::database::migrate();
let mut db_conn = state.get_db_conn().unwrap_or_else(|err| {
error!("Failed to get database connection: {}", err);
std::process::exit(1);
});
debug!("Connecting to Redis...");
let redis_pool = get_redis_pool();
let session_store = RedisStore::new(redis_pool);
// Bring the database up to date
libretunes::util::database::migrate(&mut db_conn);
drop(db_conn); // Close the connection after migrations
debug!("Setting up session store...");
let session_store = RedisStore::new(state.get_redis_conn());
let session_layer = SessionManagerLayer::new(session_store);
let auth_backend = AuthBackend;
let auth_backend = AuthBackend {
backend_state: state.clone(),
};
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
// A middleware that injects the backend state into the request extensions,
// allowing it to be extracted later in the request lifecycle
let backend_state_middleware = move |mut req: Request, next: Next| {
let state = state.clone();
async move {
req.extensions_mut().insert(state);
let response = next.run(req).await;
Ok::<Response<Body>, (StatusCode, &'static str)>(response)
}
};
let conf = get_configuration(None).unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
@ -58,16 +95,17 @@ async fn main() {
move || shell(leptos_options.clone())
})
.route(
"/assets/audio/:song",
"/assets/audio/{song}",
get(|Path(song): Path<String>| get_asset_file(song, AssetType::Audio)),
)
.route(
"/assets/images/:image",
"/assets/images/{image}",
get(|Path(image): Path<String>| get_asset_file(image, AssetType::Image)),
)
.route("/assets/*uri", get(|uri| get_static_file(uri, "")))
.route("/assets/{*uri}", get(|uri| get_static_file(uri, "")))
.layer(from_fn(require_auth_middleware))
.layer(auth_layer)
.layer(from_fn(backend_state_middleware))
.fallback(file_and_error_handler)
.with_state(leptos_options);

View File

@ -7,9 +7,9 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::*;
use std::error::Error;
use crate::util::database::PgPooledConn;
use crate::models::backend::{Song, HistoryEntry};
use crate::util::error::*;
}
}
@ -59,7 +59,7 @@ impl User {
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> Result<Vec<HistoryEntry>, Box<dyn Error>> {
) -> BackendResult<Vec<HistoryEntry>> {
use crate::schema::song_history::dsl::*;
let my_history = if let Some(limit) = limit {
@ -67,7 +67,8 @@ impl User {
.filter(user_id.eq(self.id))
.order(date.desc())
.limit(limit)
.load(conn)?
.load(conn)
.context("Error getting user history")?
} else {
song_history.filter(user_id.eq(self.id)).load(conn)?
};
@ -96,7 +97,7 @@ impl User {
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> Result<Vec<(NaiveDateTime, Song)>, Box<dyn Error>> {
) -> BackendResult<Vec<(NaiveDateTime, Song)>> {
use crate::schema::song_history::dsl::*;
use crate::schema::songs::dsl::*;
@ -107,14 +108,16 @@ impl User {
.order(date.desc())
.limit(limit)
.select((date, songs::all_columns()))
.load(conn)?
.load(conn)
.context("Error getting user history songs")?
} else {
song_history
.inner_join(songs)
.filter(user_id.eq(self.id))
.order(date.desc())
.select((date, songs::all_columns()))
.load(conn)?
.load(conn)
.context("Error getting user history songs")?
};
Ok(my_history)
@ -135,7 +138,7 @@ impl User {
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_history(&self, song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
pub fn add_history(&self, song_id: i32, conn: &mut PgPooledConn) -> BackendResult<()> {
use crate::schema::song_history;
diesel::insert_into(song_history::table)
@ -143,7 +146,8 @@ impl User {
song_history::user_id.eq(self.id),
song_history::song_id.eq(song_id),
))
.execute(conn)?;
.execute(conn)
.context("Error adding song to history")?;
Ok(())
}
@ -156,7 +160,7 @@ impl User {
song_id: i32,
like: bool,
conn: &mut PgPooledConn,
) -> Result<(), Box<dyn Error>> {
) -> BackendResult<()> {
use log::*;
debug!("Setting like for song {} to {}", song_id, like);
@ -169,7 +173,8 @@ impl User {
song_likes::song_id.eq(song_id),
song_likes::user_id.eq(self.id),
))
.execute(conn)?;
.execute(conn)
.context("Error liking song")?;
// Remove dislike if it exists
diesel::delete(
@ -179,7 +184,8 @@ impl User {
.and(song_dislikes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing dislike for song")?;
} else {
diesel::delete(
song_likes::table.filter(
@ -188,7 +194,8 @@ impl User {
.and(song_likes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing like for song")?;
}
Ok(())
@ -200,7 +207,7 @@ impl User {
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> Result<bool, Box<dyn Error>> {
) -> BackendResult<bool> {
use crate::schema::song_likes;
let like = song_likes::table
@ -210,7 +217,8 @@ impl User {
.and(song_likes::user_id.eq(self.id)),
)
.first::<(i32, i32)>(conn)
.optional()?
.optional()
.context("Error checking if song is liked")?
.is_some();
Ok(like)
@ -224,7 +232,7 @@ impl User {
song_id: i32,
dislike: bool,
conn: &mut PgPooledConn,
) -> Result<(), Box<dyn Error>> {
) -> BackendResult<()> {
use log::*;
debug!("Setting dislike for song {} to {}", song_id, dislike);
@ -237,7 +245,8 @@ impl User {
song_dislikes::song_id.eq(song_id),
song_dislikes::user_id.eq(self.id),
))
.execute(conn)?;
.execute(conn)
.context("Error disliking song")?;
// Remove like if it exists
diesel::delete(
@ -247,7 +256,8 @@ impl User {
.and(song_likes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing like for song")?;
} else {
diesel::delete(
song_dislikes::table.filter(
@ -256,7 +266,8 @@ impl User {
.and(song_dislikes::user_id.eq(self.id)),
),
)
.execute(conn)?;
.execute(conn)
.context("Error removing dislike for song")?;
}
Ok(())
@ -268,7 +279,7 @@ impl User {
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> Result<bool, Box<dyn Error>> {
) -> BackendResult<bool> {
use crate::schema::song_dislikes;
let dislike = song_dislikes::table
@ -278,7 +289,8 @@ impl User {
.and(song_dislikes::user_id.eq(self.id)),
)
.first::<(i32, i32)>(conn)
.optional()?
.optional()
.context("Error checking if song is disliked")?
.is_some();
Ok(dislike)

View File

@ -6,7 +6,6 @@ use crate::models::frontend;
use leptos::either::*;
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
#[component]
pub fn AlbumPage() -> impl IntoView {
@ -61,12 +60,7 @@ fn AlbumIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
message=format!("Album with ID {} not found", id.get())
/>
}),
Err(error) => EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting Album"
error
/>
}),
Err(error) => EitherOf3::C(error.to_component()),
}
})}
</Transition>
@ -98,21 +92,14 @@ fn AlbumSongs(#[prop(into)] id: Signal<i32>) -> impl IntoView {
<Transition
fallback= move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p> })
.collect_view()
}
}
>
{move || songs.get().map(|songs| {
songs.map(|songs| {
{move || songs.get().map(|songs| {
match songs {
Ok(songs) => Either::Left(
view! { <SongList songs=songs /> }
})
})}
</ErrorBoundary>
),
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
}
}

View File

@ -2,7 +2,6 @@ use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::models::backend::Artist;
@ -65,12 +64,7 @@ fn ArtistIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
message=format!("Artist with ID {} not found", id.get())
/>
}),
Err(error) => EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting Artist"
error
/>
}),
Err(error) => EitherOf3::C(error.to_component())
}
})}
</Transition>
@ -126,21 +120,16 @@ fn TopSongsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
{move || top_songs.get().map(|top_songs| {
match top_songs {
Ok(top_songs) => {
Either::Left(view! {
<SongListExtra songs=top_songs />
})
},
Err(error) => Either::Right(error.to_component()),
}
>
{move || top_songs.get().map(|top_songs| {
top_songs.map(|top_songs| {
view! { <SongListExtra songs=top_songs /> }
})
})}
</ErrorBoundary>
})}
</Transition>
}
}
@ -162,27 +151,20 @@ fn AlbumsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
}
>
{move || albums.get().map(|albums| {
albums.map(|albums| {
{move || albums.get().map(|albums| {
match albums {
Ok(albums) => Either::Left({
let tiles = albums.into_iter().map(|album| {
album.into()
album.into()
}).collect::<Vec<_>>();
view! {
<DashboardRow title="Albums" tiles />
}
})
})}
</ErrorBoundary>
}),
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
}
}

View File

@ -2,7 +2,6 @@ use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::components::dashboard_row::DashboardRow;
use crate::components::error::*;
@ -118,13 +117,7 @@ fn UserIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
},
Err(error) => {
show_details.set(false);
EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Getting User"
error
/>
})
EitherOf3::C(error.to_component())
}
}
})}
@ -203,25 +196,18 @@ fn TopSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
}
>
{move ||
top_songs.get().map(|top_songs| {
top_songs.map(|top_songs| {
{move ||
top_songs.get().map(|top_songs| {
match top_songs {
Ok(top_songs) => Either::Left({
view! {
<SongListExtra songs=top_songs />
}
})
})
}
</ErrorBoundary>
}),
Err(err) => Either::Right(err.to_component()),
}
})
}
</Transition>
}
}
@ -248,25 +234,16 @@ fn RecentSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
<Transition
fallback=move || view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| view! {
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
{move ||
recent_songs.get().map(|recent_songs| {
match recent_songs {
Ok(recent_songs) => Either::Left(view! {
<SongList songs=recent_songs />
}),
Err(err) => Either::Right(err.to_component()),
}
}
>
{move ||
recent_songs.get().map(|recent_songs| {
recent_songs.map(|recent_songs| {
view! {
<SongList songs=recent_songs />
}
})
})
}
</ErrorBoundary>
})
}
</Transition>
}
}
@ -305,19 +282,10 @@ fn TopArtists(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
<Loading />
}
>
<ErrorBoundary
fallback=|errors| view! {
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
.collect_view()
}
}
>
{move ||
top_artists.get().map(|top_artists| {
top_artists.map(|top_artists| {
{move ||
top_artists.get().map(|top_artists| {
match top_artists {
Ok(top_artists) => Either::Left({
let tiles = top_artists.into_iter().map(|artist| {
artist.into()
}).collect::<Vec<_>>();
@ -325,10 +293,16 @@ fn TopArtists(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
view! {
<DashboardRow title=format!("Top Artists {}", HISTORY_MESSAGE) tiles />
}
})
})
}
</ErrorBoundary>
}),
Err(err) => Either::Right(
view! {
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
{err.to_component()}
}
)
}
})
}
</Transition>
}
}

View File

@ -1,8 +1,8 @@
use crate::api::search::search;
use crate::components::dashboard_row::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use leptos::either::*;
use leptos::html::Input;
use leptos::prelude::*;
use leptos_router::hooks::query_signal;
@ -35,20 +35,10 @@ pub fn Search() -> impl IntoView {
<Suspense
fallback=|| view! { <Loading /> }
>
<ErrorBoundary
fallback=|errors| {
errors.get().into_iter().map(|(_id, error)| {
view! {
<Error<String>
message=error.to_string()
/>
}
}).collect::<Vec<_>>()
}
>
{move || {
search.get().map(|results| {
results.map(|(albums, artists, songs)| {
{move || {
search.get().map(|results| {
match results {
Ok((albums, artists, songs)) => Either::Right(
view! {
{
(albums.is_empty() && artists.is_empty() && songs.is_empty()).then(|| {
@ -86,10 +76,11 @@ pub fn Search() -> impl IntoView {
})
}
}
})
})
}}
</ErrorBoundary>
),
Err(err) => Either::Left(err.to_component()),
}
})
}}
</Suspense>
}
}

View File

@ -2,7 +2,6 @@ use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use server_fn::error::NoCustomError;
use crate::api::songs;
use crate::api::songs::*;
@ -67,14 +66,7 @@ fn SongDetails(#[prop(into)] id: Signal<i32>) -> impl IntoView {
/>
})
},
Err(error) => {
EitherOf3::C(view! {
<ServerError<NoCustomError>
title="Error Fetching Song"
error
/>
})
}
Err(error) => EitherOf3::C(error.to_component()),
}
})}
</Transition>
@ -153,14 +145,7 @@ fn SongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
<p>{format!("Plays: {plays}")}</p>
})
},
Err(error) => {
Either::Right(view! {
<ServerError<NoCustomError>
title="Error fetching song plays"
error
/>
})
}
Err(error) => Either::Right(error.to_component())
}
})}
</Transition>
@ -183,12 +168,7 @@ fn MySongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
})
},
Err(error) => {
Either::Right(view! {
<ServerError<NoCustomError>
title="Error fetching my song plays"
error
/>
})
Either::Right(error.to_component())
}
}
})}

View File

@ -1,3 +1,4 @@
use crate::util::error::*;
use std::fs::File;
use symphonia::core::codecs::CodecType;
use symphonia::core::formats::FormatOptions;
@ -7,38 +8,44 @@ use symphonia::core::probe::Hint;
/// Extract the codec and duration of an audio file
/// This is combined into one function because the file object will be consumed
pub fn extract_metadata(file: File) -> Result<(CodecType, u64), Box<dyn std::error::Error>> {
pub fn extract_metadata(file: File) -> BackendResult<(CodecType, u64)> {
let source_stream = MediaSourceStream::new(Box::new(file), Default::default());
let hint = Hint::new();
let format_opts = FormatOptions::default();
let metadata_opts = MetadataOptions::default();
let probe = symphonia::default::get_probe().format(
&hint,
source_stream,
&format_opts,
&metadata_opts,
)?;
let probe = symphonia::default::get_probe()
.format(&hint, source_stream, &format_opts, &metadata_opts)
.map_err(|e| InputError::InvalidInput(format!("{e}")))?;
let reader = probe.format;
if reader.tracks().len() != 1 {
return Err(format!("Expected 1 track, found {}", reader.tracks().len()).into());
return Err(InputError::InvalidInput(format!(
"Expected 1 track, found {}",
reader.tracks().len()
))
.into());
}
let track = &reader.tracks()[0];
let time_base = track.codec_params.time_base.ok_or("Missing time base")?;
let time_base = track
.codec_params
.time_base
.ok_or(InputError::InvalidInput("Missing time base".into()))?;
let duration = track
.codec_params
.n_frames
.map(|frames| track.codec_params.start_ts + frames)
.ok_or("Missing number of frames")?;
.ok_or(InputError::InvalidInput("Missing frame count".to_string()))?;
let duration = duration
.checked_mul(time_base.numer as u64)
.and_then(|v| v.checked_div(time_base.denom as u64))
.ok_or("Overflow while computing duration")?;
.ok_or(BackendError::InternalError(
"Duration calculation overflowed",
))?;
Ok((track.codec_params.codec, duration))
}

View File

@ -1,8 +1,9 @@
use crate::api::users::UserCredentials;
use axum_login::{AuthUser, AuthnBackend, UserId};
use leptos::server_fn::error::ServerFnErrorErr;
use crate::models::backend::User;
use crate::util::backend_state::BackendState;
use crate::util::error::*;
use async_trait::async_trait;
@ -19,26 +20,34 @@ impl AuthUser for User {
}
#[derive(Clone)]
pub struct AuthBackend;
pub struct AuthBackend {
/// The backend state is needed for the database connection
/// `extract`ing the backend state is not possible in this context
pub backend_state: BackendState,
}
#[async_trait]
impl AuthnBackend for AuthBackend {
type User = User;
type Credentials = UserCredentials;
type Error = ServerFnErrorErr;
type Error = BackendError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
crate::api::users::validate_user(creds)
let mut db_conn = self.backend_state.get_db_conn()?;
crate::api::users::validate_user(creds, &mut db_conn)
.await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error validating user: {e}")))
.context("Error validating user credentials")
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
crate::api::users::find_user_by_id(*user_id)
let mut db_conn = self.backend_state.get_db_conn()?;
crate::api::users::find_user_by_id(*user_id, &mut db_conn)
.await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error getting user: {e}")))
.context("Error finding user by ID")
}
}

104
src/util/backend_state.rs Normal file
View File

@ -0,0 +1,104 @@
use crate::util::config::Config;
use crate::util::database::{PgPool, PgPooledConn};
use crate::util::error::*;
use axum::extract::FromRequestParts;
use diesel::{pg::PgConnection, r2d2::ConnectionManager};
use http::{request::Parts, StatusCode};
use leptos_axum::extract;
use std::ops::Deref;
use std::sync::Arc;
use tower_sessions_redis_store::fred::clients::Client as RedisClient;
use tower_sessions_redis_store::fred::clients::Pool as RedisPool;
use tower_sessions_redis_store::fred::prelude::*;
use tower_sessions_redis_store::fred::types::config::Config as RedisConfig;
/// Shared state of the backend. Contains the global server configuration,
/// a connection pool to the `PostgreSQL` database, and a connection pool to Redis.
#[derive(Clone)]
pub struct BackendState(Arc<BackendStateInner>);
impl Deref for BackendState {
type Target = Arc<BackendStateInner>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl BackendState {
/// Creates a new `BackendState` from the provided configuration
pub async fn from_config(config: Config) -> BackendResult<Self> {
let inner = BackendStateInner::from_config(config).await?;
Ok(Self(Arc::new(inner)))
}
/// Extracts the `BackendState` from the request parts.
pub async fn get() -> Result<Self, BackendError> {
extract::<Self>().await.map_err(|e| {
BackendError::InternalError(format!("Failed to extract BackendState: {e}"))
})
}
}
impl<S> FromRequestParts<S> for BackendState
where
S: Send + Sync,
{
// TODO(?) Change type to BackendError
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<BackendState>()
.cloned()
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Backend state not found"))
}
}
/// Inner state of the backend, containing the configuration and connection pools
pub struct BackendStateInner {
pub config: Config,
pub pg_pool: PgPool,
pub redis_pool: RedisPool,
}
impl BackendStateInner {
pub async fn from_config(config: Config) -> BackendResult<Self> {
let pg_manager = ConnectionManager::<PgConnection>::new(config.database_url());
let pg_pool = PgPool::builder().build(pg_manager).map_err(|e| {
BackendError::InternalError(format!("Failed to create PostgreSQL pool: {e}"))
})?;
let redis_config = RedisConfig::from_url(&config.redis_url)
.map_err(|e| BackendError::InternalError(format!("Failed to parse Redis URL: {e}")))?;
let redis_pool = RedisPool::new(redis_config, None, None, None, 1).map_err(|e| {
BackendError::InternalError(format!("Failed to create Redis pool: {e}"))
})?;
redis_pool.connect();
redis_pool
.wait_for_connect()
.await
.map_err(|e| BackendError::InternalError(format!("Failed to connect to Redis: {e}")))?;
Ok(Self {
config,
pg_pool,
redis_pool,
})
}
pub fn get_db_conn(&self) -> BackendResult<PgPooledConn> {
self.pg_pool.get().map_err(|e| {
BackendError::InternalError(format!("Failed to get database connection: {e}"))
})
}
pub fn get_redis_conn(&self) -> RedisClient {
self.redis_pool.next().clone()
}
}

99
src/util/config.rs Normal file
View File

@ -0,0 +1,99 @@
use clap::Parser;
#[derive(Parser, Clone)]
#[command(version, about)]
pub struct Config {
#[clap(long, env, default_value = "assets/audio")]
/// The path to store audio files
pub audio_path: String,
#[clap(long, env, default_value = "assets/images")]
/// The path to store image files (album covers, profile pictures, playlist covers, etc.)
pub image_path: String,
#[clap(long, env)]
/// Whether to disable user signup
pub disable_signup: bool,
#[clap(long, env, required = true, conflicts_with = "database_config")]
/// The URL for the database connection.
database_url: Option<String>,
#[command(flatten)]
postgres_config: Option<PostgresConfig>,
#[clap(long, env)]
/// The URL for the Redis connection.
pub redis_url: String,
}
#[derive(clap::Args, Clone)]
#[group(id = "database_config", conflicts_with = "database_url")]
pub struct PostgresConfig {
#[arg(id = "POSTGRES_HOST", long = "postgres-host", env)]
/// The hostname of the database
pub host: String,
#[arg(id = "POSTGRES_USER", long = "postgres-user", env)]
/// The user for the database
pub user: Option<String>,
#[arg(
id = "POSTGRES_PASSWORD",
long = "postgres-password",
env,
requires = "POSTGRES_USER"
)]
/// The password for the database user
pub password: Option<String>,
#[arg(id = "POSTGRES_PORT", long = "postgres-port", env)]
/// The port for the database
pub port: Option<u16>,
#[arg(id = "POSTGRES_DB", long = "postgres-db", env)]
/// The name of the database
pub db: Option<String>,
}
impl PostgresConfig {
pub fn to_url(&self) -> String {
let mut url = "postgres://".to_string();
if let Some(user) = &self.user {
url.push_str(user);
if let Some(password) = &self.password {
url.push_str(&format!(":{password}"));
}
url.push('@');
}
url.push_str(&self.host);
if let Some(port) = self.port {
url.push_str(&format!(":{port}"));
}
if let Some(db) = &self.db {
url.push_str(&format!("/{db}"));
}
url
}
}
impl Config {
pub fn database_url(&self) -> String {
match &self.database_url {
Some(url) => url.clone(),
None => match &self.postgres_config {
Some(config) => config.to_url(),
None => panic!("Both database_url and postgres_config are missing. This error shouldn't be possible."),
},
}
}
}
pub fn load_config() -> Result<Config, clap::error::Error> {
Config::try_parse()
}

View File

@ -1,103 +1,16 @@
use leptos::logging::log;
use diesel::{pg::PgConnection, r2d2::ConnectionManager, r2d2::Pool, r2d2::PooledConnection};
use lazy_static::lazy_static;
use std::env;
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
type PgPool = Pool<ConnectionManager<PgConnection>>;
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub type PgPooledConn = PooledConnection<ConnectionManager<PgConnection>>;
// Keep a global instance of the pool
lazy_static! {
static ref DB_POOL: PgPool = init_db_pool();
}
/// Initialize the database 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").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(':');
log_url.push(':');
url.push_str(&password);
log_url.push_str("********");
}
url.push('@');
log_url.push('@');
}
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(':');
url.push_str(&port);
log_url.push(':');
log_url.push_str(&port);
}
if let Ok(dbname) = env::var("POSTGRES_DB") {
url.push('/');
url.push_str(&dbname);
log_url.push('/');
log_url.push_str(&dbname);
}
log!("Connecting to database: {}", log_url);
url
});
let manager = ConnectionManager::<PgConnection>::new(database_url);
PgPool::builder()
.build(manager)
.expect("Failed to create pool.")
}
/// Get a pooled connection to the database
///
/// Will panic if there is an error getting a connection from the pool.
///
/// # Returns
/// A pooled connection to the database
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
pub fn migrate(db_conn: &mut PgPooledConn) {
db_conn
.run_pending_migrations(DB_MIGRATIONS)
.expect("Could not run database migrations");
}

328
src/util/error.rs Normal file
View File

@ -0,0 +1,328 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use server_fn::codec::JsonEncoding;
use std::fmt::{Display, Formatter, Result as FmtResult};
use std::panic::Location;
use thiserror::Error;
/// A location in the source code
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ErrorLocation {
pub file: String,
pub line: u32,
}
impl ErrorLocation {
/// Creates a new `ErrorLocation` with the file and line number of the caller.
#[track_caller]
pub fn new() -> Self {
let location = Location::caller();
ErrorLocation {
file: location.file().to_string(),
line: location.line(),
}
}
}
impl Display for ErrorLocation {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}:{}", self.file, self.line)
}
}
pub type BackendResult<T> = Result<T, BackendError>;
/// A custom error type for backend errors
/// Contains the error, the location where it was created, and context added to the error.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BackendError {
/// The error type and data
error: BackendErrorType,
/// The location where the error was created
from: ErrorLocation,
/// Context added to the error, and location where it was added
context: Vec<(ErrorLocation, String)>,
}
impl BackendError {
/// Creates a new `BackendError` at this location with the given error type
#[track_caller]
fn new(error: BackendErrorType) -> Self {
BackendError {
error,
from: ErrorLocation::new(),
context: Vec::new(),
}
}
/// Createa new `BackendError` from a `ServerFnErrorErr`
#[track_caller]
#[allow(non_snake_case)]
pub fn ServerFnError(error: ServerFnErrorErr) -> Self {
BackendError::new(BackendErrorType::ServerFn(error))
}
/// Creates a new `BackendError` from a Diesel error
#[track_caller]
#[allow(non_snake_case)]
#[cfg(feature = "ssr")]
pub fn DieselError(error: diesel::result::Error) -> Self {
BackendError::new(BackendErrorType::Diesel(error.to_string()))
}
/// Creates a new `BackendError` from an I/O error
#[track_caller]
#[allow(non_snake_case)]
pub fn IoError(error: std::io::Error) -> Self {
BackendError::new(BackendErrorType::Io(error.to_string()))
}
/// Creates a new `BackendError` from an authentication error
#[track_caller]
#[allow(non_snake_case)]
pub fn AuthError(error: AuthError) -> Self {
BackendError::new(BackendErrorType::Auth(error))
}
/// Creates a new `BackendError` from an input error
#[track_caller]
#[allow(non_snake_case)]
pub fn InputError(error: InputError) -> Self {
BackendError::new(BackendErrorType::Input(error))
}
/// Creates a new `BackendError` from an access error
#[track_caller]
#[allow(non_snake_case)]
pub fn AccessError(error: AccessError) -> Self {
BackendError::new(BackendErrorType::Access(error))
}
/// Creates a new `BackendError` for a generic internal server error
#[track_caller]
#[allow(non_snake_case)]
pub fn InternalError(message: impl Into<String>) -> Self {
BackendError::new(BackendErrorType::Internal(message.into()))
}
/// Adds a context message to the error
#[track_caller]
#[allow(non_snake_case)]
pub fn context(mut self, context: impl Into<String>) -> Self {
self.context.push((ErrorLocation::new(), context.into()));
self
}
/// Converts the error to a view component for display
pub fn to_component(mut self) -> impl IntoView {
use leptos_icons::*;
leptos::logging::error!("{}", self);
let error = self.error.to_string();
self.context.reverse(); // Reverse the context to show the most recent first
// Get the last context, if any, or the error message itself
let message = self
.context
.first()
.cloned()
.map(|(_location, message)| message)
.unwrap_or_else(|| error.clone());
view! {
<div class="text-red-800">
<div class="grid grid-cols-[max-content_1fr] gap-1">
<Icon icon={icondata::BiErrorSolid} {..} class="self-center" />
<h1 class="self-center">{message}</h1>
</div>
{(!self.context.is_empty()).then(|| {
view! {
<details>
<summary class="cursor-pointer">{error.clone()}</summary>
<ul class="text-red-900">
{self.context.into_iter().map(|(location, message)| view! {
<li>{format!("{location}: {message}")}</li>
}).collect::<Vec<_>>()}
<li>{format!("{}: {}", self.from, error)}</li>
</ul>
</details>
}
})}
</div>
}
}
}
impl Display for BackendError {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
// Write the error type and its context
write!(f, "BackendError: {}", self.error)?;
if !self.context.is_empty() {
write!(f, "\nContext:")?;
for (location, message) in self.context.iter().rev() {
write!(f, "\n - {location}: {message}")?;
}
write!(f, "\n - {}: {}", self.from, self.error)?;
} else {
write!(f, "\nFrom: {}", self.from)?;
}
Ok(())
}
}
impl std::error::Error for BackendError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
impl<E: Into<BackendErrorType>> From<E> for BackendError {
fn from(error: E) -> Self {
BackendError::new(error.into())
}
}
impl From<ServerFnErrorErr> for BackendError {
fn from(error: ServerFnErrorErr) -> Self {
BackendError::ServerFnError(error)
}
}
pub trait Contextualize<R> {
/// Add context to the `Result` if it is an `Err`.
#[track_caller]
fn context(self, context: impl Into<String>) -> R;
}
impl<T, E: Into<BackendError>> Contextualize<Result<T, BackendError>> for Result<T, E> {
#[track_caller]
fn context(self, context: impl Into<String>) -> Result<T, BackendError> {
match self {
Ok(value) => Ok(value),
Err(e) => {
// Convert the error into BackendError and add context
let backend_error = e.into().context(context);
Err(backend_error)
}
}
}
}
impl<E: Into<BackendError>> Contextualize<BackendError> for E {
#[track_caller]
fn context(self, context: impl Into<String>) -> BackendError {
self.into().context(context)
}
}
/// The inner error type for `BackendError`
#[derive(Serialize, Deserialize, Debug, Clone, Error)]
enum BackendErrorType {
#[error("Server function error: {0}")]
ServerFn(ServerFnErrorErr),
// Using string to represent Diesel errors,
// because Diesel's Error type is not `Serializable`.
#[error("Database error: {0}")]
Diesel(String),
/// Using `String` to represent I/O errors,
/// because the `std::io::Error` type is not `Serializable`.
#[error("I/O error: {0}")]
Io(String),
#[error("Authentication error: {0}")]
Auth(AuthError),
#[error("Input error: {0}")]
Input(InputError),
#[error("Access error: {0}")]
Access(AccessError),
#[error("Internal server error: {0}")]
Internal(String),
}
impl FromServerFnError for BackendError {
type Encoder = JsonEncoding;
#[track_caller]
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
BackendError::new(BackendErrorType::ServerFn(value))
}
}
#[cfg(feature = "ssr")]
impl From<diesel::result::Error> for BackendError {
#[track_caller]
fn from(err: diesel::result::Error) -> Self {
BackendError::new(BackendErrorType::Diesel(format!("{err}")))
}
}
impl From<std::io::Error> for BackendError {
#[track_caller]
fn from(err: std::io::Error) -> Self {
BackendError::new(BackendErrorType::Io(format!("{err}")))
}
}
impl From<AuthError> for BackendError {
#[track_caller]
fn from(err: AuthError) -> Self {
BackendError::new(BackendErrorType::Auth(err))
}
}
impl From<InputError> for BackendError {
#[track_caller]
fn from(err: InputError) -> Self {
BackendError::new(BackendErrorType::Input(err))
}
}
impl From<AccessError> for BackendError {
#[track_caller]
fn from(err: AccessError) -> Self {
BackendError::new(BackendErrorType::Access(err))
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Error)]
pub enum AuthError {
#[error("Unauthorized")]
Unauthorized,
#[error("Action requires admin privileges")]
AdminRequired,
#[error("Invalid credentials provided")]
InvalidCredentials,
#[error("Signup is disabled")]
SignupDisabled,
#[error("Error during authentication: {0}")]
AuthError(String),
}
#[derive(Serialize, Deserialize, Debug, Clone, Error)]
pub enum InputError {
#[error("Missing required field {0}")]
MissingField(String),
#[error("Error reading field: {0}")]
FieldReadError(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
}
#[derive(Serialize, Deserialize, Debug, Clone, Error)]
pub enum AccessError {
#[error("Not found")]
NotFound,
#[error("Not found or unauthorized")]
NotFoundOrUnauthorized,
}

View File

@ -1,14 +1,10 @@
use crate::util::error::*;
use multer::Field;
use server_fn::{error::NoCustomError, ServerFnError};
/// Extract the text from a multipart field
pub async fn extract_field(field: Field<'static>) -> Result<String, ServerFnError> {
let field = match field.text().await {
Ok(field) => field,
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
"Error reading field: {e}"
)))?,
};
Ok(field)
pub async fn extract_field(field: Field<'static>) -> BackendResult<String> {
field
.text()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")).into())
}

View File

@ -7,11 +7,13 @@ cfg_if! {
pub mod fileserv;
pub mod database;
pub mod auth_backend;
pub mod redis;
pub mod extract_field;
pub mod config;
pub mod backend_state;
}
}
pub mod error;
pub mod img_fallback;
pub mod serverfn_client;
pub mod state;

View File

@ -1,33 +0,0 @@
use leptos::logging::log;
use lazy_static::lazy_static;
use std::env;
use tower_sessions_redis_store::fred;
use tower_sessions_redis_store::fred::prelude::*;
lazy_static! {
static ref REDIS_POOL: fred::clients::Pool = init_redis_pool();
}
fn init_redis_pool() -> fred::clients::Pool {
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
let redis_config = fred::types::config::Config::from_url(&redis_url)
.unwrap_or_else(|_| panic!("Unable to parse Redis URL: {redis_url}"));
let redis_pool = fred::clients::Pool::new(redis_config, None, None, None, 1)
.expect("Unable to create Redis pool");
log!("Connecting to Redis: {redis_url}");
redis_pool.connect();
redis_pool
}
pub fn get_redis_pool() -> fred::clients::Pool {
REDIS_POOL.clone()
}
pub fn get_redis_conn() -> fred::clients::Client {
REDIS_POOL.next().clone()
}

View File

@ -4,11 +4,11 @@ cfg_if! {
if #[cfg(feature = "reqwest_api")] {
pub type Client = CustomClient;
use reqwest;
use std::future::Future;
use server_fn::ServerFnError;
use futures::TryFutureExt;
use once_cell::sync::Lazy;
use leptos::prelude::{FromServerFnError, ServerFnErrorErr};
use server_fn::{Bytes, client::get_server_url, error::IntoAppError};
use futures::{TryFutureExt, StreamExt, SinkExt};
/// Static global client
static CLIENT: Lazy<reqwest::Client> = Lazy::new(|| reqwest::Client::builder()
@ -21,17 +21,73 @@ cfg_if! {
/// but with the cookies enabled to allow for authentication.
pub struct CustomClient;
impl <CustErr> server_fn::client::Client<CustErr> for CustomClient {
impl <
Error: FromServerFnError,
InputStreamError: FromServerFnError,
OutputStreamError: FromServerFnError,
> server_fn::client::Client<Error, InputStreamError, OutputStreamError> for CustomClient {
type Request = reqwest::Request;
type Response = reqwest::Response;
fn send(
req: Self::Request
) -> impl Future<Output = Result<Self::Response, ServerFnError<CustErr>>>
) -> impl Future<Output = Result<Self::Response, Error>>
+ Send {
CLIENT
.execute(req)
.map_err(|e| ServerFnError::Request(e.to_string()))
CLIENT.execute(req).map_err(|e| {
ServerFnErrorErr::Request(e.to_string()).into_app_error()
})
}
async fn open_websocket(
path: &str,
) -> Result<(
impl futures::Stream<Item = Result<Bytes, Bytes>> + Send + 'static,
impl futures::Sink<Bytes> + Send + 'static,
),
Error,
> {
let mut websocket_server_url = get_server_url().to_string();
if let Some(postfix) = websocket_server_url.strip_prefix("http://") {
websocket_server_url = format!("ws://{postfix}");
} else if let Some(postfix) = websocket_server_url.strip_prefix("https://") {
websocket_server_url = format!("wss://{postfix}");
}
let url = format!("{websocket_server_url}{path}");
let (ws_stream, _) =
tokio_tungstenite::connect_async(url).await.map_err(|e| {
Error::from_server_fn_error(ServerFnErrorErr::Request(
e.to_string(),
))
})?;
let (write, read) = ws_stream.split();
Ok((
read.map(|msg| match msg {
Ok(msg) => Ok(msg.into_data()),
Err(e) => Err(OutputStreamError::from_server_fn_error(
ServerFnErrorErr::Request(e.to_string()),
)
.ser()),
}),
write.with(|msg: Bytes| async move {
Ok::<
tokio_tungstenite::tungstenite::Message,
tokio_tungstenite::tungstenite::Error,
>(
tokio_tungstenite::tungstenite::Message::Binary(msg)
)
}),
))
}
fn spawn(future: impl Future<Output = ()> + Send + 'static) {
tokio::spawn(future);
}
}
} else {

View File

@ -6,6 +6,7 @@ use crate::api::playlists::get_playlists;
use crate::models::backend;
use crate::models::backend::User;
use crate::models::frontend::PlayStatus;
use crate::util::error::*;
/// Global front-end state
/// Contains anything frequently needed across multiple components
@ -22,7 +23,7 @@ pub struct GlobalState {
pub play_status: RwSignal<PlayStatus>,
/// A resource that fetches the playlists
pub playlists: Resource<Result<Vec<backend::Playlist>, ServerFnError>>,
pub playlists: Resource<BackendResult<Vec<backend::Playlist>>>,
}
impl GlobalState {
@ -60,7 +61,7 @@ impl GlobalState {
expect_context::<Self>().play_status
}
pub fn playlists() -> Resource<Result<Vec<backend::Playlist>, ServerFnError>> {
pub fn playlists() -> Resource<BackendResult<Vec<backend::Playlist>>> {
expect_context::<Self>().playlists
}
}