Compare commits

..

1 Commits

Author SHA1 Message Date
Brandon Thomas 08dfb8515f Added new models and modes: GPT Image 2.5, Wan, Output Formats 2026-09-18 16:10:26 -04:00
336 changed files with 2716 additions and 8920 deletions

View File

@ -44,7 +44,6 @@ explicitly authorizes a cache cleanup.
- Prefer `use` imports over inline fully-qualified paths; only qualify inline for true one-offs or std collisions
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
- Each Tauri command belongs in its own file module, named after the command. Keep shared state, types, and helpers in separate support modules; see the [Storyteller command conventions](crates/desktop/artcraft/src/services/storyteller/commands/AGENTS.md).
### File Layout

9
Cargo.lock generated
View File

@ -262,7 +262,6 @@ version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client_identity",
"backoff",
"chrono",
"cloudflare_errors",
@ -283,13 +282,6 @@ dependencies = [
"uuid_utils",
]
[[package]]
name = "artcraft_client_identity"
version = "0.0.1"
dependencies = [
"url",
]
[[package]]
name = "artcraft_router"
version = "0.0.1"
@ -7026,7 +7018,6 @@ dependencies = [
name = "tauri-plugin-http"
version = "2.4.3"
dependencies = [
"artcraft_client_identity",
"bytes",
"cookie_store 0.21.1",
"data-url",

View File

@ -16,7 +16,6 @@ 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",
@ -50,7 +49,6 @@ 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" }

View File

@ -9,67 +9,21 @@ To set up the ArtCraft development environment, install the following:
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
3. The frontend's Nx and Vite dependencies are installed locally with npm; a global Nx installation is not required for the combined Unix launcher.
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
**Mac and Linux Development**
```bash
# Start the frontend and Rust/Tauri together in one terminal
./script/artcraft/unix_dev.sh
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Optionally start the free-port search at another port
ARTCRAFT_DEV_PORT=6200 ./script/artcraft/unix_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
```
Use the combined launcher instead of starting the two legacy dev scripts. It
works from any working directory when invoked by its path and installs frontend
dependencies if the local Vite installation is missing. Node.js 20+ is required.
The launcher binds Vite to `127.0.0.1`, starting at port **5193** and trying
successive ports until one is available. It leaves existing listeners alone.
Only after the real frontend socket is bound does it start Tauri with that exact
`devUrl`. HTTP and the HMR websocket share the selected port; Rust communicates
through native Tauri IPC and needs no separate HTTP port. Configuration overrides
are passed in memory, without changing the checked-in Tauri config.
The native HTTP bridge normalizes loopback development origins for first-party
API requests so an automatically selected port does not break login or media
loading. See the [desktop session regression checks](../docs/desktop-session-regression.md)
when changing ports, the launcher, or authentication.
JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible
React component edits use Fast Refresh. Changes that cannot be hot-replaced
reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app
restart**, including dependent workspace crates. SQLx metadata and SQLite
migrations are also watched. Rust reload is a process restart, so it does not
preserve unsaved in-memory app state. See [Tauri's watch behavior](https://v2.tauri.app/develop/#reacting-to-source-code-changes).
After startup, a Vite configuration restart must reuse the chosen port or report
an error; it cannot silently switch to another port while Tauri uses an old URL.
Ctrl-C stops this session's frontend, Rust watcher/compiler, and desktop app.
If the Rust watcher exits, the launcher closes Vite and propagates the exit code.
It never finds or terminates another app by name or port.
The launcher retains the existing Unix defaults for `VITE_ENVIRONMENT_TYPE`,
`SQLX_OFFLINE`, `RUSTFLAGS`, and the Linux WebKit workarounds. Explicit environment
overrides are preserved. It uses the normal Artcraft app data and Cargo target
directory; port separation does not create an isolated account/data profile, and
concurrent Rust builds can wait on Cargo's build lock.
To verify the launcher without compiling Rust or opening a desktop window:
```bash
node --test frontend/scripts/unix-dev.test.mjs
```
The tests use real Vite listeners and HMR, with a test-owned fake Cargo process
tree. They cover occupied ports, simultaneous launches, React edits, shutdown,
Rust-process failure, and watcher arguments. To verify native Rust reload, run
the launcher, wait for the desktop window, make a Rust source edit, and check for
a rebuild/restart in the same terminal. Frontend edits should update the webview
without restarting the Rust process.
**Windows Development**
```powershell

View File

@ -1,73 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeRequest {}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeRequest {
pub approval_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct DecideLoginChallengeRequest {
pub approval_token: String,
pub approve: bool,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct PollLoginChallengeRequest {
pub device_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeResponse {
pub success: bool,
pub device_token: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub confirmation_code: String,
pub requesting_ip: String,
pub expires_at: DateTime<Utc>,
pub username: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
/// Present only to the device after the redemption transaction commits.
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_signed_session: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeState {
Pending,
Approved,
Redeemed,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeFailure {
UserDeclined,
Expired,
#[serde(other)]
Unknown,
}

View File

@ -2,5 +2,4 @@ pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod login;
pub mod session_info;
pub mod login_challenges;
pub mod session_info;

View File

@ -1,12 +1,11 @@
use crate::common::responses::user_details_light::UserDetailsLight;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use serde_derive::{Deserialize, Serialize};
use serde::{Deserialize as DeserializeTrait, Deserializer};
use serde_derive::Serialize;
use std::collections::BTreeSet;
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum FakeYouPlan {
Free,
@ -15,7 +14,7 @@ pub enum FakeYouPlan {
Pro,
}
#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)]
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerStreamPlan {
Free,
@ -24,7 +23,7 @@ pub enum StorytellerStreamPlan {
Pro,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, ToSchema)]
pub struct SessionUserInfo {
pub core_info: UserDetailsLight,
@ -42,7 +41,6 @@ pub struct SessionUserInfo {
/// Collection of feature / rollout flags
/// This is the proper place to detect if a user has access to some rollout (non-paywall) feature.
/// NB: The BTreeSet maintains order so React doesn't introduce re-render state bugs when order changes
#[serde(default, deserialize_with = "deserialize_known_feature_flags")]
pub maybe_feature_flags: BTreeSet<UserFeatureFlag>,
// Premium plans:
@ -78,7 +76,7 @@ pub struct SessionUserInfo {
pub can_delete_users: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, ToSchema)]
pub struct SessionOnboardingState {
/// If true, the user hasn't set their email.
pub email_not_set: bool,
@ -93,18 +91,9 @@ pub struct SessionOnboardingState {
pub username_not_customized: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[derive(Serialize, ToSchema)]
pub struct SessionInfoSuccessResponse {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUserInfo>,
}
// Server rollouts may introduce flags before a desktop release understands them.
// Ignore only unknown flag names; malformed values and authentication fields
// still fail decoding. Unknown flags never grant a client-side capability.
fn deserialize_known_feature_flags<'de, D>(deserializer: D) -> Result<BTreeSet<UserFeatureFlag>, D::Error>
where D: Deserializer<'de> {
let names = Vec::<String>::deserialize(deserializer)?;
Ok(names.into_iter().filter_map(|name| UserFeatureFlag::from_str(&name).ok()).collect())
}

View File

@ -1,27 +0,0 @@
# artcraft_client
Rust HTTP endpoint bindings used by the ArtCraft Tauri desktop app.
## Endpoint layout
- Each HTTP endpoint binding must live in its own descriptively named `.rs` file.
This applies to both free functions and methods implemented on a shared client.
Do not combine several routes into a catch-all file such as `login_challenges.rs`
or `password_auth.rs`.
- Group related bindings in a directory module. Keep `mod.rs` for module declarations;
it must not contain endpoint implementations. For example:
`endpoints/users/login_challenges/create_login_challenge.rs` and
`endpoints/users/login_challenges/poll_login_challenge.rs`.
- Keep each endpoint's route constant, request construction, and endpoint-specific
request/response types in its file. Keep shared wire types in `artcraft_api_defs`
or a dedicated shared type module; do not duplicate them across endpoint files.
- Put shared HTTP transport, client setup, and common error handling in a separate
utility module. A transport module must not accumulate endpoint bindings.
- Update callers to import types from their new modules when splitting bindings.
Preserve request methods, paths, headers, error behavior, and cookie handling.
## Validation
Run `cargo check --offline -p artcraft_client` for client changes. For native login
bindings, also run `SQLX_OFFLINE=true cargo test --offline -p artcraft --lib
login_bridge_tests`; these tests use local HTTP fixtures and temporary cookie jars.

View File

@ -19,7 +19,6 @@ 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

View File

@ -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(api_host, url).await?;
let response = http_get_anonymous(url).await?;
let response = filter_bad_response(response).await?;
let response_body = &response.text().await
.map_err(|err| StorytellerError::Api(ApiError::from(err)))?;

View File

@ -3,8 +3,7 @@ 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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@ -12,6 +11,7 @@ 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,7 +56,8 @@ pub async fn legacy_upload_media_file_from_file<P: AsRef<Path>>(
//let mut file = File::open(path)?;
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@ -85,6 +86,7 @@ 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 {

View File

@ -3,8 +3,7 @@ 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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@ -12,6 +11,7 @@ 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,7 +72,8 @@ pub async fn upload_image_media_file_from_bytes(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@ -94,6 +95,7 @@ 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 {

View File

@ -3,8 +3,7 @@ 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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use chrono::{DateTime, Utc};
@ -12,6 +11,7 @@ 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,7 +65,8 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token<P: AsRef<P
//let mut file = File::open(path)?;
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::from(err)))?;
@ -94,6 +95,7 @@ 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 {

View File

@ -1,8 +1,7 @@
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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@ -10,6 +9,7 @@ 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,7 +32,8 @@ pub async fn upload_new_engine_asset_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.build()?;
let file_bytes = std::fs::read(path.as_ref())?;
@ -48,6 +49,7 @@ 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 {

View File

@ -1,8 +1,7 @@
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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
use crate::utils::filter_bad_response::filter_bad_response;
use crate::utils::http_get_anonymous::http_get_anonymous;
use enums::common::generation_provider::GenerationProvider;
@ -10,6 +9,7 @@ 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,7 +58,8 @@ pub async fn upload_video_media_file_from_file<P: AsRef<Path>>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(args.api_host)
let client = Client::builder()
.gzip(true)
.build()?;
let file_bytes = std::fs::read(args.path.as_ref())?;
@ -77,6 +78,7 @@ 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 {

View File

@ -7,4 +7,3 @@ pub mod media_files;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod users;

View File

@ -1,16 +0,0 @@
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const CREATE_PATH: &str = "/v1/login_challenges/create";
impl LoginChallengeClient {
pub async fn create(&self) -> Result<CreateLoginChallengeResponse, LoginChallengeClientError> {
let request = self
.request(Method::POST, CREATE_PATH)
.json(&CreateLoginChallengeRequest {});
let (result, _) = self.send(request, CREATE_PATH).await?;
Ok(result)
}
}

View File

@ -1,2 +0,0 @@
pub mod create_login_challenge;
pub mod poll_login_challenge;

View File

@ -1,30 +0,0 @@
use artcraft_api_defs::users::login_challenges::{LoginChallengeResponse, PollLoginChallengeRequest};
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const POLL_PATH: &str = "/v1/login_challenges/poll";
// Deliberately no Debug/Serialize: the wire result contains a bearer credential.
pub struct PollLoginChallengeResult {
pub response: LoginChallengeResponse,
pub session_set_cookie: Option<String>,
}
impl LoginChallengeClient {
pub async fn poll(
&self,
device_token: &str,
) -> Result<PollLoginChallengeResult, LoginChallengeClientError> {
let request = self
.request(Method::POST, POLL_PATH)
.json(&PollLoginChallengeRequest {
device_token: device_token.to_owned(),
});
let (response, session_set_cookie) = self.send(request, POLL_PATH).await?;
Ok(PollLoginChallengeResult {
response,
session_set_cookie,
})
}
}

View File

@ -1,5 +0,0 @@
pub mod login_challenges;
pub mod password_auth_response;
pub mod password_login;
pub mod password_signup;
pub mod session_info;

View File

@ -1,8 +0,0 @@
use serde::Deserialize;
// No Debug: the response contains a bearer credential.
#[derive(Deserialize)]
pub struct PasswordAuthResponse {
pub success: bool,
pub signed_session: Option<String>,
}

View File

@ -1,20 +0,0 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const LOGIN_PATH: &str = "/v1/login";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordLoginRequest {
pub username_or_email: String,
pub password: String,
}
impl LoginChallengeClient {
pub async fn password_login(&self, request: &PasswordLoginRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, LOGIN_PATH).json(request), LOGIN_PATH).await
}
}

View File

@ -1,23 +0,0 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use super::password_auth_response::PasswordAuthResponse;
pub const SIGNUP_PATH: &str = "/v1/create_account";
// No Debug: the request contains credentials.
#[derive(Deserialize, Serialize)]
pub struct PasswordSignupRequest {
pub username: String,
pub email_address: String,
pub password: String,
pub password_confirmation: String,
pub signup_source: String,
}
impl LoginChallengeClient {
pub async fn password_signup(&self, request: &PasswordSignupRequest) -> Result<(PasswordAuthResponse, Option<String>), LoginChallengeClientError> {
self.send(self.request(Method::POST, SIGNUP_PATH).json(request), SIGNUP_PATH).await
}
}

View File

@ -1,19 +0,0 @@
use artcraft_api_defs::users::session_info::SessionInfoSuccessResponse;
use reqwest::Method;
use crate::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
pub const SESSION_PATH: &str = "/v1/session";
impl LoginChallengeClient {
pub async fn session(
&self,
signed_session: &str,
) -> Result<SessionInfoSuccessResponse, LoginChallengeClientError> {
let request = self
.request(Method::GET, SESSION_PATH)
.header("Cookie", format!("session={signed_session}"));
let (result, _) = self.send(request, SESSION_PATH).await?;
Ok(result)
}
}

View File

@ -1,6 +1,4 @@
use artcraft_client_identity::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN};
const HTTP_SCHEME: &str = "http";
const HTTPS_SCHEME: &str = "https";
@ -29,15 +27,6 @@ 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,

View File

@ -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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
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,11 +21,13 @@ pub async fn basic_json_delete_request<Req: Serialize, Res: DeserializeOwned>(
info!("DELETE {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.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);

View File

@ -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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
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,11 +20,13 @@ pub async fn basic_json_get_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.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 {

View File

@ -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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
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,11 +30,13 @@ pub async fn basic_json_post_request<Req: Serialize, Res: DeserializeOwned>(
info!("Requesting {:?}", &url);
}
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.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);

View File

@ -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;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use crate::utils::constants::{APPLICATION_JSON, USER_AGENT};
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,11 +26,13 @@ pub async fn basic_query_string_post_request<Res: DeserializeOwned>(
debug!("Requesting {:?}", &url);
let client = storyteller_client_builder(api_host)
let client = Client::builder()
.gzip(true)
.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 {

View File

@ -1 +1,4 @@
pub const USER_AGENT: &str = "storyteller-client/1.0";
pub const APPLICATION_JSON : &str = "application/json";

View File

@ -1,12 +1,14 @@
use crate::error::api_error::ApiError;
use crate::utils::api_host::ApiHost;
use crate::utils::storyteller_client_builder::storyteller_client_builder;
use reqwest::Client;
const USER_AGENT: &str = "storyteller-client/1.0";
pub async fn http_get_anonymous(api_host: &ApiHost, url: String) -> Result<reqwest::Response, ApiError> {
let client = storyteller_client_builder(api_host)
pub async fn http_get_anonymous(url: String) -> Result<reqwest::Response, ApiError> {
let client = Client::builder()
.gzip(true)
.build()?;
let response = client.get(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json")
//.header("Accept-Encoding", "gzip, deflate, br")
.send()

View File

@ -1,150 +0,0 @@
use std::time::Duration;
use log::{info, warn};
use reqwest::header::SET_COOKIE;
use reqwest::{Client, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use url::Url;
use crate::utils::api_host::ApiHost;
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.
#[derive(Clone)]
pub struct LoginChallengeClient {
client: Client,
api_host: ApiHost,
}
#[derive(Debug)]
pub struct LoginChallengeClientError {
pub status: Option<u16>,
pub message: &'static str,
}
impl LoginChallengeClient {
pub fn new(api_host: &ApiHost) -> Result<Self, LoginChallengeClientError> {
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())
.build()
.map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?;
Ok(Self {
client,
api_host: api_host.clone(),
})
}
pub fn api_origin(&self) -> String {
self.api_host.to_api_hostname_and_scheme()
}
pub fn api_url(&self) -> Url {
Url::parse(&format!("{}/", self.api_origin())).expect("ApiHost must produce a valid origin")
}
pub fn allows_verification_url(&self, value: &str) -> bool {
let Ok(url) = Url::parse(value) else {
return false;
};
let origin_allowed = match self.api_host {
ApiHost::Localhost { .. } => ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::Storyteller => ["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"]
.contains(&url.origin().ascii_serialization().as_str()),
ApiHost::FakeYou => false,
};
origin_allowed
&& url.username().is_empty()
&& url.password().is_none()
&& url.path() == "/login/desktop"
&& url.query().is_none()
&& url
.fragment()
.and_then(|f| f.strip_prefix("approval_token="))
.map(|s| {
s.len() == 43
&& s
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
})
.unwrap_or(false)
}
pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
self
.client
.request(method, format!("{}{path}", self.api_origin()))
.header("Accept", "application/json")
}
pub(crate) async fn send<T: DeserializeOwned>(
&self,
request: RequestBuilder,
path: &str,
) -> Result<(T, Option<String>), LoginChallengeClientError> {
let origin = self.api_origin();
info!("Website login request: origin={} path={}", origin, path);
let response = request.send().await.map_err(|_| {
warn!(
"Website login network failure: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Unable to reach the login server")
})?;
let status = response.status();
if !status.is_success() {
warn!(
"Website login HTTP failure: origin={} path={} status={}",
origin,
path,
status.as_u16()
);
return Err(LoginChallengeClientError {
status: Some(status.as_u16()),
message: "Login server rejected the request",
});
}
let session_cookie = response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find(|v| {
cookie::Cookie::parse(*v)
.map(|c| c.name() == "session")
.unwrap_or(false)
})
.map(str::to_owned);
let body = response.json().await.map_err(|_| {
warn!(
"Website login invalid response: origin={} path={}",
origin, path
);
LoginChallengeClientError::invalid("Invalid response from login server")
})?;
Ok((body, session_cookie))
}
}
impl LoginChallengeClientError {
pub fn invalid(message: &'static str) -> Self {
Self {
status: None,
message,
}
}
}
impl std::fmt::Display for LoginChallengeClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (HTTP {:?})", self.message, self.status)
}
}
impl std::error::Error for LoginChallengeClientError {}

View File

@ -4,8 +4,6 @@ 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;
pub mod login_challenge_client;

View File

@ -1,17 +0,0 @@
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)
}

View File

@ -1,5 +1,4 @@
# Rust sources and Cargo manifests must remain watched for `cargo tauri dev`.
# Vite owns frontend reloads; build artifacts must not trigger Rust rebuilds.
target/
node_modules/
dist/
# Don't restart the dev server on Rust changes
crates/
Cargo.toml
Cargo.lock

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -1,10 +1,10 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::app_startup_time::AppStartupTime;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use chrono::TimeDelta;
use errors::AnyhowResult;
use log::{error, info};
use once_cell::sync::Lazy;
use reqwest::Url;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@ -13,13 +13,24 @@ use tauri::{AppHandle, Manager};
use tauri_plugin_http::reqwest_cookie_store::CookieStore;
use tauri_plugin_http::Http;
const AVT_COOKIE_NAME: &str = "visitor";
const SESSION_COOKIE_NAME: &str = "session";
const STORYTELLER_ROOT_COOKIE_URL_STR: &str = "https://api.storyteller.ai/";
static STORYTELLER_ROOT_COOKIE_URL: Lazy<Url> = Lazy::new(|| {
Url::parse(STORYTELLER_ROOT_COOKIE_URL_STR).expect("URL should parse")
});
const AVT_COOKIE_NAME : &str = "visitor";
const SESSION_COOKIE_NAME : &str = "session";
/// There's some kind of race condition that we need to wait for before inspecting cookies.
const RACE_CONDITION_WAIT_TIME: TimeDelta = TimeDelta::milliseconds(5000);
pub async fn persist_storyteller_cookies_task(app: &AppHandle, storyteller_credential_manager: &StorytellerCredentialManager, app_startup_time: &AppStartupTime) -> AnyhowResult<()> {
pub async fn persist_storyteller_cookies_task(
app: &AppHandle,
storyteller_credential_manager: &StorytellerCredentialManager,
app_startup_time: &AppStartupTime,
) -> AnyhowResult<()> {
//if app_startup_time.time_delta_since() < RACE_CONDITION_WAIT_TIME {
// // NB: There's an issue when the "main window thread" inquires about the
// // webview cookies shortly after app startup. When it attempts to dump the
@ -30,30 +41,33 @@ pub async fn persist_storyteller_cookies_task(app: &AppHandle, storyteller_crede
//}
let maybe_http = app.try_state::<Http>();
let http = match maybe_http {
None => {
error!("No HTTP plugin found");
return Err(anyhow!("No HTTP plugin found"));
},
}
Some(http) => http,
};
match http.cookies_jar.store.lock() {
Err(err) => {
error!("Failed to lock cookie jar: {:?}", err);
return Err(anyhow!("Failed to lock cookie jar"));
},
}
Ok(cookie_jar) => {
sync_tauri_credentials(&cookie_jar, storyteller_credential_manager, &storyteller_cookie_url(app)?)?;
},
sync_tauri_credentials(&cookie_jar, storyteller_credential_manager)?;
}
}
Ok(())
}
pub(crate) fn sync_tauri_credentials(cookie_store: &CookieStore, storyteller_credential_manager: &StorytellerCredentialManager, api_url: &Url) -> AnyhowResult<()> {
let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store, api_url)?;
fn sync_tauri_credentials(
cookie_store: &CookieStore,
storyteller_credential_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
let current_http_plugin_credentials = get_credentials_from_cookie_store(cookie_store)?;
let mut replace_credentials = true;
@ -64,80 +78,31 @@ pub(crate) fn sync_tauri_credentials(cookie_store: &CookieStore, storyteller_cre
replace_credentials = false;
}
}
if replace_credentials {
info!("Syncing ArtCraft credentials ...");
storyteller_credential_manager.set_credentials(&current_http_plugin_credentials)?;
// NB: tauri-plugin-http stores the credentials on disk, so we can defer to that for now.
//storyteller_credential_manager.persist_all_to_disk()?;
}
Ok(())
}
pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore, api_url: &Url) -> AnyhowResult<StorytellerCredentialSet> {
pub fn get_credentials_from_cookie_store(cookie_jar: &CookieStore) -> AnyhowResult<StorytellerCredentialSet> {
let mut avt_cookie = None;
let mut session_cookie = None;
// Only cookies applicable to our API origin can become ArtCraft credentials.
for (name, value) in cookie_jar.get_request_values(api_url) {
match name {
AVT_COOKIE_NAME => avt_cookie = Some(StorytellerAvtCookie::new(value.to_owned())),
SESSION_COOKIE_NAME => session_cookie = Some(StorytellerSessionCookie::new(value.to_owned())),
_ => {},
for cookie in cookie_jar.iter_unexpired() {
if let Some(avt) = StorytellerAvtCookie::maybe_from_cookie(&cookie) {
avt_cookie = Some(avt);
} else if let Some(session) = StorytellerSessionCookie::maybe_from_cookie(&cookie) {
session_cookie = Some(session);
}
}
Ok(StorytellerCredentialSet::initialize(avt_cookie, session_cookie))
}
pub(crate) fn storyteller_cookie_url(app: &AppHandle) -> AnyhowResult<Url> {
let config = app.try_state::<AppEnvConfigs>().ok_or_else(|| anyhow!("API configuration unavailable"))?;
Ok(Url::parse(&format!("{}/", config.storyteller_host.to_api_hostname_and_scheme()))?)
}
#[cfg(test)]
mod login_bridge_tests {
use super::*;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use tauri_plugin_http::reqwest_cookie_store::RawCookie;
#[test]
fn approved_response_cookie_reaches_native_credentials() {
let directory = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(directory.path()).unwrap();
let manager = StorytellerCredentialManager::initialize_empty(&root);
let mut jar = CookieStore::default();
let url = Url::parse("https://api.storyteller.ai/v1/login_challenges/poll").unwrap();
jar.store_response_cookies([RawCookie::parse("session=signed_backend_cookie; Secure; HttpOnly; Path=/; Max-Age=3600").unwrap().into_owned(), RawCookie::parse("visitor=existing_visitor; Secure; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &url);
sync_tauri_credentials(&jar, &manager, &url).unwrap();
let credentials = manager.get_credentials_required().unwrap();
assert_eq!(credentials.session.unwrap().as_str(), "signed_backend_cookie");
assert_eq!(credentials.avt.unwrap().as_str(), "existing_visitor");
// Retrying the same successful poll is idempotent for native credentials too.
sync_tauri_credentials(&jar, &manager, &url).unwrap();
assert_eq!(manager.get_credentials_required().unwrap().session.unwrap().as_str(), "signed_backend_cookie");
}
#[test]
fn unrelated_and_expired_cookies_cannot_become_artcraft_sessions() {
let url = Url::parse("https://api.storyteller.ai/").unwrap();
let mut jar = CookieStore::default();
jar.store_response_cookies([RawCookie::parse("session=unrelated; Path=/; Max-Age=3600").unwrap().into_owned()].into_iter(), &Url::parse("https://unrelated.example/").unwrap());
assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none());
jar.store_response_cookies([RawCookie::parse("session=expired; Secure; Path=/; Max-Age=0").unwrap().into_owned()].into_iter(), &url);
assert!(get_credentials_from_cookie_store(&jar, &url).unwrap().session.is_none());
}
#[test]
fn configured_local_api_does_not_import_production_cookies() {
let production = Url::parse("https://api.storyteller.ai/").unwrap();
let local = Url::parse("http://localhost:12345/").unwrap();
let mut jar = CookieStore::default();
jar.store_response_cookies([RawCookie::parse("session=production; Secure; Path=/").unwrap().into_owned()].into_iter(), &production);
assert!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.is_none());
jar.store_response_cookies([RawCookie::parse("session=development; Path=/").unwrap().into_owned()].into_iter(), &local);
assert_eq!(get_credentials_from_cookie_store(&jar, &local).unwrap().session.unwrap().as_str(), "development");
assert_eq!(get_credentials_from_cookie_store(&jar, &production).unwrap().session.unwrap().as_str(), "production");
}
Ok(StorytellerCredentialSet::initialize(
avt_cookie,
session_cookie,
))
}

View File

@ -63,13 +63,6 @@ use crate::services::sora::commands::sora_logout_command::sora_logout_command;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread;
use crate::services::storyteller::commands::login_bridge::DesktopLoginBridgeState;
use crate::services::storyteller::commands::storyteller_cancel_login_challenge_command::storyteller_cancel_login_challenge_command;
use crate::services::storyteller::commands::storyteller_create_login_challenge_command::storyteller_create_login_challenge_command;
use crate::services::storyteller::commands::storyteller_get_login_session_command::storyteller_get_login_session_command;
use crate::services::storyteller::commands::storyteller_password_login_command::storyteller_password_login_command;
use crate::services::storyteller::commands::storyteller_password_signup_command::storyteller_password_signup_command;
use crate::services::storyteller::commands::storyteller_poll_login_challenge_command::storyteller_poll_login_challenge_command;
use crate::services::storyteller::commands::storyteller_get_credits_command::storyteller_get_credits_command;
use crate::services::storyteller::commands::storyteller_get_subscription_command::storyteller_get_subscription_command;
use crate::services::storyteller::commands::storyteller_purge_credentials_command::storyteller_purge_credentials_command;
@ -208,7 +201,6 @@ pub fn run() {
})
.manage(app_data_root)
.manage(app_env_configs)
.manage(DesktopLoginBridgeState::default())
.manage(app_preferences)
.manage(artcraft_platform_info)
.manage(artcraft_usage_tracker)
@ -267,12 +259,6 @@ pub fn run() {
set_provider_order_command,
sora_get_credential_info_command,
sora_logout_command,
storyteller_get_login_session_command,
storyteller_password_login_command,
storyteller_password_signup_command,
storyteller_create_login_challenge_command,
storyteller_poll_login_challenge_command,
storyteller_cancel_login_challenge_command,
storyteller_get_credits_command,
storyteller_get_subscription_command,
storyteller_open_credits_purchase_command,

View File

@ -1,17 +0,0 @@
# Tauri Commands
- Put each `#[tauri::command]` function in its own `.rs` file module named after
that command, such as `storyteller_create_login_challenge_command.rs`. Do not
collect multiple commands in a catch-all `*_commands.rs` file.
- Keep `mod.rs` for module declarations. Put shared state, response/error types,
and implementation helpers in separate support modules, with the narrowest
visibility needed by their callers.
- Update the imports used by `tauri::generate_handler!` when moving commands.
Preserve IPC command names and arguments unless changing that contract is
explicitly part of the task.
- Authentication HTTP requests run in Rust through `artcraft_client` endpoint
bindings. The frontend invokes Tauri commands; it does not call the API
directly, and desktop commands do not connect to the backend MySQL database.
- For login bridge changes, run the existing native fixture tests with
`SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests`.
These tests use loopback HTTP servers and temporary cookie stores.

View File

@ -1,340 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use artcraft_api_defs::users::login_challenges::{LoginChallengeFailure, LoginChallengeState};
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::endpoints::users::password_login::PasswordLoginRequest;
use artcraft_client::endpoints::users::password_signup::PasswordSignupRequest;
use artcraft_client::utils::api_host::ApiHost;
use artcraft_client::utils::login_challenge_client::{
LoginChallengeClient, LoginChallengeClientError,
};
use chrono::{DateTime, Utc};
use log::{info, warn};
use serde::Serialize;
use tauri::{AppHandle, Manager};
use tauri_plugin_http::reqwest_cookie_store::{CookieStoreMutex, RawCookie};
use tauri_plugin_http::Http;
use tokio::sync::Mutex;
use uuid_utils::uuid::generate_random_uuid;
use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::get_credentials_from_cookie_store;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
const MAX_PENDING_CHALLENGES: usize = 8;
/// All bearer credentials stay in Rust. The UI holds only a local lookup handle.
#[derive(Default)]
pub struct DesktopLoginBridgeState {
pending: Mutex<HashMap<String, PendingChallenge>>,
}
#[derive(Clone)]
struct PendingChallenge {
device_token: String,
expires_at: DateTime<Utc>,
client: LoginChallengeClient,
}
#[derive(Serialize)]
pub struct DesktopLoginChallenge {
pub challenge_id: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Serialize)]
pub struct DesktopLoginOutcome {
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub maybe_user: Option<SessionUserInfo>,
}
#[derive(Debug, Serialize)]
pub struct DesktopLoginError {
pub status: Option<u16>,
pub message: String,
pub retryable: bool,
}
impl DesktopLoginError {
fn local(message: &str) -> Self {
Self {
status: None,
message: message.to_owned(),
retryable: false,
}
}
pub(super) fn from_client(origin: &str, error: LoginChallengeClientError) -> Self {
warn!(
"Website login failed: origin={} status={:?} reason={}",
origin, error.status, error.message
);
Self {
retryable: error
.status
.map(|s| s == 429 || s >= 500)
.unwrap_or(error.message == "Unable to reach the login server"),
status: error.status,
message: format!("{}: {} (HTTP {:?})", origin, error.message, error.status),
}
}
}
pub(super) async fn current_login_session(
client: &LoginChallengeClient,
jar: &Arc<CookieStoreMutex>,
) -> Result<Option<SessionUserInfo>, DesktopLoginError> {
let credentials = {
let store = jar
.store
.lock()
.map_err(|_| DesktopLoginError::local("HTTP cookie store unavailable"))?;
get_credentials_from_cookie_store(&store, &client.api_url())
.map_err(|_| DesktopLoginError::local("Unable to read session cookie"))?
};
let Some(signed) = credentials.session else {
return Ok(None);
};
let session = match client.session(signed.as_str()).await {
Ok(session) => session,
Err(error) if error.status == Some(401) => return Ok(None),
Err(error) => return Err(DesktopLoginError::from_client(&client.api_origin(), error)),
};
Ok(
session
.user
.filter(|_| session.success && session.logged_in),
)
}
pub(super) async fn password_auth(
host: &ApiHost,
manager: &StorytellerCredentialManager,
jar: &Arc<CookieStoreMutex>,
login: Option<PasswordLoginRequest>,
signup: Option<PasswordSignupRequest>,
) -> Result<SessionUserInfo, DesktopLoginError> {
let client = LoginChallengeClient::new(host)
.map_err(|e| DesktopLoginError::from_client(&host.to_api_hostname_and_scheme(), e))?;
let response = match (login, signup) {
(Some(request), None) => client.password_login(&request).await,
(None, Some(request)) => client.password_signup(&request).await,
_ => return Err(DesktopLoginError::local("Invalid authentication request")),
}
.map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?;
let (response, cookie) = response;
if !response.success {
return Err(DesktopLoginError::local(
"Sign-in failed. Check your account details and try again.",
));
}
let signed = response
.signed_session
.ok_or_else(|| DesktopLoginError::local("Login server did not issue a session"))?;
let cookie =
cookie.ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?;
let (cookie, user) = verify_session(&client, &signed, cookie).await?;
install_session_cookie(&client, &signed, cookie, manager, jar)?;
persist_session_cookie(jar).await?;
Ok(user)
}
pub(super) fn http_cookie_jar(app: &AppHandle) -> Result<Arc<CookieStoreMutex>, DesktopLoginError> {
Ok(
app
.try_state::<Http>()
.ok_or_else(|| DesktopLoginError::local("HTTP cookie store unavailable"))?
.cookies_jar
.clone(),
)
}
pub(super) async fn begin_challenge(
host: &ApiHost,
state: &DesktopLoginBridgeState,
) -> Result<DesktopLoginChallenge, DesktopLoginError> {
let client = LoginChallengeClient::new(host)
.map_err(|e| DesktopLoginError::from_client(&host.to_api_hostname_and_scheme(), e))?;
let created = client
.create()
.await
.map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?;
if !created.success
|| !client.allows_verification_url(&created.verification_url)
|| created.device_token.len() != 43
|| created.expires_at <= Utc::now()
{
return Err(DesktopLoginError::local(
"Invalid login challenge or website/API environment mismatch",
));
}
let mut pending = state.pending.lock().await;
pending.retain(|_, value| value.expires_at > Utc::now());
if pending.len() >= MAX_PENDING_CHALLENGES {
return Err(DesktopLoginError::local("Too many pending login attempts"));
}
let challenge_id = generate_random_uuid();
pending.insert(
challenge_id.clone(),
PendingChallenge {
device_token: created.device_token,
expires_at: created.expires_at,
client,
},
);
Ok(DesktopLoginChallenge {
challenge_id,
verification_url: created.verification_url,
confirmation_code: created.confirmation_code,
expires_at: created.expires_at,
poll_interval_seconds: created.poll_interval_seconds.max(5),
})
}
pub(super) async fn poll_challenge(
state: &DesktopLoginBridgeState,
manager: &StorytellerCredentialManager,
jar: &Arc<CookieStoreMutex>,
challenge_id: &str,
) -> Result<DesktopLoginOutcome, DesktopLoginError> {
let challenge = {
let mut pending = state.pending.lock().await;
let challenge = pending.get(challenge_id).cloned().ok_or_else(|| {
DesktopLoginError::local("Login attempt was cancelled or is no longer available")
})?;
if challenge.expires_at <= Utc::now() {
pending.remove(challenge_id);
return Ok(expired());
}
challenge
};
let origin = challenge.client.api_origin();
let polled = challenge
.client
.poll(&challenge.device_token)
.await
.map_err(|e| DesktopLoginError::from_client(&origin, e))?;
if !polled.response.success || polled.response.status == LoginChallengeState::Unknown {
return Err(DesktopLoginError::local("Unrecognized login response"));
}
if polled.response.status != LoginChallengeState::Redeemed {
if polled.response.status == LoginChallengeState::Failed {
state.pending.lock().await.remove(challenge_id);
}
return Ok(DesktopLoginOutcome {
status: polled.response.status,
maybe_failure_type: polled.response.maybe_failure_type,
maybe_user: None,
});
}
let signed = polled
.response
.maybe_signed_session
.ok_or_else(|| DesktopLoginError::local("Login server did not issue a session"))?;
let set_cookie = polled
.session_set_cookie
.ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?;
let (cookie, user) = verify_session(&challenge.client, &signed, set_cookie).await?;
{
let pending = state.pending.lock().await;
// A cancellation during either HTTP request must prevent local installation.
if !pending.contains_key(challenge_id) {
return Err(DesktopLoginError::local("Login attempt was cancelled"));
}
if challenge.expires_at <= Utc::now() {
return Ok(expired());
}
install_session_cookie(&challenge.client, &signed, cookie, manager, jar)?;
}
// Keep the challenge for idempotent IPC retries; it expires on its original deadline.
persist_session_cookie(jar).await?;
info!("Website login complete: origin={}", origin);
Ok(DesktopLoginOutcome {
status: LoginChallengeState::Redeemed,
maybe_failure_type: None,
maybe_user: Some(user),
})
}
pub(super) async fn cancel_challenge(state: &DesktopLoginBridgeState, challenge_id: &str) {
state.pending.lock().await.remove(challenge_id);
}
async fn verify_session(
client: &LoginChallengeClient,
signed: &str,
set_cookie: String,
) -> Result<(RawCookie<'static>, SessionUserInfo), DesktopLoginError> {
let cookie = RawCookie::parse(set_cookie)
.map_err(|_| DesktopLoginError::local("Invalid session cookie"))?
.into_owned();
if cookie.name() != "session" || cookie.value() != signed {
return Err(DesktopLoginError::local(
"Session cookie did not match the redeemed session",
));
}
// Validate on the SAME native API host before publishing any new credentials.
let session = client
.session(&signed)
.await
.map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?;
let user = session
.user
.filter(|_| session.success && session.logged_in)
.ok_or_else(|| DesktopLoginError::local("The redeemed session could not authenticate"))?;
Ok((cookie, user))
}
fn install_session_cookie(
client: &LoginChallengeClient,
signed: &str,
cookie: RawCookie<'static>,
manager: &StorytellerCredentialManager,
jar: &Arc<CookieStoreMutex>,
) -> Result<(), DesktopLoginError> {
let mut store = jar
.store
.lock()
.map_err(|_| DesktopLoginError::local("HTTP cookie store unavailable"))?;
let mut candidate = store.clone();
candidate.store_response_cookies([cookie].into_iter(), &client.api_url());
let credentials = get_credentials_from_cookie_store(&candidate, &client.api_url())
.map_err(|_| DesktopLoginError::local("Unable to read session cookie"))?;
if credentials.session.as_ref().map(|c| c.as_str()) != Some(signed) {
return Err(DesktopLoginError::local(
"Session cookie does not apply to the configured API host",
));
}
manager
.set_credentials(&credentials)
.map_err(|_| DesktopLoginError::local("Unable to store native login credentials"))?;
*store = candidate;
Ok(())
}
async fn persist_session_cookie(jar: &Arc<CookieStoreMutex>) -> Result<(), DesktopLoginError> {
let saved = jar
.request_save()
.map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?;
tokio::task::spawn_blocking(move || saved.recv())
.await
.map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?
.map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?;
Ok(())
}
fn expired() -> DesktopLoginOutcome {
DesktopLoginOutcome {
status: LoginChallengeState::Failed,
maybe_failure_type: Some(LoginChallengeFailure::Expired),
maybe_user: None,
}
}
#[cfg(test)]
#[path = "storyteller_login_bridge_tests.rs"]
mod login_bridge_tests;

View File

@ -2,11 +2,4 @@ pub mod storyteller_purge_credentials_command;
pub mod storyteller_get_credits_command;
pub mod storyteller_get_subscription_command;
pub mod stripe_checkout;
pub mod stripe_customer_portal;
pub(crate) mod login_bridge;
pub mod storyteller_cancel_login_challenge_command;
pub mod storyteller_create_login_challenge_command;
pub mod storyteller_get_login_session_command;
pub mod storyteller_password_login_command;
pub mod storyteller_password_signup_command;
pub mod storyteller_poll_login_challenge_command;
pub mod stripe_customer_portal;

View File

@ -1,14 +0,0 @@
use tauri::State;
use crate::services::storyteller::commands::login_bridge::{
cancel_challenge, DesktopLoginBridgeState, DesktopLoginError,
};
#[tauri::command]
pub async fn storyteller_cancel_login_challenge_command(
state: State<'_, DesktopLoginBridgeState>,
challenge_id: String,
) -> Result<(), DesktopLoginError> {
cancel_challenge(&state, &challenge_id).await;
Ok(())
}

View File

@ -1,14 +0,0 @@
use tauri::State;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
begin_challenge, DesktopLoginBridgeState, DesktopLoginChallenge, DesktopLoginError,
};
#[tauri::command]
pub async fn storyteller_create_login_challenge_command(
config: State<'_, AppEnvConfigs>,
state: State<'_, DesktopLoginBridgeState>,
) -> Result<DesktopLoginChallenge, DesktopLoginError> {
begin_challenge(&config.storyteller_host, &state).await
}

View File

@ -1,19 +0,0 @@
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::utils::login_challenge_client::LoginChallengeClient;
use tauri::{AppHandle, State};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
current_login_session, http_cookie_jar, DesktopLoginError,
};
#[tauri::command]
pub async fn storyteller_get_login_session_command(
app: AppHandle,
config: State<'_, AppEnvConfigs>,
) -> Result<Option<SessionUserInfo>, DesktopLoginError> {
let client = LoginChallengeClient::new(&config.storyteller_host).map_err(|error| {
DesktopLoginError::from_client(&config.storyteller_host.to_api_hostname_and_scheme(), error)
})?;
current_login_session(&client, &http_cookie_jar(&app)?).await
}

