mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 18:21:12 +00:00
Bevy-specific "create scene" upload endpoint
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+198
-8
@@ -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")
|
||||
|
||||
+59
@@ -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;
|
||||
+353
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user