diff --git a/Cargo.lock b/Cargo.lock index 6db3910d47..3007b13225 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6834,6 +6834,35 @@ dependencies = [ "workspace-build-acceleration", ] +[[package]] +name = "openart_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "cookie 0.18.1", + "crockford", + "errors", + "filesys", + "hex-literal", + "idempotency", + "log", + "once_cell", + "rand 0.9.1", + "reqwest 0.12.15", + "serde", + "serde_derive", + "serde_json", + "sha3", + "subprocess_common", + "testing", + "thiserror 2.0.12", + "tokio", + "toml 0.8.22", + "workspace-build-acceleration", +] + [[package]] name = "openssl" version = "0.10.71" diff --git a/Cargo.toml b/Cargo.toml index dfe1da0ae4..0beeb796cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/lib/api_clients/google_drive_common", "crates/lib/api_clients/google_sign_in", "crates/lib/api_clients/openai_sora_client", + "crates/lib/api_clients/openart_client", "crates/lib/api_clients/runwayml_client", "crates/lib/api_clients/storyteller_client", "crates/lib/api_clients/thumbnail_generator", diff --git a/crates/lib/api_clients/openart_client/Cargo.toml b/crates/lib/api_clients/openart_client/Cargo.toml new file mode 100644 index 0000000000..4ee1b99512 --- /dev/null +++ b/crates/lib/api_clients/openart_client/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "openart_client" +edition = "2024" +version = "0.0.1" +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "openart_client" +path = "src/lib.rs" + +[dependencies] + +# Workspace hack (faster compile times) +workspace-build-acceleration = { workspace = true } + +# Internal +crockford = { path = "../../data/crockford" } +errors = { path = "../../errors" } +idempotency = { path = "../../idempotency" } +subprocess_common = { path = "../../subprocess_common" } + +# External +anyhow = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true} +cookie = { workspace = true } +hex-literal = { workspace = true } +log = { workspace = true } +once_cell= { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true, features = ["multipart", "gzip"] } +serde = { workspace = true } +serde_derive = { workspace = true } +serde_json = { workspace = true } +sha3 = { workspace = true } +thiserror = { workspace = true } +tokio = { version = "1.38.0", features = ["macros"] } +toml = { workspace = true } + +[dev-dependencies] +testing = { path = "../../testing" } +filesys = { path = "../../files/filesys" } diff --git a/crates/lib/api_clients/openart_client/src/creds/mod.rs b/crates/lib/api_clients/openart_client/src/creds/mod.rs new file mode 100644 index 0000000000..7b633d96bc --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/creds/mod.rs @@ -0,0 +1,3 @@ +pub mod openart_cookies; +pub mod openart_credentials; +pub mod openart_session_info; \ No newline at end of file diff --git a/crates/lib/api_clients/openart_client/src/creds/openart_cookies.rs b/crates/lib/api_clients/openart_client/src/creds/openart_cookies.rs new file mode 100644 index 0000000000..d6b0013a5a --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/creds/openart_cookies.rs @@ -0,0 +1,24 @@ + +/// The cookies payload is the entire cookie header (potentially many cookies). +#[derive(Clone)] +pub struct OpenArtCookies { + cookies: String, +} + +impl OpenArtCookies { + pub fn new(cookies: String) -> Self { + OpenArtCookies { cookies } + } + + pub fn as_str(&self) -> &str { + &self.cookies + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookies.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookies.clone() + } +} diff --git a/crates/lib/api_clients/openart_client/src/creds/openart_credentials.rs b/crates/lib/api_clients/openart_client/src/creds/openart_credentials.rs new file mode 100644 index 0000000000..0dcc8148e8 --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/creds/openart_credentials.rs @@ -0,0 +1,18 @@ +use crate::creds::openart_cookies::OpenArtCookies; +use crate::creds::openart_session_info::OpenArtSessionInfo; + +#[derive(Clone)] +pub struct OpenArtCredentials { + pub cookies: Option, + pub session_info: Option, +} + +impl OpenArtCredentials { + pub fn from_cookies(cookies: &str) -> Self { + let cookies = OpenArtCookies::new(cookies.to_string()); + Self { + cookies: Some(cookies), + session_info: None, + } + } +} diff --git a/crates/lib/api_clients/openart_client/src/creds/openart_session_info.rs b/crates/lib/api_clients/openart_client/src/creds/openart_session_info.rs new file mode 100644 index 0000000000..594947854a --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/creds/openart_session_info.rs @@ -0,0 +1,10 @@ + +#[derive(Debug, Clone)] +pub struct OpenArtSessionInfo { + /// This is either a session ID, user ID, or subscription ID. + /// It is passed as the header `X-USER-ID` in other requests. + pub sub: Option, + pub email: Option, + pub name: Option, + pub image: Option, +} diff --git a/crates/lib/api_clients/openart_client/src/error/api_error.rs b/crates/lib/api_clients/openart_client/src/error/api_error.rs new file mode 100644 index 0000000000..1c1600f1de --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/error/api_error.rs @@ -0,0 +1,37 @@ +use reqwest::StatusCode; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum ApiError { + /// Session endpoint did not return a valid session id. + /// Specifically for calls to https://openart.ai/api/auth/session + InvalidSession, + CouldNotParseSession { error: serde_json::Error, body: String }, + + /// An error that occurred as or after the request was sent. + ReqwestError(reqwest::Error), + + /// Some other failure response. + UncategorizedBadResponse{ + status_code: StatusCode, + message : String, + } +} + +impl Display for ApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::InvalidSession => write!(f, "Invalid session: no session id returned from the server"), + ApiError::CouldNotParseSession { error, body } => { + write!(f, "Could not parse session: {}. Body: {}", error, body) + } + ApiError::ReqwestError(err) => write!(f, "ReqwestError: {}", err), + ApiError::UncategorizedBadResponse { status_code, message } => { + write!(f, "UncategorizedBadResponse: {:?} : {}", status_code, message) + }, + } + } +} + +impl Error for ApiError {} diff --git a/crates/lib/api_clients/openart_client/src/error/classify_http_error_response.rs b/crates/lib/api_clients/openart_client/src/error/classify_http_error_response.rs new file mode 100644 index 0000000000..97b2a91bd0 --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/error/classify_http_error_response.rs @@ -0,0 +1,15 @@ +use crate::error::api_error::ApiError; +use crate::error::classify_http_error_status_code_and_body::classify_http_error_status_code_and_body; +use crate::error::openart_error::OpenArtError; + +/// This assumes the request failed and returned a non-200. +/// The caller should check. +pub async fn classify_http_error_response(response: reqwest::Response) -> OpenArtError { + let status = response.status(); + let message = match response.text().await { + Ok(text) => text, + Err(err) => return OpenArtError::Api(ApiError::ReqwestError(err)), + }; + + classify_http_error_status_code_and_body(status, &message).await +} diff --git a/crates/lib/api_clients/openart_client/src/error/classify_http_error_status_code_and_body.rs b/crates/lib/api_clients/openart_client/src/error/classify_http_error_status_code_and_body.rs new file mode 100644 index 0000000000..b882c6591f --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/error/classify_http_error_status_code_and_body.rs @@ -0,0 +1,14 @@ +use crate::error::api_error::ApiError; +use crate::error::openart_error::OpenArtError; +use reqwest::StatusCode; + +/// This assumes the request failed and returned a non-200. +/// The caller should check. +pub async fn classify_http_error_status_code_and_body(status: StatusCode, body: &str) -> OpenArtError { + + let message = body.to_string(); + + // TODO: Handle various error messages from the API. + + OpenArtError::Api(ApiError::UncategorizedBadResponse { status_code: status, message }) +} diff --git a/crates/lib/api_clients/openart_client/src/error/client_error.rs b/crates/lib/api_clients/openart_client/src/error/client_error.rs new file mode 100644 index 0000000000..78e0d525d4 --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/error/client_error.rs @@ -0,0 +1,34 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum ClientError { + /// No cookies in credentials + NoCookiesInCredentials, + + /// No session in the credentials + NoSessionInfoInCredentials, + + /// An error that occurred clientside before the request was sent. + ReqwestError(reqwest::Error), + + /// An I/O error, e.g. reading from a file or network stream. + IoError(std::io::Error), + + /// Another error that does not fit the above categories. + Other(String), +} + +impl Display for ClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoCookiesInCredentials => write!(f, "No cookies are present in the credentials"), + Self::NoSessionInfoInCredentials => write!(f, "No session in the credentials"), + Self::ReqwestError(err) => write!(f, "Reqwest error: {}", err), + Self::IoError(err) => write!(f, "I/O error: {}", err), + Self::Other(message) => write!(f, "Other client error: {}", message), + } + } +} + +impl Error for ClientError {} diff --git a/crates/lib/api_clients/openart_client/src/error/mod.rs b/crates/lib/api_clients/openart_client/src/error/mod.rs new file mode 100644 index 0000000000..456a25d8ae --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/error/mod.rs @@ -0,0 +1,5 @@ +pub mod api_error; +pub mod classify_http_error_response; +pub mod client_error; +pub mod openart_error; +pub mod classify_http_error_status_code_and_body; \ No newline at end of file diff --git a/crates/lib/api_clients/openart_client/src/error/openart_error.rs b/crates/lib/api_clients/openart_client/src/error/openart_error.rs new file mode 100644 index 0000000000..e3891f40e4 --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/error/openart_error.rs @@ -0,0 +1,33 @@ +use crate::error::api_error::ApiError; +use crate::error::client_error::ClientError; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum OpenArtError { + Api(ApiError), + Client(ClientError), +} + +impl Display for OpenArtError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Api(err) => write!(f, "ApiError: {}", err), + Self::Client(err) => write!(f, "ClientError: {}", err), + } + } +} + +impl Error for OpenArtError {} + +impl From for OpenArtError { + fn from(err: ApiError) -> Self { + OpenArtError::Api(err) + } +} + +impl From for OpenArtError { + fn from(err: ClientError) -> Self { + OpenArtError::Client(err) + } +} diff --git a/crates/lib/api_clients/openart_client/src/lib.rs b/crates/lib/api_clients/openart_client/src/lib.rs new file mode 100644 index 0000000000..c3a1a5d340 --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/lib.rs @@ -0,0 +1,3 @@ +pub mod creds; +pub mod error; +pub mod requests; \ No newline at end of file diff --git a/crates/lib/api_clients/openart_client/src/requests/get_session_request.rs b/crates/lib/api_clients/openart_client/src/requests/get_session_request.rs new file mode 100644 index 0000000000..23b8934e0e --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/requests/get_session_request.rs @@ -0,0 +1,165 @@ +/* +curl 'https://openart.ai/api/auth/session' --compressed + -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0' + -H 'Accept: * / *' + -H 'Accept-Language: en-US,en;q=0.5' + -H 'Accept-Encoding: gzip, deflate, br, zstd' + -H 'Referer: https://openart.ai/create?mode=edit&imageAction=changeBackground&action_mode=background_manual_remove' + -H 'Content-Type: application/json' + -H 'Connection: keep-alive' + -H 'Cookie: AMP_3e2fda7a5c=JTdCJTIyZGV2aWNlSWQlMjIlM0ElMjJmZmRkZTQ2MS1jMmVlLTRmNmUtYmQwMi0zNDcwYjY1OWNkMTklMjIlMkMlMjJ1c2VySWQlMjIlM0ElMjI2YWJ1ejg1Y1JvMWlWQW1rNlJqZiUyMiUyQyUyMnNlc3Npb25JZCUyMiUzQTE3NDg1ODQyODY0NzclMkMlMjJvcHRPdXQlMjIlM0FmYWxzZSUyQyUyMmxhc3RFdmVudFRpbWUlMjIlM0ExNzQ4NTg0NTU5NzEyJTJDJTIybGFzdEV2ZW50SWQlMjIlM0EzNjglMkMlMjJwYWdlQ291bnRlciUyMiUzQTklN0Q=; AMP_MKTG_3e2fda7a5c=JTdCJTIycmVmZXJyZXIlMjIlM0ElMjJodHRwcyUzQSUyRiUyRnd3dy5nb29nbGUuY29tJTJGJTIyJTJDJTIycmVmZXJyaW5nX2RvbWFpbiUyMiUzQSUyMnd3dy5nb29nbGUuY29tJTIyJTdE; __client_uat=0; __client_uat_Xt7g_-Hi=0; utm_params={%22utm_source%22:%22organic%22%2C%22utm_medium%22:%22Google%22%2C%22utm_campaign%22:%22oa_unknown%22%2C%22utm_term%22:%22oa_unknown%22%2C%22utm_content%22:%22oa_unknown%22}; themeMode=dark; themeDirection=ltr; themeColorPresets=default; themeLayout=horizontal; themeContrast=default; themeStretch=false; unique_device_id=f1f0b393-815f-4f95-aaa2-65a3b94530f6; __Host-next-auth.csrf-token=ea06d49af6d70cbe0d15774565708d6c394790c5ab2610276bb0bf29a4afb47b%7Cbdbf15d7f2bf44c83586c7ab1ec47ea6c114b26ccc8e20ae45f40f06c0377389; __Secure-next-auth.callback-url=https%3A%2F%2Fopenart.ai%2Fcreate; __Secure-next-auth.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..mzdySrH34fIj41NP.ky2ZVPIugA1EWSKL29EEEKvSxfG4LCo-R7rN-yLzLo-2LmCrFzVe15BCH2MYg90cwkdIdm1Hi-7U4BcnxG0x662UrU9RDw2yX_ZTZge6Kz70-pg1TaVvKWOS_Gibv8ERSK6MHTfqlx4WNvHccOOfDIWhN87zbLXHCbWnexgmBOB3XfMA96Hby55JNgDM3-_JPcg1lFNkT8oAW562FUZxM9EMzKD4-A4Ee1ZpVd0Z51k4lqfS4XED-0xT6xagsCd6CdwEHEHc0paIAj34Kb_lCO2nyrxGBYvx1XlVHjvXdtakrTqe6jrOuV5rQ0iEO6Xk6cibcZydMV4GssKPEwUDS718AJCxNUK9yHi4RRyfYus.bhvOrrfLdszMcHfJ3Ex2wA' + -H 'Sec-Fetch-Dest: empty' + -H 'Sec-Fetch-Mode: cors' + -H 'Sec-Fetch-Site: same-origin' + -H 'Priority: u=4' + -H 'Pragma: no-cache' + -H 'Cache-Control: no-cache' + -H 'TE: trailers' + */ + +use crate::creds::openart_credentials::OpenArtCredentials; +use crate::error::api_error::ApiError; +use crate::error::classify_http_error_status_code_and_body::classify_http_error_status_code_and_body; +use crate::error::client_error::ClientError; +use crate::error::openart_error::OpenArtError; +use chrono::{DateTime, Utc}; +use log::{debug, error, info}; +use reqwest::Client; +use serde_derive::Deserialize; + +const SESSION_URL : &str = "https://openart.ai/api/auth/session"; + +#[derive(Debug, Clone)] +pub struct SessionDetails { + /// This is either a session ID, user ID, or subscription ID. + /// It is passed as the header `X-USER-ID` in other requests. + pub sub: Option, + + pub email: Option, + pub name: Option, + pub image: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct RawSession { + user: Option, + expires: Option>, + /// This is either a session ID, user ID, or subscription ID. + /// It is passed as the header `X-USER-ID` in other requests. + sub: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct RawUser { + name: Option, + email: Option, + image: Option, +} + +pub async fn get_session_request(creds: &OpenArtCredentials) -> Result { + + let cookies = match creds.cookies.as_ref() { + Some(cookies) => cookies, + None => { + error!("Failed to request session. No cookies in credentials."); + return Err(ClientError::NoCookiesInCredentials.into()); + } + }; + + let client = Client::builder() + .gzip(true) + .build() + .map_err(|err| { + error!("Failed to create HTTP client: {}", err); + OpenArtError::Client(ClientError::ReqwestError(err)) + })?; + + info!("Getting session details from cookies... (cookie payload length: {})", cookies.as_str().len()); + + let mut http_request= client.get(SESSION_URL) + .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0") + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip, deflate, br") + .header("Accept-Language", "en-US,en;q=0.5") + .header("Cookie", cookies.as_str()); + + let http_request = http_request.build() + .map_err(|err| ApiError::ReqwestError(err))?; + + let response = client.execute(http_request) + .await + .map_err(|err| ApiError::ReqwestError(err))?; + + let status = response.status(); + + let response_body = &response.text() + .await + .map_err(|err| { + error!("Error reading body while attempting to read session details: {:?}", err); + ApiError::ReqwestError(err) + })?; + + if !status.is_success() { + error!("Failed to get session details: {} ; body = {}", status, response_body); + let error = classify_http_error_status_code_and_body(status, &response_body).await; + return Err(error); + } + + debug!("Session info response was 200. Body: {}", response_body); + + if response_body.is_empty() || response_body == "{}" { + error!("Received empty response body when requesting session details."); + return Err(OpenArtError::Api(ApiError::InvalidSession)); + } + + let session : RawSession = serde_json::from_str(response_body) + .map_err(|err| { + error!("Failed to parse session details: {} body: {}", err, response_body); + OpenArtError::Api(ApiError::CouldNotParseSession { error: err, body: response_body.to_string() } ) + })?; + + Ok(SessionDetails { + sub: session.sub, + email: session.user.as_ref().and_then(|u| u.email.clone()), + name: session.user.as_ref().and_then(|u| u.name.clone()), + image: session.user.as_ref().and_then(|u| u.image.clone()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::openart_cookies::OpenArtCookies; + use crate::creds::openart_credentials::OpenArtCredentials; + + #[tokio::test] + #[ignore] // Do not run in CI. Run manully to test session retrieval. + async fn invalid_session() { + let creds = OpenArtCredentials { + cookies: Some(OpenArtCookies::new("".to_string())), + session_info: None, + }; + + let result = get_session_request(&creds).await; + + println!("Result: {:?}", result); + + assert!(result.is_err()); + } + + #[tokio::test] + #[ignore] // Do not run in CI. Run manully to test session retrieval. + async fn valid_session() { + let cookie = "AMP_3e2fda7a5c=JTdCJTIyZGV2aWNlSWQlMjIlM0ElMjJmZmRkZTQ2MS1jMmVlLTRmNmUtYmQwMi0zNDcwYjY1OWNkMTklMjIlMkMlMjJ1c2VySWQlMjIlM0ElMjI2YWJ1ejg1Y1JvMWlWQW1rNlJqZiUyMiUyQyUyMnNlc3Npb25JZCUyMiUzQTE3NDg1ODQyODY0NzclMkMlMjJvcHRPdXQlMjIlM0FmYWxzZSUyQyUyMmxhc3RFdmVudFRpbWUlMjIlM0ExNzQ4NTg0NTU5NzEyJTJDJTIybGFzdEV2ZW50SWQlMjIlM0EzNjglMkMlMjJwYWdlQ291bnRlciUyMiUzQTklN0Q=; AMP_MKTG_3e2fda7a5c=JTdCJTIycmVmZXJyZXIlMjIlM0ElMjJodHRwcyUzQSUyRiUyRnd3dy5nb29nbGUuY29tJTJGJTIyJTJDJTIycmVmZXJyaW5nX2RvbWFpbiUyMiUzQSUyMnd3dy5nb29nbGUuY29tJTIyJTdE; __client_uat=0; __client_uat_Xt7g_-Hi=0; utm_params={%22utm_source%22:%22organic%22%2C%22utm_medium%22:%22Google%22%2C%22utm_campaign%22:%22oa_unknown%22%2C%22utm_term%22:%22oa_unknown%22%2C%22utm_content%22:%22oa_unknown%22}; themeMode=dark; themeDirection=ltr; themeColorPresets=default; themeLayout=horizontal; themeContrast=default; themeStretch=false; unique_device_id=f1f0b393-815f-4f95-aaa2-65a3b94530f6; __Host-next-auth.csrf-token=ea06d49af6d70cbe0d15774565708d6c394790c5ab2610276bb0bf29a4afb47b%7Cbdbf15d7f2bf44c83586c7ab1ec47ea6c114b26ccc8e20ae45f40f06c0377389; __Secure-next-auth.callback-url=https%3A%2F%2Fopenart.ai%2Fcreate; __Secure-next-auth.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..mzdySrH34fIj41NP.ky2ZVPIugA1EWSKL29EEEKvSxfG4LCo-R7rN-yLzLo-2LmCrFzVe15BCH2MYg90cwkdIdm1Hi-7U4BcnxG0x662UrU9RDw2yX_ZTZge6Kz70-pg1TaVvKWOS_Gibv8ERSK6MHTfqlx4WNvHccOOfDIWhN87zbLXHCbWnexgmBOB3XfMA96Hby55JNgDM3-_JPcg1lFNkT8oAW562FUZxM9EMzKD4-A4Ee1ZpVd0Z51k4lqfS4XED-0xT6xagsCd6CdwEHEHc0paIAj34Kb_lCO2nyrxGBYvx1XlVHjvXdtakrTqe6jrOuV5rQ0iEO6Xk6cibcZydMV4GssKPEwUDS718AJCxNUK9yHi4RRyfYus.bhvOrrfLdszMcHfJ3Ex2wA"; + let creds = OpenArtCredentials { + cookies: Some(OpenArtCookies::new(cookie.to_string())), + session_info: None, + }; + + let result = get_session_request(&creds).await; + + println!("Result: {:?}", result); + + assert!(result.is_ok()); + } +} diff --git a/crates/lib/api_clients/openart_client/src/requests/mod.rs b/crates/lib/api_clients/openart_client/src/requests/mod.rs new file mode 100644 index 0000000000..c591afe82a --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/requests/mod.rs @@ -0,0 +1,2 @@ +pub mod get_session_request; +pub mod upload_image_from_file_request; \ No newline at end of file diff --git a/crates/lib/api_clients/openart_client/src/requests/upload_image_from_file_request.rs b/crates/lib/api_clients/openart_client/src/requests/upload_image_from_file_request.rs new file mode 100644 index 0000000000..f688f5cf17 --- /dev/null +++ b/crates/lib/api_clients/openart_client/src/requests/upload_image_from_file_request.rs @@ -0,0 +1,174 @@ + +/* +curl 'https://openart.ai/api/media/upload_raw_image' --compressed + -X POST + -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0' + -H 'Accept: application/json, text/plain, * / *' + -H 'Accept-Language: en-US,en;q=0.5' + -H 'Accept-Encoding: gzip, deflate, br, zstd' + -H 'X-USER-ID: 6abuz85cRo1iVAmk6Rjf' + -H 'Content-Type: multipart/form-data; boundary=----geckoformboundary1bbe4d86f5d33a00827b7d293784b7ec' + -H 'Origin: https://openart.ai' + -H 'Connection: keep-alive' + -H 'Referer: https://openart.ai/create?mode=edit&imageAction=changeBackground&action_mode=background_manual_remove' + -H 'Cookie: AMP_3e2fda7a5c=JTdCJTIyZGV2aWNlSWQlMjIlM0ElMjJmZmRkZTQ2MS1jMmVlLTRmNmUtYmQwMi0zNDcwYjY1OWNkMTklMjIlMkMlMjJ1c2VySWQlMjIlM0ElMjI2YWJ1ejg1Y1JvMWlWQW1rNlJqZiUyMiUyQyUyMnNlc3Npb25JZCUyMiUzQTE3NDg2MTI1MjI4NjglMkMlMjJvcHRPdXQlMjIlM0FmYWxzZSUyQyUyMmxhc3RFdmVudFRpbWUlMjIlM0ExNzQ4NjEyNTIzMDI4JTJDJTIybGFzdEV2ZW50SWQlMjIlM0EzNzAlMkMlMjJwYWdlQ291bnRlciUyMiUzQTElN0Q=; AMP_MKTG_3e2fda7a5c=JTdCJTIycmVmZXJyZXIlMjIlM0ElMjJodHRwcyUzQSUyRiUyRnd3dy5nb29nbGUuY29tJTJGJTIyJTJDJTIycmVmZXJyaW5nX2RvbWFpbiUyMiUzQSUyMnd3dy5nb29nbGUuY29tJTIyJTdE; __client_uat=0; __client_uat_Xt7g_-Hi=0; utm_params={%22utm_source%22:%22organic%22%2C%22utm_medium%22:%22Google%22%2C%22utm_campaign%22:%22oa_unknown%22%2C%22utm_term%22:%22oa_unknown%22%2C%22utm_content%22:%22oa_unknown%22}; themeMode=dark; themeDirection=ltr; themeColorPresets=default; themeLayout=horizontal; themeContrast=default; themeStretch=false; unique_device_id=f1f0b393-815f-4f95-aaa2-65a3b94530f6; __Host-next-auth.csrf-token=ea06d49af6d70cbe0d15774565708d6c394790c5ab2610276bb0bf29a4afb47b%7Cbdbf15d7f2bf44c83586c7ab1ec47ea6c114b26ccc8e20ae45f40f06c0377389; __Secure-next-auth.callback-url=https%3A%2F%2Fopenart.ai%2Fcreate; __Secure-next-auth.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..3_1LkfPK4sWQd3GW.KZ7yqoe7fdPEDoTrekIxFIxs5hZVn9Rg8WfsS4tD4Ana6MQmn_LnFhvF-cKAd187Yh3ULWozsFpZAOIQbnndPPsZ5sYu5pYXbQypN5NGC_movTNVQOvzp6nv5r4l0Of1vMslik_wa-LPZZc-blTNGeW-E9Zy35IpK2gHruTAXV_HPfw9uA9OA_RlI3BDxveqnooBdJ1PiU7fJ_MQe6A8Q1XyJ1OtyatXlIBnvrCVuHDPS0ChZbnUA7OTD_d0O8OVyy5yAo-qGer9j_kLg8aC0e-96DUOHq7hwjQyfMDssPVfv1vuArwsZXUThfsb60DGYjDnTIsZlzru-eTghxjrJJuUo7PYRi1A71E4Nu69Ds0.dKUcrC_sRmBDIzp_lew8EQ' + -H 'Sec-Fetch-Dest: empty' + -H 'Sec-Fetch-Mode: cors' + -H 'Sec-Fetch-Site: same-origin' + -H 'Pragma: no-cache' + -H 'Cache-Control: no-cache' + -H 'TE: trailers' --data-binary $'------geckoformboundary1bbe4d86f5d33a00827b7d293784b7ec\r\n +// +// Content-Disposition: form-data; name="file"; filename="blob"\r\nContent-Type: image/png\r\n\r\n------geckoformboundary1bbe4d86f5d33a00827b7d293784b7ec--\r\n' + +{"imageUrl":"https://cdn.openart.ai/production/2025-05/uploads/anonymous/image_A-0v2jOD_1536x1024_1748612534709.png","width":1536,"height":1024} + */ + +#[derive(Clone, Debug, Deserialize)] +struct RawUploadResponse { + #[serde(rename = "imageUrl")] + image_url: Option, + width: Option, + height: Option, +} + +use crate::creds::openart_credentials::OpenArtCredentials; +use crate::error::client_error::ClientError; +use crate::error::openart_error::OpenArtError; +use log::info; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use std::path::Path; +use std::time::Duration; +use serde::{Deserialize, Serialize}; +use tokio::fs::File; +use tokio::io::AsyncReadExt; +use crate::error::api_error::ApiError; +use crate::error::classify_http_error_response::classify_http_error_response; + +/// Try to prevent buffer reallocations. +/// There's a better way to implement this. +const INITIAL_BUFFER_SIZE : usize = 1024*1024; + +const UPLOAD_URL: &str = "https://openart.ai/api/media/upload_raw_image"; + +pub async fn upload_image_from_file_request>( + file_path: P, + creds: &OpenArtCredentials, + maybe_timeout: Option, +) -> Result<(), OpenArtError> { + + let filename = file_path.as_ref().file_name() + .ok_or_else(|| OpenArtError::Client(ClientError::Other("Could not determine filename from path".to_string())))? + .to_string_lossy() + .to_string(); + + let mut file = File::open(&file_path) + .await + .map_err(|err| OpenArtError::Client(ClientError::IoError(err)))?; + + let mut file_bytes = Vec::with_capacity(INITIAL_BUFFER_SIZE); + + file.read_to_end(&mut file_bytes) + .await + .map_err(|err| OpenArtError::Client(ClientError::IoError(err)))?; + + // TODO: Read file magic bytes first, then fall back to this. + let mime_type = match file_path.as_ref().extension().and_then(|e| e.to_str()) { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + // Some("webp") => "image/webp", + // Some("gif") => "image/gif", + // Some("mp4") => "video/mp4", + // Some("mov") => "video/quicktime", + // Some("webm") => "video/webm", + _ => "application/octet-stream", + }; + + // Create multipart form + let part = Part::bytes(file_bytes) // NB: Reqwest needs to own the bytes. + .file_name(filename) // NB: Reqwest needs to own the bytes + .mime_str(mime_type) + .map_err(|err| OpenArtError::Client(ClientError::ReqwestError(err)))?; + + let form = Form::new().part("file", part); + + let cookie = creds.cookies.as_ref() + .map(|cookies| cookies.to_string()) + .ok_or_else(|| OpenArtError::Client(ClientError::NoCookiesInCredentials))?; + + let session_id = creds.session_info.as_ref() + .map(|info| info.sub.clone()) + .flatten() + .ok_or_else(|| OpenArtError::Client(ClientError::NoSessionInfoInCredentials))?; + + // Make API request + let client = Client::new(); + let mut request_builder = client.post(UPLOAD_URL) + .multipart(form) + .header("X-USER-ID", session_id) + .header("Cookie", &cookie); + + if let Some(timeout) = maybe_timeout { + request_builder = request_builder.timeout(timeout); + } + + let response = request_builder + .send() + .await + .map_err(|err| OpenArtError::Api(ApiError::ReqwestError(err)))?; + + // Check response status + if !response.status().is_success() { + info!("Error uploading image: {:?}", response.status()); + let error = classify_http_error_response(response).await; + return Err(error); + } + + // Parse response + let upload_response = response.json::() + .await + .map_err(|err| OpenArtError::Api(ApiError::ReqwestError(err)))?; + + println!("Uploaded data: {:?}", upload_response); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use errors::AnyhowResult; + use testing::test_file_path::test_file_path; + use crate::creds::openart_cookies::OpenArtCookies; + use crate::creds::openart_credentials::OpenArtCredentials; + use crate::creds::openart_session_info::OpenArtSessionInfo; + use crate::requests::get_session_request::{get_session_request, SessionDetails}; + use crate::requests::upload_image_from_file_request::upload_image_from_file_request; + + #[tokio::test] + #[ignore] // Do not run in CI. Run manully to test session retrieval. + async fn test() -> AnyhowResult<()> { + let cookie = "AMP_3e2fda7a5c=JTdCJTIyZGV2aWNlSWQlMjIlM0ElMjJmZmRkZTQ2MS1jMmVlLTRmNmUtYmQwMi0zNDcwYjY1OWNkMTklMjIlMkMlMjJ1c2VySWQlMjIlM0ElMjI2YWJ1ejg1Y1JvMWlWQW1rNlJqZiUyMiUyQyUyMnNlc3Npb25JZCUyMiUzQTE3NDg1ODQyODY0NzclMkMlMjJvcHRPdXQlMjIlM0FmYWxzZSUyQyUyMmxhc3RFdmVudFRpbWUlMjIlM0ExNzQ4NTg0NTU5NzEyJTJDJTIybGFzdEV2ZW50SWQlMjIlM0EzNjglMkMlMjJwYWdlQ291bnRlciUyMiUzQTklN0Q=; AMP_MKTG_3e2fda7a5c=JTdCJTIycmVmZXJyZXIlMjIlM0ElMjJodHRwcyUzQSUyRiUyRnd3dy5nb29nbGUuY29tJTJGJTIyJTJDJTIycmVmZXJyaW5nX2RvbWFpbiUyMiUzQSUyMnd3dy5nb29nbGUuY29tJTIyJTdE; __client_uat=0; __client_uat_Xt7g_-Hi=0; utm_params={%22utm_source%22:%22organic%22%2C%22utm_medium%22:%22Google%22%2C%22utm_campaign%22:%22oa_unknown%22%2C%22utm_term%22:%22oa_unknown%22%2C%22utm_content%22:%22oa_unknown%22}; themeMode=dark; themeDirection=ltr; themeColorPresets=default; themeLayout=horizontal; themeContrast=default; themeStretch=false; unique_device_id=f1f0b393-815f-4f95-aaa2-65a3b94530f6; __Host-next-auth.csrf-token=ea06d49af6d70cbe0d15774565708d6c394790c5ab2610276bb0bf29a4afb47b%7Cbdbf15d7f2bf44c83586c7ab1ec47ea6c114b26ccc8e20ae45f40f06c0377389; __Secure-next-auth.callback-url=https%3A%2F%2Fopenart.ai%2Fcreate; __Secure-next-auth.session-token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..mzdySrH34fIj41NP.ky2ZVPIugA1EWSKL29EEEKvSxfG4LCo-R7rN-yLzLo-2LmCrFzVe15BCH2MYg90cwkdIdm1Hi-7U4BcnxG0x662UrU9RDw2yX_ZTZge6Kz70-pg1TaVvKWOS_Gibv8ERSK6MHTfqlx4WNvHccOOfDIWhN87zbLXHCbWnexgmBOB3XfMA96Hby55JNgDM3-_JPcg1lFNkT8oAW562FUZxM9EMzKD4-A4Ee1ZpVd0Z51k4lqfS4XED-0xT6xagsCd6CdwEHEHc0paIAj34Kb_lCO2nyrxGBYvx1XlVHjvXdtakrTqe6jrOuV5rQ0iEO6Xk6cibcZydMV4GssKPEwUDS718AJCxNUK9yHi4RRyfYus.bhvOrrfLdszMcHfJ3Ex2wA"; + + let mut creds = OpenArtCredentials { + cookies: Some(OpenArtCookies::new(cookie.to_string())), + session_info: None, + }; + + let session_details = get_session_request(&creds).await.unwrap(); + + creds.session_info = Some(OpenArtSessionInfo { + sub: session_details.sub.clone(), + email: None, + name: None, + image: None, + }); + + let image_path = test_file_path("test_data/image/juno.jpg")?; // media_01jqyqgqpwf40tkcapq5bmaz5d + + let result = upload_image_from_file_request(image_path, &creds, None).await; + + assert!(result.is_ok()); + Ok(()) + } +} \ No newline at end of file