View File

@ -1,436 +0,0 @@
//! Commands' native implementation + real ArtCraft bindings + loopback HTTP.
//! All listeners bind to an ephemeral local port; no production host is contacted.
use super::*;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use serde_json::{json, Value};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
use tauri_plugin_http::reqwest_cookie_store::CookieStore;
const SIGNED: &str = "signed_native_session_fixture";
const DEVICE: &str = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD";
#[tokio::test]
async fn approved_session_is_verified_installed_persisted_and_retried_entirely_in_rust() {
let server = server(vec![
create(),
pending(),
redeemed(),
session(),
redeemed(),
session(),
]);
let h = harness();
// Native installation must preserve visitor cookies and avoid importing other origins.
h.jar.store.lock().unwrap().store_response_cookies(
[RawCookie::parse("visitor=existing; Path=/; Max-Age=3600")
.unwrap()
.into_owned()]
.into_iter(),
&server_url(&server),
);
h.manager
.set_credentials(&StorytellerCredentialSet::initialize_with_just_avt(
StorytellerAvtCookie::new("existing".into()),
))
.unwrap();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let public = serde_json::to_string(&created).unwrap();
assert!(!public.contains(DEVICE));
assert!(!public.contains("device_token"));
assert!(h
.manager
.get_credentials_required()
.unwrap()
.session
.is_none());
let wait = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.unwrap();
assert_eq!(wait.status, LoginChallengeState::Pending);
assert!(h
.manager
.get_credentials_required()
.unwrap()
.session
.is_none());
for _ in 0..2 {
let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.unwrap();
assert_eq!(result.status, LoginChallengeState::Redeemed);
assert_eq!(result.maybe_user.as_ref().unwrap().username, "google_user");
// This was the real failure: use_qt exists on the server, not this desktop.
let flags = &result.maybe_user.as_ref().unwrap().maybe_feature_flags;
assert_eq!(flags.len(), 1);
assert_eq!(flags.first().unwrap().to_str(), "studio");
assert!(!serde_json::to_string(&result).unwrap().contains(SIGNED));
let credentials = h.manager.get_credentials_required().unwrap();
assert_eq!(credentials.session.unwrap().as_str(), SIGNED);
assert_eq!(credentials.avt.unwrap().as_str(), "existing");
assert!(std::fs::read_to_string(&h.jar.path)
.unwrap()
.contains(SIGNED));
}
server.thread.join().unwrap();
}
#[tokio::test]
async fn rejection_and_timeout_never_install_a_session() {
for expired in [false, true] {
let mut steps = vec![create()];
if !expired {
steps.push(step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"failed","maybe_failure_type":"user_declined"}),
));
}
let server = server(steps);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
if expired {
h.state
.pending
.lock()
.await
.get_mut(&created.challenge_id)
.unwrap()
.expires_at = Utc::now();
}
let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.unwrap();
assert_eq!(result.status, LoginChallengeState::Failed);
assert_eq!(
result.maybe_failure_type,
Some(if expired {
LoginChallengeFailure::Expired
} else {
LoginChallengeFailure::UserDeclined
})
);
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h
.jar
.store
.lock()
.unwrap()
.iter_unexpired()
.next()
.is_none());
server.thread.join().unwrap();
}
}
#[tokio::test]
async fn invalid_downstream_session_is_not_installed_and_error_has_host_without_credentials() {
let mut invalid = session();
invalid.status = 401;
invalid.body = json!({"echo": SIGNED});
let server = server(vec![create(), redeemed(), invalid]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.err()
.unwrap();
assert_eq!(error.status, Some(401));
assert!(!error.retryable);
assert!(error
.message
.contains(&server.host.to_api_hostname_and_scheme()));
assert!(!format!("{error:?}").contains(SIGNED));
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h
.jar
.store
.lock()
.unwrap()
.iter_unexpired()
.next()
.is_none());
server.thread.join().unwrap();
}
#[tokio::test]
async fn cancelling_during_http_prevents_late_cookie_installation() {
let mut delayed = redeemed();
delayed.delay = Duration::from_millis(150);
let server = server(vec![create(), delayed, session()]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let (result, _) = tokio::join!(
poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id),
async {
tokio::time::sleep(Duration::from_millis(30)).await;
cancel_challenge(&h.state, &created.challenge_id).await;
}
);
assert!(result.is_err());
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h
.jar
.store
.lock()
.unwrap()
.iter_unexpired()
.next()
.is_none());
server.thread.join().unwrap();
}
#[tokio::test]
async fn unknown_states_and_redirects_fail_closed() {
for redirect in [false, true] {
let mut result = step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"future_state","maybe_signed_session":SIGNED}),
);
if redirect {
result.status = 302;
result.headers = "Location: https://must-never-be-contacted.invalid/\r\n".into();
}
let server = server(vec![create(), result]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id)
.await
.err()
.unwrap();
assert!(!error.retryable);
if redirect {
assert_eq!(error.status, Some(302));
}
assert!(h.manager.get_credentials().unwrap().is_none());
server.thread.join().unwrap();
}
}
#[test]
fn website_origin_must_match_native_api_environment() {
let local = LoginChallengeClient::new(&ApiHost::Localhost { port: 12345 }).unwrap();
let prod = LoginChallengeClient::new(&ApiHost::Storyteller).unwrap();
let fragment = format!("#approval_token={}", "A".repeat(43));
assert!(local.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}")));
assert!(!local.allows_verification_url(&format!(
"https://app.getartcraft.com/login/desktop{fragment}"
)));
assert!(prod.allows_verification_url(&format!(
"https://app.getartcraft.com/login/desktop{fragment}"
)));
assert!(!prod.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}")));
}
#[tokio::test]
async fn malformed_session_data_still_cannot_install_credentials() {
for (field, value) in [("maybe_feature_flags", json!([42])), ("username", Value::Null)] {
let mut invalid = session();
invalid.body["user"][field] = value;
let server = server(vec![create(), redeemed(), invalid]);
let h = harness();
let created = begin_challenge(&server.host, &h.state).await.unwrap();
assert!(poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id).await.is_err());
assert!(h.manager.get_credentials().unwrap().is_none());
assert!(h.jar.store.lock().unwrap().iter_unexpired().next().is_none());
server.thread.join().unwrap();
}
}
#[tokio::test]
async fn password_login_and_signup_verify_persist_and_recheck_via_native_api() {
for signup in [false, true] {
let mut auth = redeemed();
auth.path = if signup { "/v1/create_account" } else { "/v1/login" };
auth.body = json!({"success":true,"signed_session":SIGNED});
let server = server(vec![auth, session(), session()]);
let h = harness();
let (login, signup) = if signup {
(None, Some(PasswordSignupRequest { username: "google_user".into(), email_address: "user@example.test".into(), password: "fixture_password".into(), password_confirmation: "fixture_password".into(), signup_source: "artcraft".into() }))
} else {
(Some(PasswordLoginRequest { username_or_email: "google_user".into(), password: "fixture_password".into() }), None)
};
let user = password_auth(&server.host, &h.manager, &h.jar, login, signup).await.unwrap();
assert_eq!(user.username, "google_user");
assert_eq!(h.manager.get_credentials_required().unwrap().session.unwrap().as_str(), SIGNED);
assert!(std::fs::read_to_string(&h.jar.path).unwrap().contains(SIGNED));
let client = LoginChallengeClient::new(&server.host).unwrap();
assert_eq!(current_login_session(&client, &h.jar).await.unwrap().unwrap().username, "google_user");
server.thread.join().unwrap();
}
}
#[tokio::test]
async fn empty_native_cookie_jar_needs_no_session_http_request() {
let h = harness();
let client = LoginChallengeClient::new(&ApiHost::Localhost { port: 1 }).unwrap();
assert!(current_login_session(&client, &h.jar).await.unwrap().is_none());
}
struct Harness {
_directory: tempfile::TempDir,
state: DesktopLoginBridgeState,
manager: StorytellerCredentialManager,
jar: Arc<CookieStoreMutex>,
}
struct Server {
host: ApiHost,
thread: thread::JoinHandle<()>,
}
struct Step {
path: &'static str,
status: u16,
body: Value,
headers: String,
delay: Duration,
}
fn harness() -> Harness {
let directory = tempfile::tempdir().unwrap();
let root = AppDataRoot::create_existing(directory.path()).unwrap();
let manager = StorytellerCredentialManager::initialize_empty(&root);
let jar = Arc::new(CookieStoreMutex::new(
directory.path().join("cookies.json"),
CookieStore::default(),
));
Harness {
_directory: directory,
state: DesktopLoginBridgeState::default(),
manager,
jar,
}
}
fn server_url(server: &Server) -> reqwest::Url {
reqwest::Url::parse(&format!("{}/", server.host.to_api_hostname_and_scheme())).unwrap()
}
fn server(steps: Vec<Step>) -> Server {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let thread = thread::spawn(move || {
for step in steps {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let mut data = Vec::new();
let mut buffer = [0; 4096];
loop {
let count = stream.read(&mut buffer).unwrap();
assert!(count > 0);
data.extend_from_slice(&buffer[..count]);
if let Some(end) = data.windows(4).position(|v| v == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&data[..end]).to_lowercase();
let length: usize = headers
.lines()
.find_map(|s| s.strip_prefix("content-length:"))
.map(|s| s.trim().parse().unwrap())
.unwrap_or(0);
if data.len() >= end + 4 + length {
break;
}
}
}
let request = String::from_utf8(data).unwrap();
assert_eq!(request.split_whitespace().nth(1), Some(step.path));
assert!(request
.to_lowercase()
.contains(&format!("host: localhost:{port}")));
if step.path == "/v1/session" {
assert!(request.starts_with("GET "));
assert!(request.contains(&format!("session={SIGNED}")));
} else {
assert!(request.starts_with("POST "));
assert!(!request.to_lowercase().contains("cookie:"));
if step.path.ends_with("/poll") {
let body: Value =
serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body, json!({"device_token":DEVICE}));
}
}
thread::sleep(step.delay);
let body = step.body.to_string();
write!(stream, "HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", step.status, body.len(), step.headers, body).unwrap();
}
});
Server {
host: ApiHost::Localhost { port: port.into() },
thread,
}
}
fn step(path: &'static str, body: Value) -> Step {
Step {
path,
body,
status: 200,
headers: String::new(),
delay: Duration::ZERO,
}
}
fn create() -> Step {
step(
"/v1/login_challenges/create",
json!({"success":true,"device_token":DEVICE,"verification_url":format!("http://localhost:4201/login/desktop#approval_token={}","A".repeat(43)),"confirmation_code":"WDJBMJHT","expires_at":Utc::now()+chrono::Duration::minutes(20),"poll_interval_seconds":5}),
)
}
fn pending() -> Step {
step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"pending","maybe_failure_type":null}),
)
}
fn redeemed() -> Step {
let mut response = step(
"/v1/login_challenges/poll",
json!({"success":true,"status":"redeemed","maybe_failure_type":null,"maybe_signed_session":SIGNED}),
);
response.headers = format!("Set-Cookie: session={SIGNED}; HttpOnly; Path=/; Max-Age=3600\r\n");
response
}
fn session() -> Step {
let mut user = json!({
"user_token":"u_native_test", "username":"google_user", "display_name":"Google User", "email_gravatar_hash":"fixture",
"core_info":{"user_token":"u_native_test","username":"google_user","display_name":"Google User","gravatar_hash":"fixture","default_avatar":{"image_index":1,"color_index":1}},
"onboarding":{"email_not_set":false,"email_not_confirmed":false,"password_not_set":true,"username_not_customized":false},
"maybe_feature_flags":["studio", "use_qt", "future_server_flag"],"fakeyou_plan":"free","storyteller_stream_plan":"free"
});
for field in [
"can_access_studio",
"can_use_tts",
"can_use_w2l",
"can_delete_own_tts_results",
"can_delete_own_w2l_results",
"can_delete_own_account",
"can_upload_tts_models",
"can_upload_w2l_templates",
"can_delete_own_tts_models",
"can_delete_own_w2l_templates",
"can_approve_w2l_templates",
"can_edit_other_users_profiles",
"can_edit_other_users_tts_models",
"can_edit_other_users_w2l_templates",
"can_delete_other_users_tts_models",
"can_delete_other_users_w2l_templates",
"can_delete_other_users_tts_results",
"can_delete_other_users_w2l_results",
"can_ban_users",
"can_delete_users",
] {
user[field] = json!(false);
}
step(
"/v1/session",
json!({"success":true,"logged_in":true,"user":user}),
)
}

