228-create-unified-config-system #229
542
Cargo.lock
generated
542
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
25
Cargo.toml
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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) {
|
||||
|
148
src/api/auth.rs
148
src/api/auth.rs
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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(())
|
||||
}
|
||||
|
108
src/api/users.rs
108
src/api/users.rs
@ -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() {
|
||||
|
@ -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>
|
||||
|
58
src/main.rs
58
src/main.rs
@ -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);
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
})}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
104
src/util/backend_state.rs
Normal 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
99
src/util/config.rs
Normal 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()
|
||||
}
|
@ -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
328
src/util/error.rs
Normal 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,
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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()
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user