diff --git a/crates/_types/enums/src/by_table/media_files/media_file_origin_category.rs b/crates/_types/enums/src/by_table/media_files/media_file_origin_category.rs index fd6f36c06e..7cbab0645c 100644 --- a/crates/_types/enums/src/by_table/media_files/media_file_origin_category.rs +++ b/crates/_types/enums/src/by_table/media_files/media_file_origin_category.rs @@ -26,6 +26,9 @@ pub enum MediaFileOriginCategory { /// User uploaded files recorded directly from their device (browser, mobile), typically using device APIs. DeviceApi, + + /// From Storyteller Studio Engine + StoryEngine, } // TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! @@ -41,6 +44,7 @@ impl MediaFileOriginCategory { Self::Processed => "processed", Self::Upload => "upload", Self::DeviceApi => "device_api", + Self::StoryEngine => "story_engine", } } @@ -50,6 +54,7 @@ impl MediaFileOriginCategory { "processed" => Ok(Self::Processed), "upload" => Ok(Self::Upload), "device_api" => Ok(Self::DeviceApi), + "story_engine" => Ok(Self::StoryEngine), _ => Err(format!("invalid value: {:?}", value)), } } @@ -62,6 +67,7 @@ impl MediaFileOriginCategory { Self::Processed, Self::Upload, Self::DeviceApi, + Self::StoryEngine, ]) } } @@ -80,6 +86,7 @@ mod tests { assert_serialization(MediaFileOriginCategory::Processed, "processed"); assert_serialization(MediaFileOriginCategory::Upload, "upload"); assert_serialization(MediaFileOriginCategory::DeviceApi, "device_api"); + assert_serialization(MediaFileOriginCategory::StoryEngine, "story_engine"); } #[test] @@ -87,7 +94,7 @@ mod tests { assert_eq!(MediaFileOriginCategory::Inference.to_str(), "inference"); assert_eq!(MediaFileOriginCategory::Processed.to_str(), "processed"); assert_eq!(MediaFileOriginCategory::Upload.to_str(), "upload"); - assert_eq!(MediaFileOriginCategory::DeviceApi.to_str(), "device_api"); + assert_eq!(MediaFileOriginCategory::StoryEngine.to_str(), "story_engine"); } #[test] @@ -96,17 +103,19 @@ mod tests { assert_eq!(MediaFileOriginCategory::from_str("processed").unwrap(), MediaFileOriginCategory::Processed); assert_eq!(MediaFileOriginCategory::from_str("upload").unwrap(), MediaFileOriginCategory::Upload); assert_eq!(MediaFileOriginCategory::from_str("device_api").unwrap(), MediaFileOriginCategory::DeviceApi); + assert_eq!(MediaFileOriginCategory::from_str("story_engine").unwrap(), MediaFileOriginCategory::StoryEngine); assert!(MediaFileOriginCategory::from_str("foo").is_err()); } #[test] fn all_variants() { let mut variants = MediaFileOriginCategory::all_variants(); - assert_eq!(variants.len(), 4); + assert_eq!(variants.len(), 5); assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::Inference)); assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::Processed)); assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::Upload)); assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::DeviceApi)); + assert_eq!(variants.pop_first(), Some(MediaFileOriginCategory::StoryEngine)); assert_eq!(variants.pop_first(), None); } } @@ -128,5 +137,15 @@ mod tests { assert_eq!(variant, MediaFileOriginCategory::from_str(&format!("{:?}", variant)).unwrap()); } } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH : usize = 16; + for variant in MediaFileOriginCategory::all_variants() { + let serialized = variant.to_str(); + assert!(serialized.len() > 0, "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long", variant); + } + } } } diff --git a/crates/_types/enums/src/by_table/media_files/media_file_type.rs b/crates/_types/enums/src/by_table/media_files/media_file_type.rs index 2b887650f8..15cd476d07 100644 --- a/crates/_types/enums/src/by_table/media_files/media_file_type.rs +++ b/crates/_types/enums/src/by_table/media_files/media_file_type.rs @@ -11,7 +11,7 @@ use utoipa::ToSchema; /// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY. #[cfg_attr(test, derive(EnumIter, EnumCount))] #[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum MediaFileType { /// Audio files: wav, mp3, etc. Audio, @@ -39,6 +39,10 @@ pub enum MediaFileType { /// glTF files (for Bevy) Gltf, + + /// Bevy's scene files (in RON; Rusty Object Notation) + /// This will be replaced with another format in future versions of Bevy + SceneRon, } // TODO(bt, 2022-12-21): This desperately needs MySQL integration tests! @@ -58,6 +62,7 @@ impl MediaFileType { Self::Fbx => "fbx", Self::Glb => "glb", Self::Gltf => "gltf", + Self::SceneRon => "scene_ron", } } @@ -71,6 +76,7 @@ impl MediaFileType { "fbx" => Ok(Self::Fbx), "glb" => Ok(Self::Glb), "gltf" => Ok(Self::Gltf), + "scene_ron" => Ok(Self::SceneRon), _ => Err(format!("invalid value: {:?}", value)), } } @@ -87,6 +93,7 @@ impl MediaFileType { Self::Fbx, Self::Glb, Self::Gltf, + Self::SceneRon, ]) } } @@ -109,6 +116,7 @@ mod tests { assert_serialization(MediaFileType::Fbx, "fbx"); assert_serialization(MediaFileType::Glb, "glb"); assert_serialization(MediaFileType::Gltf, "gltf"); + assert_serialization(MediaFileType::SceneRon, "scene_ron"); } } @@ -120,10 +128,12 @@ mod tests { assert_eq!(MediaFileType::Audio.to_str(), "audio"); assert_eq!(MediaFileType::Image.to_str(), "image"); assert_eq!(MediaFileType::Video.to_str(), "video"); + assert_eq!(MediaFileType::Mocap.to_str(), "mocap"); assert_eq!(MediaFileType::Bvh.to_str(), "bvh"); assert_eq!(MediaFileType::Fbx.to_str(), "fbx"); assert_eq!(MediaFileType::Glb.to_str(), "glb"); assert_eq!(MediaFileType::Gltf.to_str(), "gltf"); + assert_eq!(MediaFileType::SceneRon.to_str(), "scene_ron"); } #[test] @@ -136,6 +146,7 @@ mod tests { assert_eq!(MediaFileType::from_str("fbx").unwrap(), MediaFileType::Fbx); assert_eq!(MediaFileType::from_str("glb").unwrap(), MediaFileType::Glb); assert_eq!(MediaFileType::from_str("gltf").unwrap(), MediaFileType::Gltf); + assert_eq!(MediaFileType::from_str("scene_ron").unwrap(), MediaFileType::SceneRon); assert!(MediaFileType::from_str("foo").is_err()); } } @@ -146,7 +157,7 @@ mod tests { #[test] fn all_variants() { let mut variants = MediaFileType::all_variants(); - assert_eq!(variants.len(), 8); + assert_eq!(variants.len(), 9); assert_eq!(variants.pop_first(), Some(MediaFileType::Audio)); assert_eq!(variants.pop_first(), Some(MediaFileType::Image)); assert_eq!(variants.pop_first(), Some(MediaFileType::Video)); @@ -155,6 +166,7 @@ mod tests { assert_eq!(variants.pop_first(), Some(MediaFileType::Fbx)); assert_eq!(variants.pop_first(), Some(MediaFileType::Glb)); assert_eq!(variants.pop_first(), Some(MediaFileType::Gltf)); + assert_eq!(variants.pop_first(), Some(MediaFileType::SceneRon)); assert_eq!(variants.pop_first(), None); } } diff --git a/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs b/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs index 724118c80b..e1e439ff12 100644 --- a/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs +++ b/crates/lib/mysql_queries/src/queries/media_files/create/insert_media_file_from_file_upload.rs @@ -17,6 +17,7 @@ use crate::queries::generic_synthetic_ids::transactional_increment_generic_synth pub enum UploadType { Filesystem, DeviceCaptureApi, + StorytellerEngine, } pub struct InsertMediaFileFromUploadArgs<'a> { @@ -52,6 +53,7 @@ pub async fn insert_media_file_from_file_upload( let origin_category = match args.upload_type { UploadType::Filesystem => MediaFileOriginCategory::Upload, UploadType::DeviceCaptureApi => MediaFileOriginCategory::DeviceApi, + UploadType::StorytellerEngine => MediaFileOriginCategory::StoryEngine, }; let mut maybe_creator_file_synthetic_id : Option = None; diff --git a/crates/lib/mysql_queries/src/queries/users/user_bookmarks/list_user_bookmarks_result.rs b/crates/lib/mysql_queries/src/queries/users/user_bookmarks/list_user_bookmarks_result.rs index 7e56919e19..85ded22b6e 100644 --- a/crates/lib/mysql_queries/src/queries/users/user_bookmarks/list_user_bookmarks_result.rs +++ b/crates/lib/mysql_queries/src/queries/users/user_bookmarks/list_user_bookmarks_result.rs @@ -156,6 +156,7 @@ impl RawUserBookmarkRecord { MediaFileType::Fbx => Some("3d file".to_string()), MediaFileType::Glb => Some("3d file".to_string()), MediaFileType::Gltf => Some("3d file".to_string()), + MediaFileType::SceneRon => Some("3d scene".to_string()), }), UserBookmarkEntityType::VoiceConversionModel => self.maybe_descriptive_text_voice_conversion_model_title, UserBookmarkEntityType::ZsVoice => self.maybe_descriptive_text_zs_voice_title, diff --git a/crates/service/web/storyteller_web/src/api_doc.rs b/crates/service/web/storyteller_web/src/api_doc.rs index 63d3fdc567..3f35668618 100644 --- a/crates/service/web/storyteller_web/src/api_doc.rs +++ b/crates/service/web/storyteller_web/src/api_doc.rs @@ -27,6 +27,9 @@ use crate::http_server::common_responses::pagination_cursors::PaginationCursors; use crate::http_server::common_responses::pagination_page::PaginationPage; use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats; use crate::http_server::endpoints::conversion::enqueue_fbx_to_gltf_handler::*; +use crate::http_server::endpoints::engine::create_scene_handler::*; +use crate::http_server::endpoints::inference_job::get_inference_job_status::*; +use crate::http_server::endpoints::inference_job::terminate_inference_job_handler::*; use crate::http_server::endpoints::media_files::delete_media_file::*; use crate::http_server::endpoints::media_files::get_media_file::*; use crate::http_server::endpoints::media_files::list_featured_media_files::*; @@ -53,13 +56,12 @@ use crate::http_server::endpoints::weights::search_model_weights_handler::*; use crate::http_server::endpoints::weights::set_model_weight_cover_image::*; use crate::http_server::endpoints::weights::update_weight::*; use crate::http_server::web_utils::response_success_helpers::*; -use crate::http_server::endpoints::inference_job::terminate_inference_job_handler::*; -use crate::http_server::endpoints::inference_job::get_inference_job_status::*; #[derive(OpenApi)] #[openapi( paths( crate::http_server::endpoints::conversion::enqueue_fbx_to_gltf_handler::enqueue_fbx_to_gltf_handler, + crate::http_server::endpoints::engine::create_scene_handler::create_scene_handler, crate::http_server::endpoints::inference_job::get_inference_job_status::get_inference_job_status_handler, crate::http_server::endpoints::inference_job::terminate_inference_job_handler::terminate_inference_job_handler, crate::http_server::endpoints::media_files::delete_media_file::delete_media_file_handler, @@ -129,6 +131,8 @@ use crate::http_server::endpoints::inference_job::get_inference_job_status::*; BatchGetUserRatingQueryParams, BatchGetUserRatingResponse, BookmarkRow, + CreateSceneError, + CreateSceneSuccessResponse, CreateUserBookmarkError, CreateUserBookmarkRequest, CreateUserBookmarkSuccessResponse, @@ -139,10 +143,8 @@ use crate::http_server::endpoints::inference_job::get_inference_job_status::*; DeleteUserBookmarkPathInfo, DeleteUserBookmarkRequest, DeleteWeightError, - DeleteWeightError, DeleteWeightPathInfo, DeleteWeightRequest, - DeleteWeightRequest, EnqueueFbxToGltfRequest, EnqueueFbxToGltfRequestError, EnqueueFbxToGltfRequestSuccessResponse, @@ -188,19 +190,16 @@ use crate::http_server::endpoints::inference_job::get_inference_job_status::*; ListUserBookmarksForUserSuccessResponse, ListUserBookmarksPathInfo, ListWeightError, - ListWeightError, ListWeightsByUserError, ListWeightsByUserPathInfo, ListWeightsByUserSuccessResponse, MediaFile, MediaFileData, MediaFileForUserListItem, - MediaFileForUserListItem, MediaFileInfo, MediaFileListItem, - MediaFileUploadError, MediaFilesByBatchListItem, - ModelWeightForList, + MediaFileUploadError, ModelWeightForList, ModelWeightSearchResult, ProfileError, @@ -228,8 +227,6 @@ use crate::http_server::endpoints::inference_job::get_inference_job_status::*; UpdateWeightError, UpdateWeightPathInfo, UpdateWeightRequest, - UpdateWeightRequest, - UpdateWeightRequest, UploadMediaSuccessResponse, UserBookmarkDetailsForUserList, UserBookmarkEntityType, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs index f4edb9992e..d58743820e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/create_scene_handler.rs @@ -2,36 +2,226 @@ use std::collections::HashSet; use std::sync::Arc; use actix_multipart::Multipart; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; +use http::StatusCode; +use log::{error, info, warn}; use once_cell::sync::Lazy; +use utoipa::ToSchema; +use buckets::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use http_server_common::request::get_request_ip::get_request_ip; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::media_files::create::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; use tokens::tokens::media_files::MediaFileToken; -use crate::http_server::endpoints::media_uploads::common::handle_upload::handle_upload; -use crate::http_server::endpoints::media_uploads::common::upload_error::UploadError; +use crate::http_server::endpoints::engine::drain_multipart_request::drain_multipart_request; use crate::server_state::ServerState; +use crate::validations::validate_idempotency_token_format::validate_idempotency_token_format; -#[derive(Serialize)] +#[derive(Debug, Serialize, ToSchema)] +pub enum CreateSceneError { + BadInput(String), + NotAuthorized, + MustBeLoggedIn, + ServerError, + RateLimited, +} + +impl ResponseError for CreateSceneError { + fn status_code(&self) -> StatusCode { + match *self { + CreateSceneError::BadInput(_) => StatusCode::BAD_REQUEST, + CreateSceneError::NotAuthorized => StatusCode::UNAUTHORIZED, + CreateSceneError::MustBeLoggedIn => StatusCode::UNAUTHORIZED, + CreateSceneError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + CreateSceneError::RateLimited => StatusCode::TOO_MANY_REQUESTS, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +impl std::fmt::Display for CreateSceneError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +#[derive(Serialize, ToSchema)] pub struct CreateSceneSuccessResponse { pub success: bool, pub media_file_token: MediaFileToken, } +#[utoipa::path( + post, + path = "/v1/engine/create_scene", + responses( + (status = 200, description = "Found", body = CreateSceneSuccessResponse), + (status = 404, description = "Not found", body = CreateSceneError), + (status = 500, description = "Server error", body = CreateSceneError), + ), +)] pub async fn create_scene_handler( http_request: HttpRequest, server_state: web::Data>, mut multipart_payload: Multipart, -) -> Result { +) -> Result { - // TODO(bt): Handle upload / save. + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + CreateSceneError::ServerError + })?; + + // ==================== READ SESSION ==================== // + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) + .await + .map_err(|e| { + error!("Session checker error: {:?}", e); + CreateSceneError::ServerError + })?; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== BANNED USERS ==================== // + + if let Some(ref user) = maybe_user_session { + if user.is_banned { + return Err(CreateSceneError::NotAuthorized); + } + } + + // ==================== RATE LIMIT ==================== // + + let rate_limiter = match maybe_user_session { + None => &server_state.redis_rate_limiters.file_upload_logged_out, + Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in, + }; + + if let Err(_err) = rate_limiter.rate_limit_request(&http_request) { + return Err(CreateSceneError::RateLimited); + } + + // ==================== READ MULTIPART REQUEST ==================== // + + let upload_media_request = drain_multipart_request(multipart_payload) + .await + .map_err(|e| { + // TODO: Error handling could be nicer. + CreateSceneError::BadInput("bad request".to_string()) + })?; + + let uuid_idempotency_token = upload_media_request.uuid_idempotency_token + .ok_or(CreateSceneError::BadInput("no uuid".to_string()))?; + + // ==================== HANDLE IDEMPOTENCY ==================== // + + if let Err(reason) = validate_idempotency_token_format(&uuid_idempotency_token) { + return Err(CreateSceneError::BadInput(reason)); + } + + // TODO(bt, 2024-02-22): This should be a transaction. + insert_idempotency_token(&uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + CreateSceneError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== USER DATA ==================== // + + let ip_address = get_request_ip(&http_request); + + let maybe_user_token = maybe_user_session + .map(|session| session.get_strongly_typed_user_token()); + + // ==================== FILE DATA ==================== // + + let mut maybe_mimetype = upload_media_request.file_bytes + .as_ref() + .map(|bytes| get_mimetype_for_bytes(bytes.as_ref())) + .flatten(); + + let mime_type = maybe_mimetype + .unwrap_or("text/plain"); + + let bytes = match upload_media_request.file_bytes { + None => return Err(CreateSceneError::BadInput("missing file contents".to_string())), + Some(bytes) => bytes, + }; + + let file_size_bytes = bytes.len(); + + let hash = sha256_hash_bytes(&bytes) + .map_err(|io_error| { + error!("Problem hashing bytes: {:?}", io_error); + CreateSceneError::ServerError + })?; + + const PREFIX : Option<&str> = Some("scene_"); + const SUFFIX : Option<&str> = Some(".scn.ron"); + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, SUFFIX); + + info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + bytes.as_ref(), + mime_type) + .await + .map_err(|e| { + warn!("Upload media bytes to bucket error: {:?}", e); + CreateSceneError::ServerError + })?; + + // TODO(bt, 2024-02-22): This should be a transaction. + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_creator_user_token: maybe_user_token.as_ref(), + maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(), + creator_ip_address: &ip_address, + creator_set_visibility: Visibility::Public, + upload_type: UploadType::StorytellerEngine, + media_file_type: MediaFileType::SceneRon, + maybe_mime_type: Some(mime_type), + file_size_bytes: file_size_bytes as u64, + duration_millis: 0, // None + sha256_checksum: &hash, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: PREFIX, + maybe_public_bucket_extension: SUFFIX, + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("New generic download creation DB error: {:?}", err); + CreateSceneError::ServerError + })?; + + info!("new media file id: {} token: {:?}", record_id, &token); let response = CreateSceneSuccessResponse { success: true, - media_file_token: MediaFileToken::new_from_str("TODO"), + media_file_token: token, }; let body = serde_json::to_string(&response) - .map_err(|e| UploadError::ServerError)?; + .map_err(|e| CreateSceneError::ServerError)?; return Ok(HttpResponse::Ok() .content_type("application/json") diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/drain_multipart_request.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/drain_multipart_request.rs new file mode 100644 index 0000000000..6bca3b91d9 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/drain_multipart_request.rs @@ -0,0 +1,59 @@ +use actix_multipart::Multipart; +use actix_web::web::BytesMut; +use futures::TryStreamExt; +use log::warn; + +use errors::AnyhowResult; +use tokens::tokens::zs_voice_datasets::ZsVoiceDatasetToken; + +use crate::http_server::web_utils::read_multipart_field_bytes::{checked_read_multipart_bytes, read_multipart_field_as_text}; + +pub struct MediaFileUploadData { + pub uuid_idempotency_token: Option, + pub file_name: Option, + pub file_bytes: Option, +} + +/// Pull common parts out of multipart media HTTP requests, typically for handling file uploads. +pub async fn drain_multipart_request(mut multipart_payload: Multipart) -> AnyhowResult { + let mut uuid_idempotency_token = None; + let mut file_bytes = None; + let mut file_name = None; + + while let Ok(Some(mut field)) = multipart_payload.try_next().await { + let mut field_name = None; + let mut field_filename = None; + + if let content_disposition = field.content_disposition() { + field_name = content_disposition.get_name() + .map(|s| s.to_string()); + field_filename = content_disposition.get_filename() // NB: Only used for the file bytes. + .map(|s| s.to_string()); + } + + match field_name.as_deref() { + Some("uuid_idempotency_token") => { + uuid_idempotency_token = read_multipart_field_as_text(&mut field).await + .map_err(|e| { + warn!("Error reading idempotency token: {:}", &e); + e + })?; + }, + Some("file") => { + file_name = field_filename.clone(); + file_bytes = checked_read_multipart_bytes(&mut field).await + .map_err(|e| { + warn!("Error reading audio upload: {:}", &e); + e + })?; + }, + _ => continue, + } + } + + Ok(MediaFileUploadData { + uuid_idempotency_token, + file_name, + file_bytes, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/mod.rs index a5224f9d91..597815a704 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/mod.rs @@ -1,3 +1,5 @@ pub mod create_scene_handler; +pub mod drain_multipart_request; pub mod get_scene_handler; +pub mod process_upload_media_file; pub mod update_scene_handler; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/engine/process_upload_media_file.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/process_upload_media_file.rs new file mode 100644 index 0000000000..2b74fe43e2 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/engine/process_upload_media_file.rs @@ -0,0 +1,353 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use actix_multipart::Multipart; +use actix_web::{HttpRequest, web}; +use log::{error, info, warn}; + +use buckets::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes; +use http_server_common::request::get_request_ip::get_request_ip; +use media::decode_basic_audio_info::decode_basic_audio_bytes_info; +use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes; +use mimetypes::mimetype_to_extension::mimetype_to_extension; +use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token; +use mysql_queries::queries::media_files::create::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType}; +use tokens::tokens::media_files::MediaFileToken; + +use crate::http_server::endpoints::media_files::upload::drain_multipart_request::{drain_multipart_request, MediaFileUploadSource}; +use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError; +use crate::server_state::ServerState; +use crate::validations::validate_idempotency_token_format::validate_idempotency_token_format; + +// TODO(bt,2023-12-20): THIS CODE NEEDS CLEANUP. This has been cargo culted three+ times. +// It's ridiculous and complicated. + +pub enum SuccessCase { + MediaAlreadyUploaded { + existing_media_file_token: MediaFileToken, + }, + MediaSuccessfullyUploaded { + media_file_token: MediaFileToken, + } +} + +impl SuccessCase { + pub fn to_media_token(self) -> MediaFileToken { + match self { + SuccessCase::MediaAlreadyUploaded { existing_media_file_token } => existing_media_file_token, + SuccessCase::MediaSuccessfullyUploaded { media_file_token } => media_file_token, + } + } +} + +pub async fn process_upload_media_file( + http_request: &HttpRequest, + server_state: &web::Data>, + mut multipart_payload: Multipart, + allowed_mimetypes: &HashSet<&'static str>, +) -> Result { + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + // ==================== READ SESSION ==================== // + + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session_from_connection(http_request, &mut mysql_connection) + .await + .map_err(|e| { + error!("Session checker error: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let maybe_avt_token = server_state + .avt_cookie_manager + .get_avt_token_from_request(&http_request); + + // ==================== BANNED USERS ==================== // + + if let Some(ref user) = maybe_user_session { + if user.is_banned { + return Err(MediaFileUploadError::NotAuthorized); + } + } + + // ==================== RATE LIMIT ==================== // + + let rate_limiter = match maybe_user_session { + None => &server_state.redis_rate_limiters.file_upload_logged_out, + Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in, + }; + + if let Err(_err) = rate_limiter.rate_limit_request(&http_request) { + return Err(MediaFileUploadError::RateLimited); + } + + // ==================== READ MULTIPART REQUEST ==================== // + + let upload_media_request = drain_multipart_request(multipart_payload) + .await + .map_err(|e| { + // TODO: Error handling could be nicer. + MediaFileUploadError::BadInput("bad request".to_string()) + })?; + + let uuid_idempotency_token = upload_media_request.uuid_idempotency_token + .ok_or(MediaFileUploadError::BadInput("no uuid".to_string()))?; + + // ==================== HANDLE IDEMPOTENCY ==================== // + + if let Err(reason) = validate_idempotency_token_format(&uuid_idempotency_token) { + return Err(MediaFileUploadError::BadInput(reason)); + } + + insert_idempotency_token(&uuid_idempotency_token, &mut *mysql_connection) + .await + .map_err(|err| { + error!("Error inserting idempotency token: {:?}", err); + MediaFileUploadError::BadInput("invalid idempotency token".to_string()) + })?; + + // ==================== PROCESS REQUEST ==================== // + + let creator_set_visibility = maybe_user_session + .as_ref() + .map(|user_session| user_session.preferred_tts_result_visibility) // TODO: We need a new type of visibility control. + .unwrap_or(Visibility::default()); + + let ip_address = get_request_ip(&http_request); + + let maybe_user_token = maybe_user_session + .map(|session| session.get_strongly_typed_user_token()); + + let maybe_file_size_bytes = upload_media_request.file_bytes + .as_ref() + .map(|bytes| bytes.len()); + + info!("Upload maybe filesize: {:?}", maybe_file_size_bytes); + + let mut maybe_mimetype = upload_media_request.file_bytes + .as_ref() + .map(|bytes| get_mimetype_for_bytes(bytes.as_ref())) + .flatten(); + + let bytes = match upload_media_request.file_bytes { + None => return Err(MediaFileUploadError::BadInput("missing file contents".to_string())), + Some(bytes) => bytes, + }; + + let hash = sha256_hash_bytes(&bytes) + .map_err(|io_error| { + error!("Problem hashing bytes: {:?}", io_error); + MediaFileUploadError::ServerError + })?; + + let file_size_bytes = bytes.len(); + + let mut maybe_duration_millis = None; + let mut maybe_codec_name = None; + let mut media_file_type = None; + + if let Some(mimetype) = maybe_mimetype.as_deref() { + + if !allowed_mimetypes.contains(mimetype) { + // NB: Don't let our error message inject malicious strings + let filtered_mimetype = mimetype + .chars() + .filter(|c| c.is_ascii()) + .filter(|c| c.is_alphanumeric() || *c == '/') + .collect::(); + return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype))); + } + + // NB: .aiff (audio/aiff) isn't supported by Symphonia: + // It contains uncompressed PCM-encoded audio similar to wav. + // See: https://github.com/pdeljanov/Symphonia/issues/75 + // NB: The following formats are not supported by Symphonia and + // do not have any open issues filed. They may simply be too old: + // - .wma (audio/x-ms-wma) + // - .avi (video/x-msvideo) + media_file_type = match mimetype { + // Audio + "audio/aac" /* .aac */ => Some(MediaFileType::Audio), + "audio/m4a" /* .m4a */ => Some(MediaFileType::Audio), + "audio/mpeg" /* .mp3 */ => Some(MediaFileType::Audio), + "audio/ogg" /* .ogg */ => Some(MediaFileType::Audio), + "audio/opus" /* .opus */ => Some(MediaFileType::Audio), + "audio/x-flac" /* .flac */ => Some(MediaFileType::Audio), + "audio/x-wav" /* .wav */ => Some(MediaFileType::Audio), + // Image + "image/gif" /* .gif */ => Some(MediaFileType::Image), + "image/jpeg" /* .jpg */ => Some(MediaFileType::Image), + "image/png" /* .png */ => Some(MediaFileType::Image), + "image/webp" /* .webp */ => Some(MediaFileType::Image), + // Video + "video/mp4" /* .mp4 */ => Some(MediaFileType::Video), + "video/quicktime" /* .mov */ => Some(MediaFileType::Video), + "video/webm" /* .webm */ => Some(MediaFileType::Video), + _ => None, + }; + + let do_audio_decode = match mimetype { + // TODO: Revisit when Safari can send us this metadata consistently + "audio/mp4" | "video/mp4" => false, + "audio/opus" => { + // TODO/FIXME(bt, 2023-05-19): Symphonia is currently broken for Firefox's opus. + // We're on an off-master branch that may resolve the problem in the future, but for now + // it panics as follows: + // + // [2023-05-19T10:25:34Z INFO symphonia_core::probe] found a possible format marker within [4f, 67, 67, 53, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, d4, c5] @ 0+2 bytes. + // [2023-05-19T10:25:34Z INFO symphonia_core::probe] found the format marker [4f, 67, 67, 53] @ 0+2 bytes. + // [2023-05-19T10:25:34Z DEBUG symphonia_format_ogg::page] grow page buffer to 8192 bytes + // [2023-05-19T10:25:34Z INFO symphonia_format_ogg::demuxer] starting new physical stream + // [2023-05-19T10:25:34Z INFO symphonia_format_ogg::demuxer] selected opus mapper for stream with serial=0x19aac5d4 + // [2023-05-19T10:25:34Z INFO media::decode_basic_audio_info] Probed! + // [2023-05-19T10:25:34Z INFO media::decode_basic_audio_info] Find audio track... + // [2023-05-19T10:25:34Z INFO media::decode_basic_audio_info] Found audio track (maybe) + // [2023-05-19T10:25:34Z INFO media::decode_basic_audio_info] Maybe track duration: None + // [2023-05-19T10:25:34Z INFO media::decode_basic_audio_info] Maybe codec short name: Some("opus") + // [2023-05-19T10:25:34Z INFO media::decode_basic_audio_info] Opus handler + // [2023-05-19T10:25:34Z INFO media::decode_basic_audio_info] Media source stream + // [2023-05-19T10:25:34Z INFO media::decode_webm_opus_info] decode_mkv : options... + // [2023-05-19T10:25:34Z INFO media::decode_webm_opus_info] decode_mkv : try_read... + // [2023-05-19T10:25:34Z DEBUG symphonia_format_mkv::ebml] element with tag: 4F67 + // thread 'actix-rt|system:0|arbiter:1' panicked at 'assertion failed: `(left == right)` + // left: `Unknown`, + // right: `Ebml`: EBML element type must be checked before calling this function', /Users/bt/.cargo/git/checkouts/symphonia-8fbe6c90fc095688/e1a7009/symphonia-format-mkv/src/ebml.rs:335:9 + false + } + // Also, don't decode images + "image/jpeg" => false, + "image/png" => false, + "image/webp" => false, + _ => true, + }; + + if do_audio_decode && media_file_type.is_some() { + let basic_info = decode_basic_audio_bytes_info( + bytes.as_ref(), + Some(mimetype), + None + ).map_err(|e| { + warn!("file decoding error: {:?}", e); + MediaFileUploadError::BadInput("could not decode file".to_string()) + })?; + + maybe_duration_millis = basic_info.duration_millis; + maybe_codec_name = basic_info.codec_name; + } + } + + if media_file_type.is_none() && maybe_mimetype.is_none() { + // https://research.cs.wisc.edu/graphics/Courses/cs-838-1999/Jeff/BVH.html + const BVH_HEADER : &[u8] = "HIERARCHY".as_bytes(); + + // https://code.blender.org/2013/08/fbx-binary-file-format-specification/ + const FBX_HEADER : &[u8] = "Kaydara FBX Binary".as_bytes(); + + // https://github.com/KhronosGroup/glTF-Tutorials/blob/master/gltfTutorial/gltfTutorial_002_BasicGltfStructure.md + // TODO(bt,2024-01-28): Fix this ASAP + const GLTF_CONTENTS_1 : &[u8] = "{".as_bytes(); + + if bytes.starts_with(BVH_HEADER) { + media_file_type = Some(MediaFileType::Bvh); + maybe_mimetype = Some("application/octet-stream"); + } else if bytes.starts_with(FBX_HEADER) { + media_file_type = Some(MediaFileType::Fbx); + maybe_mimetype = Some("application/octet-stream"); + } else if bytes.starts_with(GLTF_CONTENTS_1) { + // TODO(bt,2024-01-28): This is a horrible check! + media_file_type = Some(MediaFileType::Gltf); + maybe_mimetype = Some("application/json"); + } + } + + let media_file_type = match media_file_type { + Some(m) => m, + None => { + warn!("Invalid mimetype: {:?}", maybe_mimetype); + return Err(MediaFileUploadError::BadInput(format!("unknown mimetype: {:?}", maybe_mimetype))) + }, + }; + + let upload_type = match upload_media_request.media_source { + MediaFileUploadSource::Unknown => UploadType::Filesystem, + MediaFileUploadSource::UserFile => UploadType::Filesystem, + MediaFileUploadSource::UserDeviceApi => UploadType::DeviceCaptureApi, + }; + + // TODO: Clean up code + let mime_type = match maybe_mimetype { + Some(m) => m, + None => { + warn!("Missing mimetype!"); + return Err(MediaFileUploadError::BadInput("Missing mimetype".to_string())); + }, + }; + + let mut extension = mimetype_to_extension(mime_type) + .map(|extension| format!(".{extension}")); + + if extension.is_none() { + extension = match media_file_type { + MediaFileType::Mocap => Some(".bvh".to_string()), + MediaFileType::Bvh => Some(".bvh".to_string()), + MediaFileType::Fbx => Some(".fbx".to_string()), + MediaFileType::Gltf => Some(".gltf".to_string()), + _ => None, + }; + } + + const PREFIX : Option<&str> = Some("upload_"); + + let public_upload_path = MediaFileBucketPath::generate_new(PREFIX, extension.as_deref()); + + info!("Uploading media to bucket path: {}", public_upload_path.get_full_object_path_str()); + + server_state.public_bucket_client.upload_file_with_content_type( + public_upload_path.get_full_object_path_str(), + bytes.as_ref(), + mime_type) + .await + .map_err(|e| { + warn!("Upload media bytes to bucket error: {:?}", e); + MediaFileUploadError::ServerError + })?; + + let (token, record_id) = insert_media_file_from_file_upload(InsertMediaFileFromUploadArgs { + maybe_creator_user_token: maybe_user_token.as_ref(), + maybe_creator_anonymous_visitor_token: maybe_avt_token.as_ref(), + creator_ip_address: &ip_address, + creator_set_visibility, + upload_type, + media_file_type, + maybe_mime_type: Some(mime_type), + file_size_bytes: file_size_bytes as u64, + duration_millis: 0, // TODO + sha256_checksum: &hash, + public_bucket_directory_hash: public_upload_path.get_object_hash(), + maybe_public_bucket_prefix: PREFIX, + maybe_public_bucket_extension: extension.as_deref(), + pool: &server_state.mysql_pool, + }) + .await + .map_err(|err| { + warn!("New generic download creation DB error: {:?}", err); + MediaFileUploadError::ServerError + })?; + + info!("new media file id: {} token: {:?}", record_id, &token); + + Ok(SuccessCase::MediaSuccessfullyUploaded { + media_file_token: token, + }) +}