From 161ea5f9c20daf38b079dfa0b3fc0fb8dd568655 Mon Sep 17 00:00:00 2001 From: Ethan Girouard Date: Fri, 10 May 2024 15:20:57 -0400 Subject: [PATCH] Implement file upload backend --- src/lib.rs | 1 + src/upload.rs | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 src/upload.rs diff --git a/src/lib.rs b/src/lib.rs index e26e2c5..e7949e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod users; pub mod search; pub mod fileserv; pub mod error_template; +pub mod upload; pub mod util; use cfg_if::cfg_if; diff --git a/src/upload.rs b/src/upload.rs new file mode 100644 index 0000000..db3a3e5 --- /dev/null +++ b/src/upload.rs @@ -0,0 +1,274 @@ +use leptos::*; +use server_fn::{codec::{MultipartData, MultipartFormData}, error::NoCustomError}; +use time::Date; + +use cfg_if::cfg_if; + +cfg_if! { + if #[cfg(feature = "ssr")] { + use multer::Field; + use crate::database::get_db_conn; + use diesel::prelude::*; + use log::*; + } +} + +/// Extract the text from a multipart field +#[cfg(feature = "ssr")] +async fn extract_field(field: Field<'static>) -> Result { + let field = match field.text().await { + Ok(field) => field, + Err(e) => Err(ServerFnError::::ServerError(format!("Error reading field: {}", e)))?, + }; + + Ok(field) +} + +/// 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, ServerFnError> { + use crate::models::Artist; + use diesel::result::Error::NotFound; + + // Extract the artist id from the field + match artist_ids.text().await { + Ok(artist_ids) => { + let artist_ids = artist_ids.split(','); + + artist_ids.map(|artist_id| { + // Parse the artist id as an integer + if let Ok(artist_id) = artist_id.parse::() { + // Check if the artist exists + let db_con = &mut get_db_conn(); + let artist = crate::schema::artists::dsl::artists.find(artist_id).first::(db_con); + + match artist { + Ok(_) => Ok(artist_id), + Err(NotFound) => Err(ServerFnError:::: + ServerError("Artist does not exist".to_string())), + Err(e) => Err(ServerFnError:::: + ServerError(format!("Error finding artist id: {}", e))), + } + } else { + Err(ServerFnError::::ServerError("Error parsing artist id".to_string())) + } + }).collect() + }, + + Err(e) => Err(ServerFnError::::ServerError(format!("Error reading artist id: {}", e))), + } +} + +/// 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 { + use crate::models::Album; + use diesel::result::Error::NotFound; + + // Extract the album id from the field + match album_id.text().await { + Ok(album_id) => { + // Parse the album id as an integer + if let Ok(album_id) = album_id.parse::() { + // Check if the album exists + let db_con = &mut get_db_conn(); + let album = crate::schema::albums::dsl::albums.find(album_id).first::(db_con); + + match album { + Ok(_) => Ok(album_id), + Err(NotFound) => Err(ServerFnError:::: + ServerError("Album does not exist".to_string())), + Err(e) => Err(ServerFnError:::: + ServerError(format!("Error finding album id: {}", e))), + } + } else { + Err(ServerFnError::::ServerError("Error parsing album id".to_string())) + } + }, + Err(e) => Err(ServerFnError::::ServerError(format!("Error reading album id: {}", e))), + } +} + +/// 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 { + match track_number.text().await { + Ok(track_number) => { + if let Ok(track_number) = track_number.parse::() { + if track_number < 0 { + return Err(ServerFnError:::: + ServerError("Track number must be positive or 0".to_string())); + } else { + Ok(track_number) + } + } else { + return Err(ServerFnError::::ServerError("Error parsing track number".to_string())); + } + }, + Err(e) => Err(ServerFnError::::ServerError(format!("Error reading track number: {}", e)))?, + } +} + +/// 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 { + match release_date.text().await { + Ok(release_date) => { + let date_format = time::macros::format_description!("[year]-[month]-[day]"); + let release_date = Date::parse(&release_date.trim(), date_format); + + match release_date { + Ok(release_date) => Ok(release_date), + Err(_) => Err(ServerFnError::::ServerError("Invalid release date".to_string())), + } + }, + Err(e) => Err(ServerFnError::::ServerError(format!("Error reading release date: {}", e))), + } +} + +/// Handle the file upload form +#[server(input = MultipartFormData, endpoint = "/upload")] +pub async fn upload(data: MultipartData) -> Result<(), ServerFnError> { + // 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 mut title = None; + let mut artist_ids = None; + let mut album_id = None; + let mut track = None; + let mut release_date = None; + let mut file_name = None; + let mut duration = None; + + // Fetch the fields from the form data + while let Ok(Some(mut field)) = data.next_field().await { + let name = field.name().unwrap_or_default().to_string(); + + println!("Field name: {}", name); + + match name.as_str() { + "title" => { title = Some(extract_field(field).await?); }, + "artist_ids" => { artist_ids = Some(validate_artist_ids(field).await?); }, + "album_id" => { album_id = Some(validate_album_id(field).await?); }, + "track_number" => { track = Some(validate_track_number(field).await?); }, + "release_date" => { release_date = Some(validate_release_date(field).await?); }, + "file" => { + use symphonia::core::codecs::CODEC_TYPE_MP3; + use crate::util::audio::extract_metadata; + use std::fs::OpenOptions; + use std::io::{Seek, Write}; + + // Some logging is done here where there is high potential for bugs / failures, + // or behavior that we may wish to change in the future + + // Create file name + let title = title.clone().ok_or(ServerFnError:::: + ServerError("Title field required and must precede file field".to_string()))?; + + let clean_title = title.replace(" ", "_").replace("/", "_"); + let date_format = time::macros::format_description!("[year]-[month]-[day]_[hour]:[minute]:[second]"); + let date_str = time::OffsetDateTime::now_utc().format(date_format).unwrap_or_default(); + let upload_path = format!("assets/audio/upload-{}_{}.mp3", date_str, clean_title); + file_name = Some(format!("upload-{}_{}.mp3", date_str, clean_title)); + + debug!("Saving uploaded file {}", upload_path); + + // Save file to disk + // Use these open options to create the file, write to it, then read from it + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(upload_path.clone())?; + + while let Some(chunk) = field.chunk().await? { + file.write(&chunk)?; + } + + file.flush()?; + + // Rewind the file so the duration can be measured + file.rewind()?; + + // 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::::ServerError(msg) + })?; + + if file_codec != CODEC_TYPE_MP3 { + let msg = format!("Invalid uploaded audio file codec: {}", file_codec); + warn!("{}", msg); + return Err(ServerFnError::::ServerError(msg)); + } + + duration = Some(file_duration); + }, + _ => { + warn!("Unknown file upload field: {}", name); + } + } + } + + // Unwrap mandatory fields + let title = title.ok_or(ServerFnError::::ServerError("Missing title".to_string()))?; + let artist_ids = artist_ids.unwrap_or(vec![]); + let file_name = file_name.ok_or(ServerFnError::::ServerError("Missing file".to_string()))?; + let duration = duration.ok_or(ServerFnError::::ServerError("Missing duration".to_string()))?; + let duration = i32::try_from(duration).map_err(|e| ServerFnError:::: + ServerError(format!("Error converting duration to i32: {}", e)))?; + + // Create the song + use crate::models::Song; + let song = Song { + id: None, + title, + album_id, + track, + duration, + release_date, + storage_path: file_name, + image_path: None, + }; + + // Save the song to the database + let db_con = &mut get_db_conn(); + let song = song.insert_into(crate::schema::songs::table) + .get_result::(db_con) + .map_err(|e| { + let msg = format!("Error saving song to database: {}", e); + warn!("{}", msg); + ServerFnError::::ServerError(msg) + })?; + + // Save the song's artists to the database + let song_id = song.id.ok_or_else(|| { + let msg = "Error saving song to database: song id not found after insertion".to_string(); + warn!("{}", msg); + ServerFnError::::ServerError(msg) + })?; + + use crate::schema::song_artists; + use diesel::ExpressionMethods; + + let artist_ids = artist_ids.into_iter().map(|artist_id| { + (song_artists::song_id.eq(song_id), song_artists::artist_id.eq(artist_id)) + }).collect::>(); + + 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::::ServerError(msg) + })?; + + Ok(()) +}