diff --git a/crates/lib/mysql_queries/src/queries/media_files/list_media_files_for_user.rs b/crates/lib/mysql_queries/src/queries/media_files/list_media_files_for_user.rs new file mode 100644 index 0000000000..1c7bcdee62 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/media_files/list_media_files_for_user.rs @@ -0,0 +1,203 @@ +use chrono::{DateTime, Utc}; +use sqlx::{MySql, MySqlPool, QueryBuilder}; + +use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; +use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType; +use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use errors::AnyhowResult; +use tokens::tokens::media_files::MediaFileToken; + +pub struct MediaFileListPage { + pub records: Vec, + + pub sort_ascending: bool, + + /// ID of the first record. + pub first_id: Option, + + /// ID of the last record. + pub last_id: Option, +} + +pub struct MediaFileListItem { + pub token: MediaFileToken, + + pub origin_category: MediaFileOriginCategory, + pub origin_product_category: MediaFileOriginProductCategory, + pub maybe_origin_model_type: Option, + pub maybe_origin_model_token: Option, + + pub media_type: MediaFileType, + pub public_bucket_directory_hash: String, + pub maybe_public_bucket_prefix: Option, + pub maybe_public_bucket_extension: Option, + + pub creator_set_visibility: Visibility, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Clone, Copy)] +pub enum ViewAs { + Author, + Moderator, + AnotherUser, +} + +pub struct ListMediaFileForUserArgs<'a> { + pub username: &'a str, + pub limit: usize, + pub maybe_filter_media_type: Option, + pub maybe_offset: Option, + pub cursor_is_reversed: bool, + pub view_as: ViewAs, + pub mysql_pool: &'a MySqlPool, +} + +pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> AnyhowResult { + + let mut query = query_builder( + args.maybe_filter_media_type, + args.username, + args.limit, + args.maybe_offset, + args.cursor_is_reversed, + args.view_as, + ); + + let query = query.build_query_as::(); + + let results = query.fetch_all(args.mysql_pool).await?; + + let first_id = results.first() + .map(|raw_result| raw_result.id); + + let last_id = results.last() + .map(|raw_result| raw_result.id); + + let results = results.into_iter() + .map(|record| { + MediaFileListItem { + token: record.token, + origin_category: record.origin_category, + origin_product_category: record.origin_product_category, + maybe_origin_model_type: record.maybe_origin_model_type, + maybe_origin_model_token: record.maybe_origin_model_token, + media_type: record.media_type, + public_bucket_directory_hash: record.public_bucket_directory_hash, + maybe_public_bucket_prefix: record.maybe_public_bucket_prefix, + maybe_public_bucket_extension: record.maybe_public_bucket_extension, + creator_set_visibility: record.creator_set_visibility, + created_at: record.created_at, + updated_at: record.updated_at, + } + }) + .collect::>(); + + Ok(MediaFileListPage { + records: results, + sort_ascending: !args.cursor_is_reversed, + first_id, + last_id, + }) +} + +fn query_builder<'a>( + maybe_filter_media_type: Option, + username: &'a str, + limit: usize, + maybe_offset: Option, + cursor_is_reversed: bool, + view_as: ViewAs, +) -> QueryBuilder<'a, MySql> { + + // NB: Query cannot be statically checked by sqlx + let mut query_builder: QueryBuilder = QueryBuilder::new( + r#" +SELECT + m.id, + m.token as `token: tokens::tokens::media_files::MediaFileToken`, + + m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`, + m.origin_product_category as `origin_product_category: enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory`, + + m.maybe_origin_model_type, + m.maybe_origin_model_token, + + m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`, + + m.public_bucket_directory_hash, + m.maybe_public_bucket_prefix, + m.maybe_public_bucket_extension, + + m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`, + m.created_at, + m.updated_at + +FROM media_files AS m +LEFT OUTER JOIN users AS u + ON m.maybe_creator_user_token = u.token + +WHERE u.username = ? +AND m.user_deleted_at IS NULL +AND m.mod_deleted_at IS NULL + "# + ); + + query_builder.push_bind(username); + + if let Some(media_type) = maybe_filter_media_type { + query_builder.push(" AND m.media_file_type = ? "); + query_builder.push_bind(media_type); + } + + match view_as { + ViewAs::Author => {} + ViewAs::Moderator => {} + ViewAs::AnotherUser => { + query_builder.push(" AND m.creator_set_visibility = 'public' "); + } + } + + if let Some(offset) = maybe_offset { + if cursor_is_reversed { + query_builder.push(format!(" AND m.id < {offset} ")); + } else { + query_builder.push(format!(" AND m.id > {offset} ")); + } + } + + if cursor_is_reversed { + query_builder.push(" ORDER BY m.id ASC "); + } else { + query_builder.push(" ORDER BY m.id DESC "); + } + + query_builder.push(format!(" LIMIT {limit} ")); + + query_builder +} + +#[derive(sqlx::FromRow)] +struct MediaFileListItemInternal { + id: i64, + token: MediaFileToken, + + origin_category: MediaFileOriginCategory, + origin_product_category: MediaFileOriginProductCategory, + maybe_origin_model_type: Option, + maybe_origin_model_token: Option, + + media_type: MediaFileType, + public_bucket_directory_hash: String, + maybe_public_bucket_prefix: Option, + maybe_public_bucket_extension: Option, + + creator_set_visibility: Visibility, + + created_at: DateTime, + updated_at: DateTime, +} diff --git a/crates/lib/mysql_queries/src/queries/media_files/mod.rs b/crates/lib/mysql_queries/src/queries/media_files/mod.rs index 3225ced58e..619f018ce5 100644 --- a/crates/lib/mysql_queries/src/queries/media_files/mod.rs +++ b/crates/lib/mysql_queries/src/queries/media_files/mod.rs @@ -1,7 +1,8 @@ -pub mod get_media_file; -pub mod insert_media_file_from_face_animation; -pub mod insert_media_file_from_zero_shot_tts; -pub mod insert_media_file_from_cli_tool; -pub mod insert_media_file_from_voice_conversion; pub mod delete_media_file; +pub mod get_media_file; +pub mod insert_media_file_from_cli_tool; +pub mod insert_media_file_from_face_animation; +pub mod insert_media_file_from_voice_conversion; +pub mod insert_media_file_from_zero_shot_tts; +pub mod list_media_files_for_user; pub mod update_media_file; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/list_media_files_for_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/list_media_files_for_user.rs new file mode 100644 index 0000000000..35ae3f074c --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/list_media_files_for_user.rs @@ -0,0 +1,215 @@ +use std::sync::Arc; + +use actix_web::{HttpMessage, HttpRequest, HttpResponse, web}; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::{Path, Query}; +use chrono::{DateTime, Utc}; +use log::{info, warn}; + +use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory; +use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType; +use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use mysql_queries::queries::media_files::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs, ViewAs}; +use tokens::tokens::media_files::MediaFileToken; + +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::server_state::ServerState; + +#[derive(Deserialize)] +pub struct PathInfo { + username: String, +} + +#[derive(Deserialize)] +pub struct QueryParams { + pub sort_ascending: Option, + pub limit: Option, + pub cursor: Option, + pub cursor_is_reversed: Option, + pub filter_media_type: Option, +} + +#[derive(Serialize)] +pub struct SuccessResponse { + pub success: bool, + pub results: Vec, + pub cursor_next: Option, + pub cursor_previous: Option, +} + +#[derive(Serialize)] +pub struct MediaFileListItem { + pub token: MediaFileToken, + + pub origin_category: MediaFileOriginCategory, + pub origin_product_category: MediaFileOriginProductCategory, + pub maybe_origin_model_type: Option, + pub maybe_origin_model_token: Option, + + pub media_type: MediaFileType, + pub public_bucket_directory_hash: String, + pub maybe_public_bucket_prefix: Option, + pub maybe_public_bucket_extension: Option, + + pub creator_set_visibility: Visibility, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug)] +pub enum ErrorResponse { + ServerError, +} + +impl ResponseError for ErrorResponse { + fn status_code(&self) -> StatusCode { + match *self { + ErrorResponse::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + ErrorResponse::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +impl std::fmt::Display for ErrorResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +pub async fn list_media_files_for_user_handler( + http_request: HttpRequest, + path: Path, + query: Query, + server_state: web::Data> +) -> Result +{ + info!("Fetching inference results for user: {}", &path.username); + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session(&http_request, &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + ErrorResponse::ServerError + })?; + + let mut is_author = false; + let mut is_mod = false; + + match maybe_user_session { + None => {}, + Some(session) => { + is_author = session.username == path.username; + is_mod = session.can_ban_users; + }, + }; + + // TODO(bt,2023-12-04): Enforce real maximums and defaults + let limit = query.limit.unwrap_or(25); + + let sort_ascending = query.sort_ascending.unwrap_or(false); + let cursor_is_reversed = query.cursor_is_reversed.unwrap_or(false); + + let cursor = if let Some(cursor) = query.cursor.as_deref() { + let cursor = server_state.sort_key_crypto.decrypt_id(cursor) + .map_err(|e| { + warn!("crypto error: {:?}", e); + ErrorResponse::ServerError + })?; + Some(cursor as usize) + } else { + None + }; + + let view_as = if is_author { + ViewAs::Author + } else if is_mod { + ViewAs::Moderator + } else { + ViewAs::AnotherUser + }; + + let query_results = list_media_files_for_user(ListMediaFileForUserArgs { + username: &path.username, + limit, + maybe_filter_media_type: query.filter_media_type, + maybe_offset: cursor, + cursor_is_reversed, + view_as, + mysql_pool: &server_state.mysql_pool, + }).await; + + + let results_page = match query_results { + Ok(results) => results, + Err(e) => { + warn!("Query error: {:?}", e); + return Err(ErrorResponse::ServerError); + } + }; + + let cursor_next = if let Some(id) = results_page.last_id { + let cursor = server_state.sort_key_crypto.encrypt_id(id as u64) + .map_err(|e| { + warn!("crypto error: {:?}", e); + ErrorResponse::ServerError + })?; + Some(cursor) + } else { + None + }; + + let cursor_previous = if let Some(id) = results_page.first_id { + let cursor = server_state.sort_key_crypto.encrypt_id(id as u64) + .map_err(|e| { + warn!("crypto error: {:?}", e); + ErrorResponse::ServerError + })?; + Some(cursor) + } else { + None + }; + + let results = results_page.records.into_iter() + .map(|record| MediaFileListItem { + token: record.token, + origin_category: record.origin_category, + origin_product_category: record.origin_product_category, + maybe_origin_model_type: record.maybe_origin_model_type, + maybe_origin_model_token: record.maybe_origin_model_token, + media_type: record.media_type, + public_bucket_directory_hash: record.public_bucket_directory_hash, + maybe_public_bucket_prefix: record.maybe_public_bucket_prefix, + maybe_public_bucket_extension: record.maybe_public_bucket_extension, + creator_set_visibility: record.creator_set_visibility, + created_at: record.created_at, + updated_at: record.updated_at, + }) + .collect::>(); + + let response = SuccessResponse { + success: true, + results, + cursor_next, + cursor_previous, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| ErrorResponse::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs index 014fa68854..6af4273b7a 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs @@ -1,3 +1,4 @@ +pub mod delete_media_file; pub mod drain_multipart_request; pub mod get_media_file; -pub mod delete_media_file; +pub mod list_media_files_for_user; diff --git a/crates/service/web/storyteller_web/src/routes.rs b/crates/service/web/storyteller_web/src/routes.rs index 071b76c46d..9aafd5340c 100644 --- a/crates/service/web/storyteller_web/src/routes.rs +++ b/crates/service/web/storyteller_web/src/routes.rs @@ -44,6 +44,7 @@ use crate::http_server::endpoints::investor_demo::enable_demo_mode_handler::enab use crate::http_server::endpoints::leaderboard::get_leaderboard::leaderboard_handler; use crate::http_server::endpoints::media_files::delete_media_file::delete_media_file_handler; use crate::http_server::endpoints::media_files::get_media_file::get_media_file_handler; +use crate::http_server::endpoints::media_files::list_media_files_for_user::list_media_files_for_user_handler; use crate::http_server::endpoints::media_uploads::list_user_media_uploads_of_type::list_user_media_uploads_of_type_handler; use crate::http_server::endpoints::media_uploads::upload_audio::upload_audio_handler; use crate::http_server::endpoints::media_uploads::upload_image::upload_image_handler; @@ -1006,6 +1007,10 @@ fn add_media_file_routes (app: App) -> App .route(web::delete().to(delete_media_file_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/user/{username}") + .route(web::get().to(list_media_files_for_user_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) }