View File

@ -1,26 +0,0 @@
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::endpoints::users::password_login::PasswordLoginRequest;
use tauri::{AppHandle, State};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
http_cookie_jar, password_auth, DesktopLoginError,
};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
#[tauri::command]
pub async fn storyteller_password_login_command(
app: AppHandle,
config: State<'_, AppEnvConfigs>,
manager: State<'_, StorytellerCredentialManager>,
request: PasswordLoginRequest,
) -> Result<SessionUserInfo, DesktopLoginError> {
password_auth(
&config.storyteller_host,
&manager,
&http_cookie_jar(&app)?,
Some(request),
None,
)
.await
}

View File

@ -1,26 +0,0 @@
use artcraft_api_defs::users::session_info::SessionUserInfo;
use artcraft_client::endpoints::users::password_signup::PasswordSignupRequest;
use tauri::{AppHandle, State};
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::services::storyteller::commands::login_bridge::{
http_cookie_jar, password_auth, DesktopLoginError,
};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
#[tauri::command]
pub async fn storyteller_password_signup_command(
app: AppHandle,
config: State<'_, AppEnvConfigs>,
manager: State<'_, StorytellerCredentialManager>,
request: PasswordSignupRequest,
) -> Result<SessionUserInfo, DesktopLoginError> {
password_auth(
&config.storyteller_host,
&manager,
&http_cookie_jar(&app)?,
None,
Some(request),
)
.await
}

