mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
client identity configs
This commit is contained in:
Generated
+9
@@ -262,6 +262,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
"artcraft_client_identity",
|
||||
"backoff",
|
||||
"chrono",
|
||||
"cloudflare_errors",
|
||||
@@ -282,6 +283,13 @@ dependencies = [
|
||||
"uuid_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft_client_identity"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft_router"
|
||||
version = "0.0.1"
|
||||
@@ -7018,6 +7026,7 @@ dependencies = [
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.4.3"
|
||||
dependencies = [
|
||||
"artcraft_client_identity",
|
||||
"bytes",
|
||||
"cookie_store 0.21.1",
|
||||
"data-url",
|
||||
|
||||
@@ -16,6 +16,7 @@ members = [
|
||||
"crates/api_clients/worldlabs_api_client",
|
||||
"crates/api_clients/worldlabs_consumer_client",
|
||||
"crates/desktop/artcraft",
|
||||
"crates/lib/artcraft_client_identity",
|
||||
"crates/lib/browser_emulation",
|
||||
"crates/lib/build_metadata",
|
||||
"crates/lib/caching/memory_store",
|
||||
@@ -49,6 +50,7 @@ publish = false
|
||||
[workspace.dependencies]
|
||||
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
|
||||
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
|
||||
artcraft_client_identity = { path = "crates/lib/artcraft_client_identity" }
|
||||
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
|
||||
browser_emulation = { path = "crates/lib/browser_emulation" }
|
||||
cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" }
|
||||
|
||||
@@ -19,6 +19,7 @@ path = "src/lib.rs"
|
||||
|
||||
# Internal
|
||||
artcraft_api_defs.workspace = true
|
||||
artcraft_client_identity.workspace = true
|
||||
cloudflare_errors.workspace = true
|
||||
enums.workspace = true
|
||||
errors.workspace = true
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToke
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let response = http_get_anonymous(url).await?;
|
||||
let response = http_get_anonymous(api_host, url).await?;
|
||||
let response = filter_bad_response(response).await?;
|
||||
let response_body = &response.text().await
|
||||
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;
|
||||
|
||||
+3
-5
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
@@ -56,8 +56,7 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
|
||||
|
||||
//let mut file = File::open(path)?;
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
@@ -86,7 +85,6 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
|
||||
+3
-5
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -11,7 +12,6 @@ use chrono::{DateTime, Utc};
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
@@ -72,8 +72,7 @@ pub async fn upload_image_media_file_from_bytes(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
@@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_bytes(
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
|
||||
+3
-5
@@ -3,7 +3,8 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider;
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
@@ -65,8 +65,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
|
||||
|
||||
//let mut file = File::open(path)?;
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
@@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
|
||||
+3
-5
@@ -1,7 +1,8 @@
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -9,7 +10,6 @@ use chrono::{DateTime, Utc};
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
@@ -32,8 +32,7 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()?;
|
||||
|
||||
let file_bytes = std::fs::read(path.as_ref())?;
|
||||
@@ -49,7 +48,6 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = maybe_creds {
|
||||
|
||||
+3
-5
@@ -1,7 +1,8 @@
|
||||
use crate::credentials::storyteller_credential_set::StorytellerCredentialSet;
|
||||
use crate::error::api_error::ApiError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use crate::utils::http_get_anonymous::http_get_anonymous;
|
||||
use enums::common::generation_provider::GenerationProvider;
|
||||
@@ -9,7 +10,6 @@ use chrono::{DateTime, Utc};
|
||||
use uuid_utils::uuid::generate_random_uuid;
|
||||
use log::debug;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use reqwest::Client;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokens::tokens::batch_generations::BatchGenerationToken;
|
||||
@@ -58,8 +58,7 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(args.api_host)
|
||||
.build()?;
|
||||
|
||||
let file_bytes = std::fs::read(args.path.as_ref())?;
|
||||
@@ -78,7 +77,6 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
|
||||
}
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = args.maybe_creds {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
use artcraft_client_identity::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
|
||||
|
||||
const HTTP_SCHEME: &str = "http";
|
||||
|
||||
const HTTPS_SCHEME: &str = "https";
|
||||
@@ -27,6 +29,15 @@ impl ApiHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Origin` the desktop client declares to this API.
|
||||
pub fn request_origin(&self) -> &'static str {
|
||||
match self {
|
||||
ApiHost::Storyteller => ARTCRAFT_DESKTOP_ORIGIN,
|
||||
ApiHost::FakeYou => ARTCRAFT_DESKTOP_ORIGIN,
|
||||
ApiHost::Localhost { .. } => ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scheme(&self) -> &'static str {
|
||||
match self {
|
||||
ApiHost::Storyteller => HTTPS_SCHEME,
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -21,13 +21,11 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
|
||||
|
||||
info!("DELETE {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.delete(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON)
|
||||
.header("Content-Type", APPLICATION_JSON);
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -20,13 +20,11 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = maybe_creds {
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -30,13 +30,11 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
|
||||
info!("Requesting {:?}", &url);
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON)
|
||||
.header("Content-Type", APPLICATION_JSON);
|
||||
|
||||
|
||||
+3
-5
@@ -3,11 +3,11 @@ use crate::error::api_error::ApiError;
|
||||
use crate::error::client_error::ClientError;
|
||||
use crate::error::storyteller_error::StorytellerError;
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
|
||||
use crate::utils::constants::APPLICATION_JSON;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
use crate::utils::filter_bad_response::filter_bad_response;
|
||||
use errors::AnyhowResult;
|
||||
use log::{debug, info};
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -26,13 +26,11 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
|
||||
|
||||
debug!("Requesting {:?}", &url);
|
||||
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()
|
||||
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
|
||||
|
||||
let mut request_builder = client.post(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", APPLICATION_JSON);
|
||||
|
||||
if let Some(creds) = maybe_creds {
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
|
||||
pub const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
|
||||
pub const APPLICATION_JSON : &str = "application/json";
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use crate::error::api_error::ApiError;
|
||||
use reqwest::Client;
|
||||
const USER_AGENT: &str = "storyteller-client/1.0";
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
|
||||
pub async fn http_get_anonymous(url: String) -> Result<reqwest::Response, ApiError> {
|
||||
let client = Client::builder()
|
||||
.gzip(true)
|
||||
pub async fn http_get_anonymous(api_host: &ApiHost, url: String) -> Result<reqwest::Response, ApiError> {
|
||||
let client = storyteller_client_builder(api_host)
|
||||
.build()?;
|
||||
|
||||
let response = client.get(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "application/json")
|
||||
//.header("Accept-Encoding", "gzip, deflate, br")
|
||||
.send()
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::de::DeserializeOwned;
|
||||
use url::Url;
|
||||
|
||||
use crate::utils::api_host::ApiHost;
|
||||
use crate::utils::constants::USER_AGENT;
|
||||
use crate::utils::storyteller_client_builder::storyteller_client_builder;
|
||||
|
||||
/// Native transport for the session bridge. No request/response bodies, cookies,
|
||||
/// or challenge credentials are logged, even at debug level. Never follows redirects.
|
||||
@@ -25,14 +25,13 @@ pub struct LoginChallengeClientError {
|
||||
|
||||
impl LoginChallengeClient {
|
||||
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
|
||||
let mut builder = Client::builder();
|
||||
let mut builder = storyteller_client_builder(api_host);
|
||||
if matches!(api_host, ApiHost::Localhost { .. }) {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
let client = builder
|
||||
.timeout(Duration::from_secs(15))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
|
||||
Ok(Self {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod basic_json_post_request;
|
||||
pub (crate) mod basic_query_string_post_request;
|
||||
pub (crate) mod filter_bad_response;
|
||||
pub (crate) mod http_get_anonymous;
|
||||
pub (crate) mod storyteller_client_builder;
|
||||
pub mod api_host;
|
||||
pub mod constants;
|
||||
pub mod status_codes;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use artcraft_client_identity::user_agents::ARTCRAFT_DESKTOP_USER_AGENT;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ORIGIN};
|
||||
use reqwest::{Client, ClientBuilder};
|
||||
|
||||
use crate::utils::api_host::ApiHost;
|
||||
|
||||
/// Every request to our API is built from this so the desktop client identifies itself
|
||||
/// consistently (`User-Agent` and `Origin`). Only use it for Storyteller / ArtCraft APIs.
|
||||
pub(crate) fn storyteller_client_builder(api_host: &ApiHost) -> ClientBuilder {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(ORIGIN, HeaderValue::from_static(api_host.request_origin()));
|
||||
|
||||
Client::builder()
|
||||
.user_agent(ARTCRAFT_DESKTOP_USER_AGENT)
|
||||
.default_headers(headers)
|
||||
.gzip(true)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "artcraft_client_identity"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "artcraft_client_identity"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
# External
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# (None)
|
||||
@@ -0,0 +1,166 @@
|
||||
use url::{Host, Url};
|
||||
|
||||
use crate::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
|
||||
use crate::third_party_provider::{ThirdPartyProvider, THIRD_PARTY_PROVIDERS};
|
||||
|
||||
/// Hostnames of our deployed APIs (HTTPS only).
|
||||
const ARTCRAFT_API_HOSTNAMES: [&str; 3] = [
|
||||
"api.storyteller.ai",
|
||||
"api.getartcraft.com",
|
||||
"api.fakeyou.com",
|
||||
];
|
||||
|
||||
/// Domains we own. Subdomains match too (CDNs, uploads, websites).
|
||||
const ARTCRAFT_DOMAINS: [&str; 4] = [
|
||||
"storyteller.ai",
|
||||
"getartcraft.com",
|
||||
"fakeyou.com",
|
||||
"artcraft.ai",
|
||||
];
|
||||
|
||||
/// Who a request is going to, which decides how the desktop app identifies itself.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Destination {
|
||||
/// Our HTTP API, deployed or running locally, and the `Origin` to declare to it.
|
||||
ArtcraftApi { origin: &'static str },
|
||||
/// Our other hosts: CDNs, uploads, websites.
|
||||
ArtcraftHost,
|
||||
/// A known third-party provider.
|
||||
ThirdParty(&'static ThirdPartyProvider),
|
||||
/// Anything else.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Destination {
|
||||
pub fn classify(destination: &Url) -> Self {
|
||||
if is_local_development_api(destination) {
|
||||
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN };
|
||||
}
|
||||
let Some(host) = destination.host_str() else {
|
||||
return Self::Unknown;
|
||||
};
|
||||
if destination.scheme() == "https" && ARTCRAFT_API_HOSTNAMES.contains(&host) {
|
||||
return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_ORIGIN };
|
||||
}
|
||||
if ARTCRAFT_DOMAINS.iter().any(|domain| is_domain_or_subdomain(host, domain)) {
|
||||
return Self::ArtcraftHost;
|
||||
}
|
||||
THIRD_PARTY_PROVIDERS
|
||||
.iter()
|
||||
.find(|provider| provider.domains.iter().any(|domain| is_domain_or_subdomain(host, domain)))
|
||||
.map_or(Self::Unknown, Self::ThirdParty)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_local_development_api(destination: &Url) -> bool {
|
||||
if !matches!(destination.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
match destination.host() {
|
||||
Some(Host::Domain("localhost")) => true,
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_domain_or_subdomain(host: &str, domain: &str) -> bool {
|
||||
host == domain
|
||||
|| host
|
||||
.strip_suffix(domain)
|
||||
.is_some_and(|prefix| prefix.ends_with('.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod artcraft {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployed_apis_get_desktop_origin() {
|
||||
for destination in [
|
||||
"https://api.storyteller.ai/v1/session",
|
||||
"https://api.getartcraft.com/v1/session",
|
||||
"https://api.fakeyou.com/v1/session",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "https://desktop.getartcraft.com" });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_apis_get_development_origin() {
|
||||
for destination in [
|
||||
"http://localhost:12345/v1/session",
|
||||
"https://localhost:12345/v1/session",
|
||||
"http://127.0.0.1:12345/v1/session",
|
||||
"http://[::1]:12345/v1/session",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "http://localhost" });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_owned_hosts_are_not_apis() {
|
||||
for destination in [
|
||||
"https://cdn-2.fakeyou.com/media/a/b.png",
|
||||
"https://cdn.storyteller.ai/media/a/b.png",
|
||||
"https://style.storyteller.ai/",
|
||||
"https://app.getartcraft.com/",
|
||||
"https://getartcraft.com/",
|
||||
"http://api.storyteller.ai/v1/session",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::ArtcraftHost, "{destination}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod third_party {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn providers_get_their_own_origins() {
|
||||
for (destination, name, origin) in [
|
||||
("https://grok.com/rest/app-chat/conversations/new", "Grok", "https://grok.com"),
|
||||
("https://assets.grok.com/users/a/b.mp4", "Grok", "https://grok.com"),
|
||||
("https://imagine-public.x.ai/a.png", "Grok", "https://grok.com"),
|
||||
("https://www.midjourney.com/api/submit-jobs", "Midjourney", "https://www.midjourney.com"),
|
||||
("https://cdn.midjourney.com/a/0_0.png", "Midjourney", "https://www.midjourney.com"),
|
||||
("https://sora.chatgpt.com/backend/nf/create", "Sora", "https://sora.chatgpt.com"),
|
||||
("https://videos.openai.com/a.mp4", "Sora", "https://sora.chatgpt.com"),
|
||||
("https://chatgpt.com/backend-api/sentinel", "ChatGPT", "https://chatgpt.com"),
|
||||
("https://api.worldlabs.ai/api/v1/worlds", "World Labs", "https://marble.worldlabs.ai"),
|
||||
("https://static.kinovi.ai/a.png", "Kinovi", "https://kinovi.ai"),
|
||||
("https://static.seedance2-pro.com/materials/a.png", "Seedance 2 Pro", "https://seedance2-pro.com"),
|
||||
("https://v3b.fal.media/files/a.png", "fal", "https://fal.ai"),
|
||||
("https://console.gmicloud.ai/api", "GMI Cloud", "https://console.gmicloud.ai"),
|
||||
] {
|
||||
let Destination::ThirdParty(provider) = classify(destination) else {
|
||||
panic!("{destination} is not a third party");
|
||||
};
|
||||
assert_eq!((provider.name, provider.origin), (name, origin));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookalikes_and_others_are_unknown() {
|
||||
for destination in [
|
||||
"https://api.storyteller.ai.example.com/v1/session",
|
||||
"https://notstoryteller.ai/",
|
||||
"https://evilgrok.com/",
|
||||
"https://grok.com.example.com/",
|
||||
"https://www.gravatar.com/avatar/a",
|
||||
"https://storage.googleapis.com/bucket/a.png",
|
||||
"http://192.168.1.2:12345/v1/session",
|
||||
"tauri://localhost",
|
||||
] {
|
||||
assert_eq!(classify(destination), Destination::Unknown, "{destination}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(destination: &str) -> Destination {
|
||||
Destination::classify(&Url::parse(destination).unwrap())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! artcraft_client_identity
|
||||
//!
|
||||
//! How the ArtCraft desktop app identifies itself (`Origin`, `User-Agent`) to each destination.
|
||||
//!
|
||||
//! Our own APIs get the declared ArtCraft desktop identity. Known third-party providers get an
|
||||
//! identity close to their own website, and never ours. The native third-party Rust clients
|
||||
//! (Grok, Midjourney, Sora, etc.) set their own headers and do not use this crate.
|
||||
|
||||
pub mod destination;
|
||||
pub mod origins;
|
||||
pub mod third_party_provider;
|
||||
pub mod user_agents;
|
||||
@@ -0,0 +1,11 @@
|
||||
/// `Origin` sent to our deployed APIs.
|
||||
/// Keep in sync with `actix_cors_configs/src/configs/artcraft_desktop.rs` in artcraft-services.
|
||||
pub const ARTCRAFT_DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com";
|
||||
|
||||
/// `Origin` sent to a locally running API (eg. `http://localhost:12345`).
|
||||
/// Development CORS accepts any `localhost` origin.
|
||||
pub const ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN: &str = "http://localhost";
|
||||
|
||||
/// `Origin` packaged builds historically sent everywhere. Now only sent to our non-API hosts
|
||||
/// (CDNs, websites), where it is left unchanged.
|
||||
pub const LEGACY_DESKTOP_ORIGIN: &str = "https://studio.storyteller.ai";
|
||||
@@ -0,0 +1,30 @@
|
||||
/// A third-party provider, and the `Origin` of its own website that we present to its hosts.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ThirdPartyProvider {
|
||||
pub name: &'static str,
|
||||
/// Registrable domains; subdomains match too.
|
||||
pub domains: &'static [&'static str],
|
||||
pub origin: &'static str,
|
||||
}
|
||||
|
||||
/// Every third party that gets an invented origin. Origins match what each provider's native
|
||||
/// Rust client already sends. First match wins, so more specific entries go first.
|
||||
pub const THIRD_PARTY_PROVIDERS: [ThirdPartyProvider; 9] = [
|
||||
ThirdPartyProvider { name: "Grok", domains: &["grok.com", "x.ai"], origin: "https://grok.com" },
|
||||
ThirdPartyProvider { name: "Midjourney", domains: &["midjourney.com"], origin: "https://www.midjourney.com" },
|
||||
ThirdPartyProvider {
|
||||
name: "Sora",
|
||||
domains: &["sora.chatgpt.com", "sora.com", "openai.com", "oaistatic.com"],
|
||||
origin: "https://sora.chatgpt.com",
|
||||
},
|
||||
ThirdPartyProvider { name: "ChatGPT", domains: &["chatgpt.com"], origin: "https://chatgpt.com" },
|
||||
ThirdPartyProvider {
|
||||
name: "World Labs",
|
||||
domains: &["worldlabs.ai", "wlt-ai.art"],
|
||||
origin: "https://marble.worldlabs.ai",
|
||||
},
|
||||
ThirdPartyProvider { name: "Kinovi", domains: &["kinovi.ai"], origin: "https://kinovi.ai" },
|
||||
ThirdPartyProvider { name: "Seedance 2 Pro", domains: &["seedance2-pro.com"], origin: "https://seedance2-pro.com" },
|
||||
ThirdPartyProvider { name: "fal", domains: &["fal.ai", "fal.run", "fal.media"], origin: "https://fal.ai" },
|
||||
ThirdPartyProvider { name: "GMI Cloud", domains: &["gmicloud.ai"], origin: "https://console.gmicloud.ai" },
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
/// `User-Agent` sent to our own APIs and hosts.
|
||||
///
|
||||
/// TODO: Change to "artcraft-desktop/1.0" once the backend services can parse it.
|
||||
pub const ARTCRAFT_DESKTOP_USER_AGENT: &str = "storyteller-client/1.0";
|
||||
|
||||
/// `User-Agent` sent to third parties: the default of the OS webview Tauri runs on
|
||||
/// (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const WEBVIEW_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const WEBVIEW_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0";
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
pub const WEBVIEW_USER_AGENT: &str =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)";
|
||||
+1
@@ -36,6 +36,7 @@ urlpattern = "0.3"
|
||||
regex = "1"
|
||||
|
||||
[dependencies]
|
||||
artcraft_client_identity = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use artcraft_client_identity::destination::Destination;
|
||||
use artcraft_client_identity::origins::LEGACY_DESKTOP_ORIGIN;
|
||||
use artcraft_client_identity::user_agents::{ARTCRAFT_DESKTOP_USER_AGENT, WEBVIEW_USER_AGENT};
|
||||
use http::{header, HeaderMap, HeaderValue};
|
||||
use url::{Host, Url};
|
||||
|
||||
/// The `User-Agent` to send when the frontend didn't set one.
|
||||
pub(crate) fn artcraft_default_user_agent(destination: &Url) -> &'static str {
|
||||
match Destination::classify(destination) {
|
||||
Destination::ArtcraftApi { .. } | Destination::ArtcraftHost => ARTCRAFT_DESKTOP_USER_AGENT,
|
||||
Destination::ThirdParty(_) | Destination::Unknown => WEBVIEW_USER_AGENT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the webview's own `Origin` (packaged `tauri://localhost` etc., or a Vite loopback
|
||||
/// origin at any port) with the identity chosen for the destination. The native HTTP bridge
|
||||
/// shares the desktop cookie jar, so its identity must not depend on the webview.
|
||||
pub(crate) fn normalize_artcraft_origin(destination: &Url, headers: &mut HeaderMap) {
|
||||
let origin = headers.get(header::ORIGIN).and_then(|value| value.to_str().ok());
|
||||
// Packaged-app webview origins on macOS and Windows.
|
||||
let packaged_origin = headers.get(header::ORIGIN).is_none()
|
||||
|| matches!(origin, Some("null" | "http://tauri.localhost" | "tauri://localhost"));
|
||||
// Development webview origins (Vite on any loopback port).
|
||||
let webview_origin = packaged_origin || origin.is_some_and(is_loopback_origin);
|
||||
|
||||
let replacement = match Destination::classify(destination) {
|
||||
// Our APIs (deployed or local) see the declared desktop identity.
|
||||
Destination::ArtcraftApi { origin } if webview_origin => origin,
|
||||
// Known third parties see an origin close to their own website.
|
||||
Destination::ThirdParty(provider) if webview_origin => provider.origin,
|
||||
// Our CDNs and websites keep the historical packaged-app handling.
|
||||
Destination::ArtcraftHost if packaged_origin => LEGACY_DESKTOP_ORIGIN,
|
||||
// Unknown hosts and ordinary website origins are left untouched.
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// HeaderMap is a multimap; insert replaces all existing Origin values.
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static(replacement));
|
||||
}
|
||||
|
||||
fn is_loopback_origin(origin: &str) -> bool {
|
||||
let Ok(url) = Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
match url.host() {
|
||||
Some(Host::Domain("localhost")) => true,
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const API: &str = "https://api.storyteller.ai/v1/session";
|
||||
const LOCAL_API: &str = "http://localhost:12345/v1/session";
|
||||
const CDN: &str = "https://cdn-2.fakeyou.com/media/a/b.png";
|
||||
const GCS: &str = "https://storage.googleapis.com/bucket/a.png";
|
||||
const GROK: &str = "https://grok.com/rest/app-chat/conversations/new";
|
||||
const MIDJOURNEY: &str = "https://www.midjourney.com/api/submit-jobs";
|
||||
const UNKNOWN: &str = "https://www.gravatar.com/avatar/a";
|
||||
|
||||
const DEV_WEBVIEW_ORIGIN: &str = "http://127.0.0.1:5193";
|
||||
const PACKAGED_ORIGINS: [Option<&str>; 4] =
|
||||
[None, Some("null"), Some("http://tauri.localhost"), Some("tauri://localhost")];
|
||||
|
||||
mod development {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_dynamic_ports_and_loopback_hosts() {
|
||||
for origin in [
|
||||
"http://127.0.0.1:5193",
|
||||
"http://127.0.0.1:5194",
|
||||
"http://127.0.0.1:65535",
|
||||
"http://localhost:5173",
|
||||
"http://[::1]:5193",
|
||||
] {
|
||||
assert_eq!(normalized(API, Some(origin)), "https://desktop.getartcraft.com");
|
||||
assert_eq!(normalized(LOCAL_API, Some(origin)), "http://localhost");
|
||||
assert_eq!(normalized(GROK, Some(origin)), "https://grok.com");
|
||||
}
|
||||
assert_eq!(
|
||||
normalized("https://api.fakeyou.com/v1/session", Some(DEV_WEBVIEW_ORIGIN)),
|
||||
"https://desktop.getartcraft.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_cdns_and_unknown_destinations_unchanged() {
|
||||
for destination in [
|
||||
CDN,
|
||||
GCS,
|
||||
UNKNOWN,
|
||||
"https://api.storyteller.ai.example.com/v1/session",
|
||||
"http://api.storyteller.ai/v1/session",
|
||||
] {
|
||||
assert_eq!(normalized(destination, Some(DEV_WEBVIEW_ORIGIN)), DEV_WEBVIEW_ORIGIN, "{destination}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_webview_origins_unchanged() {
|
||||
for origin in [
|
||||
"https://app.getartcraft.com",
|
||||
"https://example.com",
|
||||
"http://localhost.example.com:5193",
|
||||
"http://192.168.1.2:5193",
|
||||
"not a URL",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(normalized(API, Some(origin)), origin);
|
||||
assert_eq!(normalized(GROK, Some(origin)), origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod packaged {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn our_apis_get_desktop_identity() {
|
||||
for origin in PACKAGED_ORIGINS {
|
||||
assert_eq!(normalized(API, origin), "https://desktop.getartcraft.com");
|
||||
assert_eq!(normalized(LOCAL_API, origin), "http://localhost");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_parties_get_their_own_origin() {
|
||||
for origin in PACKAGED_ORIGINS {
|
||||
assert_eq!(normalized(GROK, origin), "https://grok.com");
|
||||
assert_eq!(normalized(MIDJOURNEY, origin), "https://www.midjourney.com");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdns_keep_legacy_origin() {
|
||||
for origin in PACKAGED_ORIGINS {
|
||||
assert_eq!(normalized(CDN, origin), "https://studio.storyteller.ai");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_destinations_never_get_our_origin() {
|
||||
for destination in [UNKNOWN, GCS] {
|
||||
assert_eq!(normalized(destination, Some("tauri://localhost")), "tauri://localhost");
|
||||
assert_eq!(normalized(destination, Some("null")), "null");
|
||||
assert_eq!(normalized_or_missing(destination, None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_duplicate_origin_values_without_changing_session_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
|
||||
headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN));
|
||||
headers.insert(header::COOKIE, HeaderValue::from_static("session=fixture"));
|
||||
normalize_artcraft_origin(&Url::parse(API).unwrap(), &mut headers);
|
||||
assert_eq!(headers.get_all(header::ORIGIN).iter().count(), 1);
|
||||
assert_eq!(headers[header::ORIGIN], "https://desktop.getartcraft.com");
|
||||
assert_eq!(headers[header::COOKIE], "session=fixture");
|
||||
}
|
||||
}
|
||||
|
||||
mod user_agent {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ours_get_artcraft_user_agent() {
|
||||
for destination in [API, LOCAL_API, CDN] {
|
||||
assert_eq!(user_agent(destination), "storyteller-client/1.0");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_parties_get_webview_user_agent() {
|
||||
for destination in [GROK, MIDJOURNEY, GCS, UNKNOWN] {
|
||||
let user_agent = user_agent(destination);
|
||||
assert!(user_agent.starts_with("Mozilla/5.0 ("), "{user_agent}");
|
||||
assert!(!user_agent.contains("storyteller"), "{user_agent}");
|
||||
}
|
||||
}
|
||||
|
||||
fn user_agent(destination: &str) -> &'static str {
|
||||
artcraft_default_user_agent(&Url::parse(destination).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(destination: &str, origin: Option<&str>) -> String {
|
||||
normalized_or_missing(destination, origin).unwrap()
|
||||
}
|
||||
|
||||
fn normalized_or_missing(destination: &str, origin: Option<&str>) -> Option<String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
normalize_artcraft_origin(&Url::parse(destination).unwrap(), &mut headers);
|
||||
headers.get(header::ORIGIN).map(|value| value.to_str().unwrap().to_owned())
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
use http::{header, HeaderMap, HeaderValue};
|
||||
use url::{Host, Url};
|
||||
|
||||
const DESKTOP_ORIGIN: &str = "https://studio.storyteller.ai";
|
||||
|
||||
/// The native HTTP bridge shares the desktop cookie jar. Its API identity must
|
||||
/// not depend on which loopback port Vite happened to bind for this launch.
|
||||
pub(crate) fn normalize_artcraft_origin(destination: &Url, headers: &mut HeaderMap) {
|
||||
let origin = headers.get(header::ORIGIN);
|
||||
// Preserve the existing packaged-app handling on macOS and Windows.
|
||||
let packaged_origin = origin.is_none()
|
||||
|| matches!(
|
||||
origin.and_then(|value| value.to_str().ok()),
|
||||
Some("null" | "http://tauri.localhost" | "tauri://localhost")
|
||||
);
|
||||
// Only first-party API requests get the development-origin replacement.
|
||||
// Leave third-party requests and ordinary website origins untouched.
|
||||
let development_origin = destination.scheme() == "https"
|
||||
&& matches!(destination.host_str(), Some("api.storyteller.ai" | "api.fakeyou.com"))
|
||||
&& origin.and_then(|value| value.to_str().ok()).is_some_and(is_loopback_origin);
|
||||
|
||||
if packaged_origin || development_origin {
|
||||
// HeaderMap is a multimap; insert replaces all existing Origin values.
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static(DESKTOP_ORIGIN));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_loopback_origin(origin: &str) -> bool {
|
||||
let Ok(url) = Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
match url.host() {
|
||||
Some(Host::Domain("localhost")) => true,
|
||||
Some(Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(Host::Ipv6(address)) => address.is_loopback(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const API: &str = "https://api.storyteller.ai/v1/session";
|
||||
|
||||
mod development {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_dynamic_ports_and_loopback_hosts() {
|
||||
for origin in [
|
||||
"http://127.0.0.1:5193",
|
||||
"http://127.0.0.1:5194",
|
||||
"http://127.0.0.1:65535",
|
||||
"http://localhost:5173",
|
||||
"http://[::1]:5193",
|
||||
] {
|
||||
assert_eq!(normalized(API, Some(origin)), DESKTOP_ORIGIN);
|
||||
}
|
||||
assert_eq!(
|
||||
normalized("https://api.fakeyou.com/v1/session", Some("http://127.0.0.1:5193")),
|
||||
DESKTOP_ORIGIN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_other_destinations_and_non_loopback_origins_unchanged() {
|
||||
for destination in [
|
||||
"https://example.com/v1/session",
|
||||
"https://api.storyteller.ai.example.com/v1/session",
|
||||
"http://api.storyteller.ai/v1/session",
|
||||
"http://localhost:12345/v1/session",
|
||||
] {
|
||||
assert_eq!(normalized(destination, Some("http://127.0.0.1:5193")), "http://127.0.0.1:5193");
|
||||
}
|
||||
for origin in [
|
||||
"https://app.getartcraft.com",
|
||||
"https://example.com",
|
||||
"http://localhost.example.com:5193",
|
||||
"http://192.168.1.2:5193",
|
||||
"not a URL",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(normalized(API, Some(origin)), origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod packaged {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preserves_existing_desktop_origins() {
|
||||
for origin in [None, Some("null"), Some("http://tauri.localhost"), Some("tauri://localhost")]
|
||||
{
|
||||
assert_eq!(normalized(API, origin), DESKTOP_ORIGIN);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_duplicate_origin_values_without_changing_session_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:5193"));
|
||||
headers.append(header::ORIGIN, HeaderValue::from_static("http://127.0.0.1:5193"));
|
||||
headers.insert(header::COOKIE, HeaderValue::from_static("session=fixture"));
|
||||
normalize_artcraft_origin(&Url::parse(API).unwrap(), &mut headers);
|
||||
assert_eq!(headers.get_all(header::ORIGIN).iter().count(), 1);
|
||||
assert_eq!(headers[header::ORIGIN], DESKTOP_ORIGIN);
|
||||
assert_eq!(headers[header::COOKIE], "session=fixture");
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(destination: &str, origin: Option<&str>) -> String {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
normalize_artcraft_origin(&Url::parse(destination).unwrap(), &mut headers);
|
||||
headers[header::ORIGIN].to_str().unwrap().to_owned()
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -16,13 +16,11 @@ use tauri::{
|
||||
use tokio::sync::oneshot::{channel, Receiver, Sender};
|
||||
|
||||
use crate::{
|
||||
artcraft_origin::normalize_artcraft_origin,
|
||||
artcraft_identity::{artcraft_default_user_agent, normalize_artcraft_origin},
|
||||
scope::{Entry, Scope},
|
||||
Error, Http, Result,
|
||||
};
|
||||
|
||||
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
|
||||
|
||||
struct ReqwestResponse(reqwest::Response);
|
||||
impl tauri::Resource for ReqwestResponse {}
|
||||
|
||||
@@ -285,7 +283,7 @@ pub async fn fetch<R: Runtime>(
|
||||
}
|
||||
|
||||
if !headers.contains_key(header::USER_AGENT) {
|
||||
headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?);
|
||||
headers.append(header::USER_AGENT, HeaderValue::from_static(artcraft_default_user_agent(&url)));
|
||||
}
|
||||
|
||||
// ensure we have an Origin header set
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ use tauri::{
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
mod artcraft_origin;
|
||||
mod artcraft_identity;
|
||||
|
||||
// NB(bt): We're making everything public so we can access it.
|
||||
pub mod commands;
|
||||
|
||||
@@ -18,8 +18,61 @@ development origins, at any port, for HTTPS requests to `api.storyteller.ai` and
|
||||
website origins retain their headers. Cookie handling and API authorization are
|
||||
unchanged. A frontend reload alone cannot update this fix: Rust must rebuild.
|
||||
|
||||
## Dedicated desktop origin and rollout
|
||||
|
||||
The desktop app now declares itself per destination. Both transports read the
|
||||
values from one place, `crates/lib/artcraft_client_identity`
|
||||
(`Destination::classify`, origins, user agents, third-party provider table):
|
||||
|
||||
| Destination | Origin sent | User-Agent |
|
||||
|--------------------------------------------------------|-------------------------------------|--------------------------|
|
||||
| Our APIs (`api.storyteller.ai`, `api.fakeyou.com`, …) | `https://desktop.getartcraft.com` | `storyteller-client/1.0` |
|
||||
| Local API (`localhost`, `127.0.0.1`, `[::1]`) | `http://localhost` | `storyteller-client/1.0` |
|
||||
| Our CDNs / sites (`*.storyteller.ai`, `*.fakeyou.com`) | unchanged (`studio.storyteller.ai`) | `storyteller-client/1.0` |
|
||||
| Named third parties (`THIRD_PARTY_PROVIDERS`) | the provider's own site | OS webview |
|
||||
| Any other host (incl. `storage.googleapis.com`) | webview origin, untouched | OS webview |
|
||||
|
||||
- The native Rust client (`artcraft_client`) sends the Origin and User-Agent on
|
||||
every API request via `storyteller_client_builder`; it previously sent no
|
||||
Origin.
|
||||
- The vendored HTTP plugin only replaces the webview's own origin (packaged
|
||||
`tauri://localhost` etc., or a loopback Vite origin), and only sets the
|
||||
User-Agent when the frontend didn't.
|
||||
- Native third-party Rust clients (Grok, Midjourney, Sora, World Labs, Kinovi)
|
||||
set their own headers and are not affected.
|
||||
- Older desktop releases keep working: the server still allows the studio and
|
||||
Tauri origins, and requests without an Origin.
|
||||
|
||||
Keep the deployed origin in sync with
|
||||
`../artcraft-services/crates/lib/actix_cors_configs/src/configs/artcraft_desktop.rs`.
|
||||
Development servers accept any `localhost` origin.
|
||||
|
||||
Deploy the server allowlist to the APIs **before running or releasing a desktop
|
||||
build with the new origin**. Otherwise the deployed server will reject desktop
|
||||
requests until its allowlist is updated. The server retains the studio and
|
||||
legacy Tauri origins so existing desktop releases continue working.
|
||||
|
||||
Check both repositories:
|
||||
|
||||
```sh
|
||||
# From artcraft/
|
||||
cargo test --offline -p artcraft_client_identity -p tauri-plugin-http --lib
|
||||
|
||||
# From artcraft-services/
|
||||
cargo test --offline -p actix_cors_configs --lib
|
||||
```
|
||||
|
||||
The server tests verify credentialed GET/POST requests and OPTIONS preflights in
|
||||
production and development, rejection of lookalike domains/HTTP/alternate ports,
|
||||
and compatibility with previous desktop origins. After server deployment, repeat
|
||||
the native checks below using the updated app.
|
||||
|
||||
## Observed native results
|
||||
|
||||
These observations were captured with the earlier studio origin, before the
|
||||
dedicated desktop origin migration. They do not establish that production has
|
||||
deployed the new allowlist.
|
||||
|
||||
Checked in the real macOS Tauri dev app at `http://127.0.0.1:5193`, using its
|
||||
existing session. Temporary probes were removed after verification. No cookies,
|
||||
tokens, passwords, account identifiers, or media contents are recorded here.
|
||||
|
||||
Reference in New Issue
Block a user