Bevy-specific "create scene" upload endpoint

This commit is contained in:
Brandon Thomas committed 2024-02-22 16:58:30 -05:00
1 parent 7cad80ad05
commit 896fae0ef5
9 files changed
+657 -22

No files matched your search

@@ -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);
}
}
}
}
@@ -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);
}
}
@@ -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<u64> = None;
@@ -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,
@@ -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,
@@ -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<Arc<ServerState>>,
mut multipart_payload: Multipart,
) -> Result<HttpResponse, UploadError> {
) -> Result<HttpResponse, CreateSceneError> {
// 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")
@@ -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<String>,
pub file_name: Option<String>,
pub file_bytes: Option<BytesMut>,
}
/// 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<MediaFileUploadData> {
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,
})
}
@@ -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;
@@ -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<Arc<ServerState>>,
mut multipart_payload: Multipart,
allowed_mimetypes: &HashSet<&'static str>,
) -> Result<SuccessCase, MediaFileUploadError> {
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::<String>();
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,
})
}