View File

@ -1,17 +0,0 @@
use tauri::{AppHandle, State};
use crate::services::storyteller::commands::login_bridge::{
http_cookie_jar, poll_challenge, DesktopLoginBridgeState, DesktopLoginError, DesktopLoginOutcome,
};
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
#[tauri::command]
pub async fn storyteller_poll_login_challenge_command(
app: AppHandle,
state: State<'_, DesktopLoginBridgeState>,
manager: State<'_, StorytellerCredentialManager>,
challenge_id: String,
) -> Result<DesktopLoginOutcome, DesktopLoginError> {
let jar = http_cookie_jar(&app)?;
poll_challenge(&state, &manager, &jar, &challenge_id).await
}

View File

@ -3,10 +3,9 @@ use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LOG_ACTIVE_USER_V2_PATH};
use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest;
use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::error::storyteller_error::StorytellerError;
use errors::AnyhowResult;
use log::{debug, error, info};
@ -39,7 +38,7 @@ pub async fn storyteller_activity_thread(
&app_session_token,
).await;
if let Err(err) = res {
error!("Activity request failed: origin={} path={} error={:?}", app_env_configs.storyteller_host.to_api_hostname_and_scheme(), LOG_ACTIVE_USER_V2_PATH, err);
error!("An error occurred: {:?}", err);
}
// NB: Sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await;
@ -63,7 +62,7 @@ async fn polling_loop(
continue;
}
Some(creds) => {
if !has_activity_session(&creds) {
if creds.is_empty() {
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
continue;
}
@ -124,31 +123,7 @@ async fn polling_loop(
};
// Wait at least a minute, no matter what the server tells us.
let wait_millis = std::cmp::max(wait_millis, 60_000);
let wait_millis = std::cmp::min(wait_millis, 60_000);
tokio::time::sleep(std::time::Duration::from_millis(wait_millis)).await;
}
}
// Anonymous visitor tracking is not authentication for active_user_v2.
fn has_activity_session(creds: &StorytellerCredentialSet) -> bool {
creds.session.as_ref().map(|session| !session.as_str().is_empty()).unwrap_or(false)
}
#[cfg(test)]
mod login_bridge_tests {
use super::has_activity_session;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
#[test]
fn anonymous_visitor_cookie_does_not_authorize_activity_requests() {
assert!(!has_activity_session(&StorytellerCredentialSet::empty()));
let visitor_only = StorytellerCredentialSet::initialize_with_just_avt(StorytellerAvtCookie::new("visitor_fixture".into()));
assert!(!has_activity_session(&visitor_only));
let empty_session = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new(String::new()));
assert!(!has_activity_session(&empty_session));
let signed_in = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new("signed_session_fixture".into()));
assert!(has_activity_session(&signed_in));
}
}

View File

@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.41.0";
pub const ARTCRAFT_VERSION: &str = "0.39.0";

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.41.0",
"version": "0.39.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",

Some files were not shown because too many files have changed in this diff Show More