Compare commits

..
Author SHA1 Message Date
Brandon Thomas edbe9a352d move ip banlist 2026-01-24 09:26:11 -05:00
Brandon Thomas 990b020ad9 move safety list 2026-01-24 09:23:14 -05:00
Brandon Thomas 358a9648bb adjust reserved list (hasn't been looked at in a while) 2026-01-24 09:19:36 -05:00
Brandon Thomas 088a59510e reorganize reserved usernames 2026-01-24 09:09:46 -05:00
495 changed files with 3284 additions and 59028 deletions
+2 -2
View File
@@ -233,8 +233,8 @@ jobs:
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "See the assets below to download this version and install."
releaseName: "Artcraft v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -99,9 +99,9 @@ jobs:
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "Production build of ArtCraft. See the assets below to download and install."
tagName: artcraft-windows-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "Artcraft Windows v__VERSION__"
releaseBody: "Windows build - See the assets to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
-3
View File
@@ -32,9 +32,6 @@ script/production_database_secret_backup.sh
script/production_database_secret_connect.sh
script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Scratch data
scratch/
test_data/temp/
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n password_hash = ?,\n ip_address_last_update = ?,\n is_without_password = false,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "03761e50f94f2805f1c5c64f598a23af8d708e514a01c6090570b2e0bfabfe2c"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n password_hash = ?,\n ip_address_last_update = ?,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "05de463f1c408793d64fb011b1732e3c1b3cf485076b3e1a4a50d16683b99e46"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n ",
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_confirmed,\n users.email_gravatar_hash,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n ",
"describe": {
"columns": [
{
@@ -45,16 +45,6 @@
},
{
"ordinal": 4,
"name": "email_gravatar_hash",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "email_confirmed",
"type_info": {
"type": "Tiny",
@@ -63,48 +53,18 @@
"max_size": 1
}
},
{
"ordinal": 5,
"name": "email_gravatar_hash",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "email_confirmed_by_google",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 7,
"name": "email_is_synthetic",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 8,
"name": "is_without_password",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 9,
"name": "username_is_not_customized",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 10,
"name": "maybe_stripe_customer_id",
"type_info": {
"type": "VarString",
@@ -114,7 +74,7 @@
}
},
{
"ordinal": 11,
"ordinal": 7,
"name": "maybe_loyalty_program_key",
"type_info": {
"type": "VarString",
@@ -124,7 +84,7 @@
}
},
{
"ordinal": 12,
"ordinal": 8,
"name": "disable_gravatar",
"type_info": {
"type": "Tiny",
@@ -134,7 +94,7 @@
}
},
{
"ordinal": 13,
"ordinal": 9,
"name": "auto_play_audio_preference",
"type_info": {
"type": "Tiny",
@@ -144,7 +104,7 @@
}
},
{
"ordinal": 14,
"ordinal": 10,
"name": "auto_play_video_preference",
"type_info": {
"type": "Tiny",
@@ -154,7 +114,7 @@
}
},
{
"ordinal": 15,
"ordinal": 11,
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -164,7 +124,7 @@
}
},
{
"ordinal": 16,
"ordinal": 12,
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
@@ -174,7 +134,7 @@
}
},
{
"ordinal": 17,
"ordinal": 13,
"name": "user_role_slug",
"type_info": {
"type": "VarString",
@@ -184,7 +144,7 @@
}
},
{
"ordinal": 18,
"ordinal": 14,
"name": "is_banned",
"type_info": {
"type": "Tiny",
@@ -194,7 +154,7 @@
}
},
{
"ordinal": 19,
"ordinal": 15,
"name": "can_access_studio",
"type_info": {
"type": "Tiny",
@@ -204,7 +164,7 @@
}
},
{
"ordinal": 20,
"ordinal": 16,
"name": "maybe_feature_flags",
"type_info": {
"type": "VarString",
@@ -214,7 +174,7 @@
}
},
{
"ordinal": 21,
"ordinal": 17,
"name": "can_use_tts",
"type_info": {
"type": "Tiny",
@@ -224,7 +184,7 @@
}
},
{
"ordinal": 22,
"ordinal": 18,
"name": "can_use_w2l",
"type_info": {
"type": "Tiny",
@@ -234,7 +194,7 @@
}
},
{
"ordinal": 23,
"ordinal": 19,
"name": "can_delete_own_tts_results",
"type_info": {
"type": "Tiny",
@@ -244,7 +204,7 @@
}
},
{
"ordinal": 24,
"ordinal": 20,
"name": "can_delete_own_w2l_results",
"type_info": {
"type": "Tiny",
@@ -254,7 +214,7 @@
}
},
{
"ordinal": 25,
"ordinal": 21,
"name": "can_delete_own_account",
"type_info": {
"type": "Tiny",
@@ -264,7 +224,7 @@
}
},
{
"ordinal": 26,
"ordinal": 22,
"name": "can_upload_tts_models",
"type_info": {
"type": "Tiny",
@@ -274,7 +234,7 @@
}
},
{
"ordinal": 27,
"ordinal": 23,
"name": "can_upload_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -284,7 +244,7 @@
}
},
{
"ordinal": 28,
"ordinal": 24,
"name": "can_delete_own_tts_models",
"type_info": {
"type": "Tiny",
@@ -294,7 +254,7 @@
}
},
{
"ordinal": 29,
"ordinal": 25,
"name": "can_delete_own_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -304,7 +264,7 @@
}
},
{
"ordinal": 30,
"ordinal": 26,
"name": "can_approve_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -314,7 +274,7 @@
}
},
{
"ordinal": 31,
"ordinal": 27,
"name": "can_edit_other_users_profiles",
"type_info": {
"type": "Tiny",
@@ -324,7 +284,7 @@
}
},
{
"ordinal": 32,
"ordinal": 28,
"name": "can_edit_other_users_tts_models",
"type_info": {
"type": "Tiny",
@@ -334,7 +294,7 @@
}
},
{
"ordinal": 33,
"ordinal": 29,
"name": "can_edit_other_users_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -344,7 +304,7 @@
}
},
{
"ordinal": 34,
"ordinal": 30,
"name": "can_delete_other_users_tts_models",
"type_info": {
"type": "Tiny",
@@ -354,7 +314,7 @@
}
},
{
"ordinal": 35,
"ordinal": 31,
"name": "can_delete_other_users_tts_results",
"type_info": {
"type": "Tiny",
@@ -364,7 +324,7 @@
}
},
{
"ordinal": 36,
"ordinal": 32,
"name": "can_delete_other_users_w2l_templates",
"type_info": {
"type": "Tiny",
@@ -374,7 +334,7 @@
}
},
{
"ordinal": 37,
"ordinal": 33,
"name": "can_delete_other_users_w2l_results",
"type_info": {
"type": "Tiny",
@@ -384,7 +344,7 @@
}
},
{
"ordinal": 38,
"ordinal": 34,
"name": "can_ban_users",
"type_info": {
"type": "Tiny",
@@ -394,7 +354,7 @@
}
},
{
"ordinal": 39,
"ordinal": 35,
"name": "can_delete_users",
"type_info": {
"type": "Tiny",
@@ -414,10 +374,6 @@
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
@@ -450,5 +406,5 @@
true
]
},
"hash": "3ca99449c071cb0a8d90a735e1a767a94be224a02e0d16e81997d569af8d4cbb"
"hash": "1c32bdb79ed2bc1b8988bbb6ea3f2a1db4ed4552d53cb4f31ff4b08de01691f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO users\nSET\n token = ?,\n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 19
},
"nullable": []
},
"hash": "453f0386cfe34e533501ba29dc2022fc449266f9858f16886bde9a0df6b82026"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_address = ?,\n email_confirmed = false,\n email_confirmed_by_google = false,\n email_is_synthetic = false,\n email_gravatar_hash = ?,\n ip_address_last_update = ?,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "4737c44e7b1c73b21a245dd9d1f96aecfc4c614ed19ac4b590a26809c98fee03"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 22
},
"nullable": []
},
"hash": "56746bcb993cb33f5492f401ce4fc3fd44d91fed660c2f106cb22e482dffa740"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_address = ?,\n email_gravatar_hash = ?,\n email_confirmed = false,\n email_confirmed_by_google = false,\n email_is_synthetic = false,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "6fa90a16784c7b7e3436877c2a751bf0b60054640ebe69cb5f33938238def0fe"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n email_is_synthetic = false,\n is_without_password = false,\n is_temporary = false,\n password_hash = ?,\n ip_address_last_update = ?,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "9cd3a581f6857e5c1db68a103f47eba9714ee4b5eefc8ba02fc49a4b3a19310e"
}
@@ -1,36 +0,0 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n email_address,\n email_is_synthetic\nFROM users\nWHERE token = ?\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 1,
"name": "email_is_synthetic",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "c2dd5120b34d62153772acc94f97c3b7a22b7c53216721e54882c0019d2467e6"
}
Generated
+103 -926
View File
File diff suppressed because it is too large Load Diff
+2 -26
View File
@@ -36,7 +36,6 @@ members = [
"crates/desktop/artcraft",
# Shared libraries
"crates/lib/actix_artcraft",
"crates/lib/actix_cors_configs",
"crates/lib/actix_helpers",
"crates/lib/bootstrap",
@@ -81,6 +80,7 @@ members = [
"crates/lib/migration",
"crates/lib/password",
"crates/lib/server_environment",
"crates/lib/shared_service_components",
"crates/lib/sqlx_mysql_helpers",
"crates/lib/storyteller_root",
"crates/lib/subprocess_common",
@@ -90,7 +90,6 @@ members = [
"crates/lib/url_config",
"crates/lib/url_utils",
"crates/lib/user_input_common",
"crates/lib/users",
"crates/lib/web_scrapers",
#"crates/lib/api_clients/fakeyou_client",
#"crates/lib/machine_learning/ml_models",
@@ -136,23 +135,6 @@ members = [
"crates/vendor/fal-rs/generate",
"crates/vendor/limitation",
"crates/vendor/tauri-plugin-http",
"crates/vendor/Symphonia/symphonia",
"crates/vendor/Symphonia/symphonia-bundle-flac",
"crates/vendor/Symphonia/symphonia-bundle-mp3",
"crates/vendor/Symphonia/symphonia-codec-aac",
"crates/vendor/Symphonia/symphonia-codec-alac",
"crates/vendor/Symphonia/symphonia-codec-opus",
"crates/vendor/Symphonia/symphonia-codec-pcm",
"crates/vendor/Symphonia/symphonia-codec-vorbis",
"crates/vendor/Symphonia/symphonia-codec-wavpack",
"crates/vendor/Symphonia/symphonia-core",
"crates/vendor/Symphonia/symphonia-format-isomp4",
"crates/vendor/Symphonia/symphonia-format-mkv",
"crates/vendor/Symphonia/symphonia-format-ogg",
"crates/vendor/Symphonia/symphonia-format-wav",
"crates/vendor/Symphonia/symphonia-metadata",
"crates/vendor/Symphonia/symphonia-play",
"crates/vendor/Symphonia/symphonia-utils-xiph",
# NB(bt,2025-05-25): Too many failures on Mac. Disable for now.
#"crates/vendor/candle/candle-core",
#"crates/vendor/candle/candle-datasets",
@@ -204,17 +186,13 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
# Internal libs
# Setting paths here ensures we don't need to do so in every crate's Cargo.toml
# It makes refactoring much easier.
actix_artcraft = { path = "crates/lib/actix_artcraft" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
build_metadata = { path = "crates/lib/build_metadata" }
cloud_storage = { path = "crates/api_clients/cloud_storage" }
cloudflare_errors = { path = "crates/lib/cloudflare_errors" }
cloudflare_mitigation = { path = "crates/lib/cloudflare_mitigation" }
collections = { path = "crates/lib/data/collections" }
component_traits = { path = "crates/service/plugins/component_traits" }
cookie_store = { path = "crates/lib/cookie_store" }
cookies = { path = "crates/lib/cookies" }
crockford = { path = "crates/lib/data/crockford" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
@@ -241,7 +219,6 @@ test_data = { path = "crates/lib/test_data" }
testing = { path = "crates/lib/testing" }
tokens = { path = "crates/schema/public/tokens" }
url_utils = { path = "crates/lib/url_utils" }
users = { path = "crates/lib/users" }
web_base64 = { path = "crates/lib/data/web_base64" }
world_labs_client = { path = "crates/api_clients/world_labs_client" }
@@ -270,13 +247,12 @@ futures = { version = "0.3.31" }
hex-literal = "1.0.0"
indexmap = "2.9.0"
log = "0.4.27"
md-5 = "0.10.6" # insecure but used for gravatar
once_cell = "1.21.3"
r2d2 = "0.8.10"
rand = "0.9.1"
redis = { version = "1.0.2", features = ["r2d2", "tokio-rustls-comp", "tokio-comp", "cluster-async", "aio"] }
regex = "1.12.2"
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls", "gzip" ] }
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls" ] }
reqwest-websocket = { version = "0.5.0" }
serde = { version = "1.0.219", features = ["derive"] }
serde_derive = "1.0.219"
+7 -14
View File
@@ -1,17 +1,3 @@
<p align="center">
<video src="https://github.com/user-attachments/assets/b4e24c27-d87d-4fd1-8599-dc0d0b8af48d" width="100%" autoplay="true" loop controls>
</p>
<p align="center">The IDE for artists.</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Discord" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&label=discord&color=ffffff&logo=discord&logoColor=ffffff" /></a>
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/youtube/channel/subscribers/UCdjY4VG0ntoGwFsKZO4sVWA?style=for-the-badge&logo=YouTube" /></a>
<a href="https://x.com/intent/follow?screen_name=get_artcraft"><img alt="X" src="https://img.shields.io/twitter/follow/get_artcraft?style=for-the-badge&label=follow&logo=x&logoColor=ffffff&color=ffffff" /></a>
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin--0A66C2?style=for-the-badge"></a>
</p>
---
ArtCraft
========
ArtCraft is the IDE for interactive AI image and video creation.
@@ -80,3 +66,10 @@ those providers, for example: OpenArt, FreePik, etc.
- [license](./LICENSE.md)
- [roadmap](./ROADMAP.md)
## Social
[![discord](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/discord.svg)](https://discord.gg/artcraft)
[![youtube](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/youtube.svg)](https://www.youtube.com/@OfficialArtCraftStudios)
[![x](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/linkedin.svg)](https://www.linkedin.com/company/artcraft-ai)
[![x](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/twitter-x.svg)](https://x.com/get_artcraft)
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN
was_eagerly_provisioned;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN
was_eagerly_provisioned BOOLEAN NOT NULL DEFAULT false;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN
is_temporary;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN
is_temporary BOOLEAN NOT NULL DEFAULT false;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN
email_is_synthetic;
@@ -1,7 +0,0 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN
email_is_synthetic BOOLEAN NOT NULL DEFAULT false;
@@ -12,14 +12,6 @@ CREATE TABLE users (
-- Visible "primary key"
token VARCHAR(32) NOT NULL,
-- This is TRUE for accounts that were provisioned automatically (eg. eager stripe
-- checkout flow), and who haven't completed onboarding (eg. finishing with billing).
-- Once the user finishes setup, this is set to FALSE.
-- Existing users and normal signup flow always sets this to FALSE.
-- If enough time has elapsed for an incomplete `is_temporary` user, such user records
-- might be possible to delete as we assume the user abandoned sign up long ago.
is_temporary BOOLEAN NOT NULL DEFAULT false,
-- Username is a lookup key; display_name allows the user to add custom case.
username VARCHAR(20) NOT NULL,
display_name VARCHAR(20) NOT NULL,
@@ -38,8 +30,6 @@ CREATE TABLE users (
-- Set by the user at sign up:
-- - When email + password signup, this is the email provided.
-- - When Google SSO, this is the email provided by Google.
-- - When "eager" checkout flow, this is synthetically generated and
-- updated by the user later.
email_address VARCHAR(255) NOT NULL,
-- Email address is confirmed by us.
@@ -49,11 +39,6 @@ CREATE TABLE users (
-- Email address is confirmed by Google.
email_confirmed_by_google BOOLEAN NOT NULL DEFAULT false,
-- We created a user account provisionally, and the email address is not yet real.
-- This happens during "eager" checkout flow before users create accounts - we create
-- a user account for them with a fake email address (since this is a unique key).
email_is_synthetic BOOLEAN NOT NULL DEFAULT false,
-- The role assigned to the user confers permissions.
user_role_slug VARCHAR(16) NOT NULL,
@@ -101,7 +86,6 @@ CREATE TABLE users (
password_version INT UNSIGNED NOT NULL DEFAULT 0,
-- Users signing up from SSO do not have a password at time of account creation.
-- Users signing up with eager stripe checkout flow do not have a password at time of creation
is_without_password BOOLEAN NOT NULL DEFAULT false,
-- ========== ABUSE TRACKING ==========
@@ -207,19 +191,10 @@ CREATE TABLE users (
maybe_source VARCHAR(255) DEFAULT NULL,
-- How users created their account
-- Older users do not have a value and are assumed to be "email_password"
-- Values:
-- - "email_password"
-- - "google_sign_in"
-- - "stripe_checkout" (eager stripe checkout account provisioning)
-- Initially this will be "email_password" or "google_sign_in"
-- Older users do not have a value and are assumed to be "email_sign_in"
maybe_signup_method VARCHAR(16) DEFAULT NULL,
-- Account was provisioned automatically (eg. stripe eager checkout flow)
-- This will remain true even if the user finishes account setup, email change,
-- password setup, etc. (It's a permanent label on the account for later
-- statistical evaluation.)
was_eagerly_provisioned BOOLEAN NOT NULL DEFAULT false,
-- ========== MODERATION DETAILS ==========
-- Different than deleted.
-45
View File
@@ -1,45 +0,0 @@
Dev Setup
=========
# ArtCraft
ArtCraft is a Rust / Tauri app.
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. [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
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
```
**Windows Development**
```powershell
# Run the frontend dev server
.\script\artcraft\windows_frontend_dev.ps1
# Run the Tauri Rust application
.\script\artcraft\windows_rust_dev.ps1
```
# ArtCraft Server
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
See [dev_setup_server.md](./dev_setup_server.md) for instructions.
-105
View File
@@ -1,105 +0,0 @@
# ArtCraft Server
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
`storyteller-web` can function entirely within the development environment, and ArtCraft can be
pointed to a local instance of the server.
**TODO**: Concisely describe setting up server components. Note: we have
previous docs in `old/` that may still be relevant, though they're perhaps
slightly out of date.
## Server Setup (Mac)
### Install and Migrate MySQL Database
```bash
# For now, the tooling doesn't support MySQL 9.6 as it was just released in January 2026.
# This will probably change in the future to no longer need to specify the 8.* series.
brew install mysql@8.4
# Try to connect
mysql -uroot
# If connection fails, start service, then try again.
brew services start mysql
mysql -uroot
# If that fails, reboot and try connecting again.
```
Create the `storyteller` database table. Connect to the database:
```bash
sudo mysql -u root
# If it needs a password, use argument -p and when prompted use "root" as the typical default password
```
Then run this:
```mysql
use mysql;
CREATE DATABASE storyteller;
CREATE USER 'storyteller'@'localhost' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON storyteller.* TO 'storyteller'@'localhost';
```
Then verify access with `./script/dev_mysql_connect.sh`
```bash
cargo install diesel_cli \
--no-default-features \
--features mysql,sqlite
```
If this fails,
```bash
brew install zstd
brew install openssl
```
Possibly,
```bash
export RUSTFLAGS="-L$(brew --prefix zstd)/lib -L$(brew --prefix openssl)/lib"
```
In older Macs, there [have been some issues with Diesel CLI](https://github.com/diesel-rs/diesel/issues/2605)
that require a few extra dependencies to be installed:
```bash
# If you're on Mac and the above command didn't work, run the following and then retry:
brew install libpq
```
You're ready to run the migrations:
```bash
diesel migration run
```
You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this
error if you see it in isolation. It doesn't impact the migrations whatsoever.
Finally, you'll need the sqlx CLI tool to run codegen.
You likely won't need this now, but if you change any queries, this will be necessary:
```bash
cargo install sqlx-cli --features rustls,mysql,sqlite
```
### Install Redis (Mac)
```bash
# Install Redis
brew install redis
# Start the service:
brew services start redis
```
@@ -7,5 +7,4 @@ pub mod media_file;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod users;
pub mod utils;
@@ -1,54 +0,0 @@
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const CREATE_NEW_USER_ACCOUNT_AND_SUBSCRIPTION_CHECKOUT_URL_PATH: &str = "/v1/stripe_artcraft/user_signup/subscription_checkout";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest {
/// The (non-Stripe) internal identifier for the product or subscription.
/// This will be translated into a Stripe identifier.
pub plan: Option<ArtcraftSubscriptionSlug>,
pub cadence: Option<PlanBillingCadence>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)]
pub enum PlanBillingCadence {
#[serde(rename = "monthly")]
Monthly,
#[serde(rename = "yearly")]
Yearly,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupResponse {
pub success: bool,
/// The checkout session URL.
pub stripe_checkout_redirect_url: String,
/// If a user account was created, these are the details.
pub generated_user: Option<UserDetails>,
/// If a session was created, these are the details.
pub session: Option<SessionDetails>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UserDetails {
/// The generated username.
pub username: String,
/// The generated display name.
pub display_name: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionDetails {
/// A signed session that can be sent as a header, bypassing cookies.
/// This is useful for API clients that don't support cookies or Google
/// browsers killing cross-domain cookies.
pub signed_session: String,
}
@@ -1,4 +1,3 @@
pub mod create_create_new_user_account_and_subscription_checkout;
pub mod create_credits_pack_checkout;
pub mod create_subscription_checkout;
pub mod customer_portal_cancel_plan;
@@ -1,13 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ChangePasswordRequest {
pub password: String,
pub password_confirmation: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ChangePasswordResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditEmailRequest {
pub email_address: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditEmailResponse {
pub success: bool,
}
@@ -1,12 +0,0 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditUsernameRequest {
pub display_name: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EditUsernameResponse {
pub success: bool,
}
@@ -1,4 +0,0 @@
pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod session_info;
@@ -1,99 +0,0 @@
use crate::common::responses::user_details_light::UserDetailsLight;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use serde_derive::Serialize;
use std::collections::BTreeSet;
use tokens::tokens::users::UserToken;
use utoipa::ToSchema;
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum FakeYouPlan {
Free,
Basic,
Standard,
Pro,
}
#[derive(Serialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum StorytellerStreamPlan {
Free,
Basic,
Standard,
Pro,
}
#[derive(Serialize, ToSchema)]
pub struct SessionUserInfo {
pub core_info: UserDetailsLight,
pub user_token: UserToken,
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
pub onboarding: SessionOnboardingState,
// TODO(bt,2024-03-30): Remove legacy feature flag
#[deprecated(note = "DO NOT USE. Use `maybe_feature_flags` instead! The flag you're looking for is `studio`.")]
pub can_access_studio: bool,
/// 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
pub maybe_feature_flags: BTreeSet<UserFeatureFlag>,
// Premium plans:
#[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")]
pub fakeyou_plan: FakeYouPlan,
#[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")]
pub storyteller_stream_plan: StorytellerStreamPlan,
// Usage permissions:
pub can_use_tts: bool,
pub can_use_w2l: bool,
pub can_delete_own_tts_results: bool,
pub can_delete_own_w2l_results: bool,
pub can_delete_own_account: bool,
// Contribution permissions:
pub can_upload_tts_models: bool,
pub can_upload_w2l_templates: bool,
pub can_delete_own_tts_models: bool,
pub can_delete_own_w2l_templates: bool,
// Moderation permissions:
pub can_approve_w2l_templates: bool,
pub can_edit_other_users_profiles: bool,
pub can_edit_other_users_tts_models: bool,
pub can_edit_other_users_w2l_templates: bool,
pub can_delete_other_users_tts_models: bool,
pub can_delete_other_users_tts_results: bool,
pub can_delete_other_users_w2l_templates: bool,
pub can_delete_other_users_w2l_results: bool,
pub can_ban_users: bool,
pub can_delete_users: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SessionOnboardingState {
/// If true, the user hasn't set their email.
pub email_not_set: bool,
/// If true, the user hasn't confirmed their email.
pub email_not_confirmed: bool,
/// If true, the user hasn't set their password.
pub password_not_set: bool,
/// If true, the user hasn't set their username (the username they have is random).
pub username_not_customized: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SessionInfoSuccessResponse {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUserInfo>,
}
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4_edit_image::{bytedance_seedream_v4_edit_image, BytedanceSeedreamV4EditImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -54,23 +53,6 @@ pub enum EnqueueBytedanceSeedreamV4EditImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.03 per image.
let unit_cost = 3;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4_edit_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4EditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4p5_edit_image::{bytedance_seedream_v4p5_edit_image, BytedanceSeedreamV4p5EditImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -53,23 +52,6 @@ pub enum EnqueueBytedanceSeedreamV4p5EditImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4p5EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.04 per image.
let unit_cost = 4;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4p5_edit_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4p5EditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gemini_25_flash_image::edit::gemini_25_flash_image_edit;
use fal::prelude::fal_ai::gemini_25_flash_image::edit::Gemini25FlashImageEditInput;
use fal::webhook::WebhookResponse;
@@ -50,20 +49,6 @@ pub enum Gemini25FlashEditAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for Gemini25FlashEditArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.039 per image. For $1.00, you can run this model 25 times.
match self.num_images {
Gemini25FlashEditNumImages::One => 4,
Gemini25FlashEditNumImages::Two => 8,
Gemini25FlashEditNumImages::Three => 12,
Gemini25FlashEditNumImages::Four => 16,
}
}
}
pub async fn enqueue_gemini_25_flash_edit_webhook<R: IntoUrl>(
args: Gemini25FlashEditArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -2,7 +2,6 @@ use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gpt_image_1::edit_image::byok::{gpt_edit_image, GptEditImageRequest};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -45,31 +44,6 @@ pub enum GptEditImageNumImages{
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptEditImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptEditImageQuality::Auto => 17,
GptEditImageQuality::Low => 1,
GptEditImageQuality::Medium => 4,
GptEditImageQuality::High => 17,
};
let cost = match self.num_images {
GptEditImageNumImages::One => base_cost,
GptEditImageNumImages::Two => base_cost * 2,
GptEditImageNumImages::Three => base_cost * 3,
GptEditImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_edit_image_webhook<V: IntoUrl>(
args: GptEditImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gpt_image::gpt_image_1p5_image_edit::{gpt_image_1p5_image_edit, GptImage1p5ImageEditInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -70,34 +69,6 @@ pub enum EnqueueGptImage1p5EditImageOutputFormat {
Webp,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1p5EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost different amounts based on the number of images, quality, and size.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
let use_quality = self.quality.unwrap_or(EnqueueGptImage1p5EditImageQuality::Medium);
let use_size = self.image_size.unwrap_or(EnqueueGptImage1p5EditImageSize::Square);
let base_cost = match (use_quality, use_size) {
(EnqueueGptImage1p5EditImageQuality::Low, EnqueueGptImage1p5EditImageSize::Square) => 1,
(EnqueueGptImage1p5EditImageQuality::Low, _) => 1,
(EnqueueGptImage1p5EditImageQuality::Medium, EnqueueGptImage1p5EditImageSize::Square) => 3,
(EnqueueGptImage1p5EditImageQuality::Medium, _) => 5,
(EnqueueGptImage1p5EditImageQuality::High, EnqueueGptImage1p5EditImageSize::Square) => 13,
(EnqueueGptImage1p5EditImageQuality::High, _) => 20,
};
let cost = match self.num_images {
EnqueueGptImage1p5EditImageNumImages::One => base_cost,
EnqueueGptImage1p5EditImageNumImages::Two => base_cost * 2,
EnqueueGptImage1p5EditImageNumImages::Three => base_cost * 3,
EnqueueGptImage1p5EditImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1p5_image_edit_webhook<R: IntoUrl>(
args: EnqueueGptImage1p5EditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::nano_banana_pro::nano_banana_pro_image_edit::{nano_banana_pro_image_edit, NanoBananaProImageEditInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -57,30 +56,6 @@ pub enum EnqueueNanoBananaProEditImageAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueNanoBananaProEditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.15 per image.
// For $1.00, you can run this model 7 times.
// 4K outputs will be charged at double the standard rate.
// If web search is used, an additional $0.015 will be charged.
// Note: Pricing may change in the future.
let cost = match self.resolution {
None => 15,
Some(EnqueueNanoBananaProEditImageResolution::OneK) => 15,
Some(EnqueueNanoBananaProEditImageResolution::TwoK) => 15,
Some(EnqueueNanoBananaProEditImageResolution::FourK) => 30,
};
let cost = match self.num_images {
EnqueueNanoBananaProEditImageNumImages::One => cost,
EnqueueNanoBananaProEditImageNumImages::Two => cost * 2,
EnqueueNanoBananaProEditImageNumImages::Three => cost * 3,
EnqueueNanoBananaProEditImageNumImages::Four => cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_nano_banana_pro_image_edit_webhook<R: IntoUrl>(
args: EnqueueNanoBananaProEditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::qwen_image_edit::{qwen_image_edit, QwenImageEditInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -46,23 +45,6 @@ pub enum QwenImageEditSize {
//Custom { width: u32, height: u32 }, // TODO
}
impl <U: IntoUrl, R: IntoUrl> FalRequestCostCalculator for QwenImageEditArgs<'_, U, R> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.03 per megapixel. -- Ugh, not calculating that
let base_cost = 3;
let cost = match self.num_images {
None => base_cost,
Some(QwenImageEditNumImages::One) => base_cost,
Some(QwenImageEditNumImages::Two) => base_cost * 2,
Some(QwenImageEditNumImages::Three) => base_cost * 3,
Some(QwenImageEditNumImages::Four) => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_qwen_image_edit_webhook<U: IntoUrl, R: IntoUrl>(
args: QwenImageEditArgs<'_, U, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::flux_pro::v1_1::{v1_1, FluxProPlusTextToImageInput, ImageSizeProperty};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -34,22 +33,6 @@ pub enum FluxPro11NumImages {
Four,
}
impl <U: IntoUrl> FalRequestCostCalculator for FluxPro11Args<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.04 per megapixel. -- NB: What? Not going to calculate that.
let base_cost = 6;
let cost = match self.num_images {
FluxPro11NumImages::One => base_cost,
FluxPro11NumImages::Two => base_cost * 2,
FluxPro11NumImages::Three => base_cost * 3,
FluxPro11NumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_flux_pro_11_text_to_image_webhook<U: IntoUrl>(
args: FluxPro11Args<'_, U>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::flux_pro::v1_1_ultra::{v1_1_ultra, AspectRatioProperty, FluxProUltraTextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -36,22 +35,6 @@ pub enum FluxPro11UltraNumImages {
Four,
}
impl <U: IntoUrl> FalRequestCostCalculator for FluxPro11UltraArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.06 per image.
let base_cost = 6;
let cost = match self.num_images {
FluxPro11UltraNumImages::One => base_cost,
FluxPro11UltraNumImages::Two => base_cost * 2,
FluxPro11UltraNumImages::Three => base_cost * 3,
FluxPro11UltraNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_flux_pro_11_ultra_text_to_image_webhook<U: IntoUrl>(
args: FluxPro11UltraArgs<'_, U>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -2,7 +2,6 @@ use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::prelude::fal_ai::gpt_image_1::edit_image::byok::{gpt_text_to_image, GptTextToImageRequest};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -44,31 +43,6 @@ pub enum GptTextToImageNumImages{
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptTextToImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptTextToImageQuality::Auto => 17,
GptTextToImageQuality::Low => 1,
GptTextToImageQuality::Medium => 4,
GptTextToImageQuality::High => 17,
};
let cost = match self.num_images {
GptTextToImageNumImages::One => base_cost,
GptTextToImageNumImages::Two => base_cost * 2,
GptTextToImageNumImages::Three => base_cost * 3,
GptTextToImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4_text_to_image::BytedanceSeedreamV4TextToImageInput;
use fal::prelude::fal_ai::bytedance::seedream::bytedance_seedream_v4_text_to_image::bytedance_seedream_v4_text_to_image;
use fal::webhook::WebhookResponse;
@@ -54,24 +53,6 @@ pub enum EnqueueBytedanceSeedreamV4TextToImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Copied from edit image version
// Your request will cost $0.03 per image.
let unit_cost = 3;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4_text_to_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4TextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -4,8 +4,6 @@ use crate::error::fal_error_plus::FalErrorPlus;
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4p5_text_to_image::{bytedance_seedream_v4p5_text_to_image, BytedanceSeedreamV4p5TextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::{EnqueueBytedanceSeedreamV4p5EditImageArgs, EnqueueBytedanceSeedreamV4p5EditImageNumImages};
pub struct EnqueueBytedanceSeedreamV4p5TextToImageArgs<'a, R: IntoUrl> {
// Request required
@@ -53,24 +51,6 @@ pub enum EnqueueBytedanceSeedreamV4p5TextToImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4p5TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// NB: Copied from edit image costs
// Your request will cost $0.04 per image.
let unit_cost = 4;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4p5_text_to_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4p5TextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -5,8 +5,6 @@ use fal::prelude::fal_ai::gemini_25_flash_image::text_to_image::gemini_25_flash_
use fal::prelude::fal_ai::gemini_25_flash_image::text_to_image::Gemini25FlashTextToImageInput;
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{Gemini25FlashEditArgs, Gemini25FlashEditNumImages};
pub struct Gemini25FlashTextToImageArgs<'a, R: IntoUrl> {
// Request required
@@ -48,21 +46,6 @@ pub enum Gemini25FlashTextToImageAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for Gemini25FlashTextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// NB: This was copied from the image-to-image case.
// Your request will cost $0.039 per image. For $1.00, you can run this model 25 times.
match self.num_images {
Gemini25FlashTextToImageNumImages::One => 4,
Gemini25FlashTextToImageNumImages::Two => 8,
Gemini25FlashTextToImageNumImages::Three => 12,
Gemini25FlashTextToImageNumImages::Four => 16,
}
}
}
pub async fn enqueue_gemini_25_flash_text_to_image_webhook<R: IntoUrl>(
args: Gemini25FlashTextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,7 +1,6 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gpt_image::gpt_image_1p5_text_to_image::{gpt_image_1p5_text_to_image, GptImage1p5TextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -62,34 +61,6 @@ pub enum EnqueueGptImage1p5TextToImageOutputFormat {
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1p5TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// NB: copied from edit image case - should be correct, but not verified.
// Your request will cost different amounts based on the number of images, quality, and size.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
let use_quality = self.quality.unwrap_or(EnqueueGptImage1p5TextToImageQuality::Medium);
let use_size = self.image_size.unwrap_or(EnqueueGptImage1p5TextToImageSize::Square);
let base_cost = match (use_quality, use_size) {
(EnqueueGptImage1p5TextToImageQuality::Low, EnqueueGptImage1p5TextToImageSize::Square) => 1,
(EnqueueGptImage1p5TextToImageQuality::Low, _) => 1,
(EnqueueGptImage1p5TextToImageQuality::Medium, EnqueueGptImage1p5TextToImageSize::Square) => 3,
(EnqueueGptImage1p5TextToImageQuality::Medium, _) => 5,
(EnqueueGptImage1p5TextToImageQuality::High, EnqueueGptImage1p5TextToImageSize::Square) => 13,
(EnqueueGptImage1p5TextToImageQuality::High, _) => 20,
};
let cost = match self.num_images {
EnqueueGptImage1p5TextToImageNumImages::One => base_cost,
EnqueueGptImage1p5TextToImageNumImages::Two => base_cost * 2,
EnqueueGptImage1p5TextToImageNumImages::Three => base_cost * 3,
EnqueueGptImage1p5TextToImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1p5_text_to_image_webhook<R: IntoUrl>(
args: EnqueueGptImage1p5TextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -4,8 +4,6 @@ use crate::error::fal_error_plus::FalErrorPlus;
use fal::endpoints::fal_ai::nano_banana_pro::nano_banana_pro_text_to_image::{nano_banana_pro_text_to_image, NanoBananaProTextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::{EnqueueNanoBananaProEditImageArgs, EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageResolution};
pub struct EnqueueNanoBananaProTextToImageArgs<'a, R: IntoUrl> {
// Request required
@@ -55,31 +53,6 @@ pub enum EnqueueNanoBananaProTextToImageAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueNanoBananaProTextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.15 per image.
// For $1.00, you can run this model 7 times.
// 4K outputs will be charged at double the standard rate.
// If web search is used, an additional $0.015 will be charged.
// Note: Pricing may change in the future.
let cost = match self.resolution {
None => 15,
Some(EnqueueNanoBananaProTextToImageResolution::OneK) => 15,
Some(EnqueueNanoBananaProTextToImageResolution::TwoK) => 15,
Some(EnqueueNanoBananaProTextToImageResolution::FourK) => 30,
};
let cost = match self.num_images {
EnqueueNanoBananaProTextToImageNumImages::One => cost,
EnqueueNanoBananaProTextToImageNumImages::Two => cost * 2,
EnqueueNanoBananaProTextToImageNumImages::Three => cost * 3,
EnqueueNanoBananaProTextToImageNumImages::Four => cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_nano_banana_pro_text_to_image_webhook<R: IntoUrl>(
args: EnqueueNanoBananaProTextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "artcraft"
version = "0.6.0"
version = "0.5.0"
description = "Artcraft"
authors = ["you"]
license = ""
@@ -54,7 +54,6 @@ bytes = "1.10.1"
chrono.workspace = true
directories = "6.0.0"
dirs = "6.0.0"
discord-rich-presence = "1.1.0"
futures = "0.3"
image = "0.25.5"
indexmap.workspace = true
@@ -78,6 +77,10 @@ url.workspace = true
# NB: We need to vendor this in so that we can access the cookies inside via the private API
tauri-plugin-http = { path = "../../vendor/tauri-plugin-http" }
# NB: The version of the `discord-rich-presence` crate (0.2.5) last published has non-Send/Sync error
# types and can't do threading. We need to wait for a new version to unpin from Git.
discord-rich-presence = { git = "https://github.com/vionya/discord-rich-presence" }
tauri-plugin-opener = "2"
# # Transparent title bar on MacOS
@@ -1,8 +1,8 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
/// This is a comprehensive list of common aspect ratios.
/// Not every model will support every aspect ratio.
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
/// In the case a model doesn't support the aspect ratio, pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
@@ -1,2 +1 @@
pub mod aspect_ratio;
pub mod resolution;
pub mod aspect_ratio;
@@ -1,17 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
/// Not every model will support every resolution
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonResolution {
/// Models: Nano Banana Pro
OneK,
/// Models: Nano Banana Pro
TwoK,
/// Models: (None)
ThreeK,
/// Models: Nano Banana Pro
FourK,
}
@@ -1 +0,0 @@
pub mod resolution_to_artcraft_nano_banana_pro;
@@ -1,12 +0,0 @@
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenImageResolution;
pub fn resolution_to_artcraft_nano_banana_pro(resolution: CommonResolution) -> NanoBananaProMultiFunctionImageGenImageResolution {
match resolution {
CommonResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
CommonResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
// NB: ThreeK doesn't exist in Nano Banana Pro, use FourK as nearest higher resolution
CommonResolution::ThreeK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
CommonResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
}
}
@@ -1,2 +0,0 @@
pub mod common_resolution;
pub mod convert;
@@ -62,7 +62,8 @@ pub async fn handle_artcraft_gpt_image_1p5_text_to_image(
prompt: request.prompt.clone(),
image_size: aspect_ratio,
num_images,
image_media_tokens: request.image_media_tokens.clone(),
// Not provided for text-to-image
image_media_tokens: None,
mask_image_token: None,
input_fidelity: None,
// Not provided
@@ -1,7 +1,5 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana_pro::aspect_ratio_to_artcraft_nano_banana_pro;
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use crate::core::api_adapters::resolution::convert::resolution_to_artcraft_nano_banana_pro::resolution_to_artcraft_nano_banana_pro;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageResolution, EditImageSize};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
@@ -63,12 +61,18 @@ pub async fn handle_artcraft_nano_banana_pro_text_to_image(
};
let aspect_ratio = get_aspect_ratio(request);
let resolution = get_resolution(request);
let resolution = request.image_resolution
.map(|image_resolution| match image_resolution {
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
});
let request = NanoBananaProMultiFunctionImageGenRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_media_tokens: request.image_media_tokens.clone(),
image_media_tokens: None,
num_images,
resolution,
aspect_ratio,
@@ -122,20 +126,3 @@ fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option<NanoBananaPro
None
}
fn get_resolution(request: &EnqueueTextToImageRequest) -> Option<NanoBananaProMultiFunctionImageGenImageResolution> {
if let Some(common_resolution) = request.common_resolution {
return Some(resolution_to_artcraft_nano_banana_pro(common_resolution));
}
if let Some(resolution) = request.image_resolution {
// Deprecated resolution
return Some(match resolution {
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
})
}
None
}
@@ -1,5 +1,3 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
use crate::core::commands::enqueue::image_edit::image_edit_models::image_edit_model_to_model_type;
@@ -18,8 +16,6 @@ use crate::core::events::generation_events::common::{GenerationAction, Generatio
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::state::task_database::TaskDatabase;
@@ -38,6 +34,9 @@ use serde::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
@@ -89,20 +88,15 @@ pub struct EnqueueTextToImageRequest {
/// Text prompt for the image generation. Required.
pub prompt: Option<String>,
/// Aspect ratio.
#[deprecated(note="use common_aspect_ratio")]
pub aspect_ratio: Option<TextToImageSize>,
/// New field for aspect ratio.
/// Not all models support each of these aspect ratios, but we can choose or interpolate sensibly.
pub common_aspect_ratio: Option<CommonAspectRatio>,
/// New field for resolution.
/// Not all models support each of these resolutions, but we can choose or interpolate sensibly.
pub common_resolution: Option<CommonResolution>,
/// Aspect ratio.
#[deprecated(note="use common_aspect_ratio")]
pub aspect_ratio: Option<TextToImageSize>,
/// Aspect ratio.
#[deprecated(note="use common_resolution")]
pub image_resolution: Option<TextToImageResolution>,
/// The number of images to generate.
+1 -1
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.6.0";
pub const ARTCRAFT_VERSION: &str = "0.5.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.6.0",
"version": "0.5.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.6.0",
"version": "0.5.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
-40
View File
@@ -1,40 +0,0 @@
# For things that are too application-logic specific to live in generic actix helpers.
[package]
name = "actix_artcraft"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "actix_artcraft"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
actix_helpers.workspace = true
cookies.workspace = true
enums.workspace = true
errors.workspace = true
http_headers.workspace = true
tokens.workspace = true
# External
actix-http.workspace = true
actix-service.workspace = true
actix-web.workspace = true
log.workspace = true
anyhow.workspace = true
[dev-dependencies]
# (None yet)
-21
View File
@@ -1,21 +0,0 @@
//! actix_artcraft - for actix helpers that need to be shared between crates,
//! but are too application-specific to be in general actix helper crates
//!
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things
// Okay to toggle
#![forbid(unreachable_patterns)]
#![forbid(unused_imports)]
#![forbid(unused_mut)]
#![forbid(unused_variables)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod requests;
pub mod sessions;
@@ -1 +0,0 @@
pub mod get_request_signup_source_enum;
+1 -2
View File
@@ -29,8 +29,7 @@ log = { workspace = true }
#symphonia = { version = "0.5.2", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "vorbis", "wav"] }
# Opus support still hasn't landed in Symphonia, so we have to use an older version (~0.5) off-master.
# Opus is the browser's default (only) recording codec: https://blog.addpipe.com/audio-constraints-getusermedia/
# symphonia = { git = "https://github.com/pdeljanov/Symphonia.git", rev = "e1a70095c4409478c1ded499fb8dd4afa540fb90", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
symphonia = { path = "../../../vendor/Symphonia/symphonia", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
symphonia = { git = "https://github.com/pdeljanov/Symphonia.git", rev = "e1a70095c4409478c1ded499fb8dd4afa540fb90", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
[dev-dependencies]
# None yet
@@ -0,0 +1,33 @@
[package]
name = "shared_service_components"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "shared_service_components"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
openai_sora_client = { workspace = true }
errors = { workspace = true }
# External
anyhow = { workspace = true }
log = { workspace = true }
r2d2_redis = "0.14.0"
reqwest = { workspace = true, features = ["multipart", "gzip"] }
[dev-dependencies]
# None yet
@@ -0,0 +1,4 @@
//! This library contains code shared between `storyteller-web` and jobs.
pub mod sora_redis_credentials;
pub mod sora_redis_sentinel_refresh;
@@ -0,0 +1,33 @@
use crate::sora_redis_credentials::keys::{BEARER_SUBKEY, COOKIE_SUBKEY, SENTINEL_SUBKEY, SORA_SECRET_REDIS_KEY};
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::creds::sora_credential_builder::SoraCredentialBuilder;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use std::collections::HashMap;
pub fn get_sora_credentials_from_redis(
redis: &mut PooledConnection<RedisConnectionManager>
) -> AnyhowResult<SoraCredentialSet> {
let values : HashMap<String, String> = redis.hgetall(SORA_SECRET_REDIS_KEY)
.map_err(|e| anyhow!("Failed to get Sora credentials from Redis: {}", e))?;
let bearer = values.get(BEARER_SUBKEY);
let cookie = values.get(COOKIE_SUBKEY);
let sentinel = values.get(SENTINEL_SUBKEY);
match (bearer, cookie, sentinel) {
(Some(b), Some(c), Some(s)) => {
let creds = SoraCredentialBuilder::new()
.with_jwt_bearer_token(&b)
.with_cookies(&c)
.with_sora_sentinel(&s)
.build()?;
Ok(creds)
}
_ => Err(anyhow!("redis sora credential values not present")),
}
}
@@ -0,0 +1,29 @@
/// Redis key
pub (crate) const SORA_SECRET_REDIS_KEY : &str = "sora_secret";
/// HKEY field for the bearer token
pub (crate) const BEARER_SUBKEY : &str = "bearer";
/// HKEY field for the cookie
pub (crate) const COOKIE_SUBKEY : &str = "cookie";
/// HKEY field for the sentinel
pub (crate) const SENTINEL_SUBKEY : &str = "sentinel";
/// Which subkey to write
#[derive(Clone,Copy,Debug)]
pub enum RedisSoraCredentialSubkey {
Bearer,
Cookie,
Sentinel,
}
impl RedisSoraCredentialSubkey {
pub (crate) fn to_str(&self) -> &'static str {
match self {
RedisSoraCredentialSubkey::Bearer => BEARER_SUBKEY,
RedisSoraCredentialSubkey::Cookie => COOKIE_SUBKEY,
RedisSoraCredentialSubkey::Sentinel => SENTINEL_SUBKEY,
}
}
}
@@ -0,0 +1,3 @@
pub mod keys;
pub mod set_sora_credential_field_in_redis;
pub mod get_sora_credentials_from_redis;
@@ -0,0 +1,18 @@
use anyhow::anyhow;
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use errors::AnyhowResult;
use crate::sora_redis_credentials::keys::{RedisSoraCredentialSubkey, SORA_SECRET_REDIS_KEY};
pub fn set_sora_credential_field_in_redis(
redis: &mut PooledConnection<RedisConnectionManager>,
subkey: RedisSoraCredentialSubkey,
value: &str,
) -> AnyhowResult<()> {
redis.hset(SORA_SECRET_REDIS_KEY, subkey.to_str(), value)
.map_err(|e| anyhow!("Failed to set Sora credentials in Redis: {}", e))?;
Ok(())
}
@@ -0,0 +1 @@
pub mod refresh;
@@ -0,0 +1,19 @@
/// Send a POST request to env variable SORA_SENTINEL_REFRESH_URL
use errors::AnyhowResult;
use reqwest::Client;
use std::env;
const SORA_SENTINEL_REFRESH_URL: &str = "http://oai-token-server/refresh_token";
/// Refresh the Sora sentinel
/// The response body does _not_ contain the new sentinel
/// We should read it again from redis or generate a new one here
/// For now we refresh in the server handler
pub async fn refresh_sentinel() -> AnyhowResult<String> {
let client = Client::new();
let url = env::var("SORA_SENTINEL_REFRESH_URL").unwrap_or(SORA_SENTINEL_REFRESH_URL.to_string());
let response = client.post(url).send().await?;
let body = response.text().await?;
Ok(body)
}
-29
View File
@@ -1,29 +0,0 @@
[package]
name = "users"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "users"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
collections.workspace = true
primitives.workspace = true
# External
md-5.workspace = true
once_cell.workspace = true
rand.workspace = true
[dev-dependencies]
# (None)
@@ -1,34 +0,0 @@
use md5::{Digest, Md5};
pub fn email_to_gravatar_hash(email_address: &str) -> String {
let email = email_address.trim().to_lowercase();
let mut hasher = Md5::new();
hasher.update(email);
let hash = hasher.finalize();
let gravatar_hash = format!("{:x}", hash);
gravatar_hash
}
#[cfg(test)]
pub mod tests {
use crate::email::email_to_gravatar_hash::email_to_gravatar_hash;
#[test]
fn test_gravatar() {
// Email 1
assert_eq!(email_to_gravatar_hash("example@example.com"), "23463b99b62a72f26ed677cc556c44e8".to_string());
assert_eq!(email_to_gravatar_hash("EXAMPLE@EXAMPLE.COM"), "23463b99b62a72f26ed677cc556c44e8".to_string());
assert_eq!(email_to_gravatar_hash(" example@example.com \n "), "23463b99b62a72f26ed677cc556c44e8".to_string());
// Email 2
assert_eq!(email_to_gravatar_hash("echelon@gmail.com"), "37e7e18f19d8f654a1ace405c846a9de".to_string());
assert_eq!(email_to_gravatar_hash("ECHELON@GMAIL.COM"), "37e7e18f19d8f654a1ace405c846a9de".to_string());
assert_eq!(email_to_gravatar_hash(" ECHELON@GMAIL.COM "), "37e7e18f19d8f654a1ace405c846a9de".to_string());
// Misc
assert_eq!(email_to_gravatar_hash(""), "d41d8cd98f00b204e9800998ecf8427e".to_string());
assert_eq!(email_to_gravatar_hash(" "), "d41d8cd98f00b204e9800998ecf8427e".to_string());
}
}
@@ -1,36 +0,0 @@
use rand::Rng;
const SYNTHETIC_EMAIL_PREFIX : &str = "synthetic.email";
const SYNTHETIC_EMAIL_DOMAIN : &str = "getartcraft.com";
pub fn generate_random_synthetic_email() -> String {
let random_digit = rand::thread_rng().gen_range(u32::MIN..u32::MAX);
format!("{}.{}@{}", SYNTHETIC_EMAIL_PREFIX, random_digit, SYNTHETIC_EMAIL_DOMAIN)
}
#[cfg(test)]
mod tests {
use crate::email::generate_random_synthetic_email::generate_random_synthetic_email;
use std::collections::HashSet;
#[test]
fn test_base_case_success() {
assert!(generate_random_synthetic_email().len() > 0);
}
#[test]
fn generate_lots() {
let mut collection = HashSet::new();
for _ in 0..100 {
collection.insert(generate_random_synthetic_email());
}
assert!(collection.len() > 50); // NB: Should be an easy bar to hit
}
#[test]
fn test_email_format() {
let email = generate_random_synthetic_email();
assert!(email.contains("@"));
assert!(email.contains("."));
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod email_to_gravatar_hash;
pub mod generate_random_synthetic_email;
pub mod validate_email_address_format;
@@ -1,182 +0,0 @@
/// Basic email format validation
pub fn validate_email_address_format(email: &str) -> Result<(), String> {
let email = email.trim();
if email.is_empty() {
return Err("email address cannot be empty".to_string());
}
if email.len() > 255 {
return Err("email address is too long".to_string());
}
// Basic format check: must contain exactly one @ with content on both sides
let at_count = email.matches('@').count();
if at_count != 1 {
return Err("email address must contain exactly one @".to_string());
}
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return Err("invalid email format".to_string());
}
let local = parts[0];
let domain = parts[1];
if local.is_empty() {
return Err("email address must have content before @".to_string());
}
if domain.is_empty() {
return Err("email address must have a domain after @".to_string());
}
// Domain must contain at least one dot
if !domain.contains('.') {
return Err("email domain must contain a dot".to_string());
}
// Domain cannot start or end with a dot
if domain.starts_with('.') || domain.ends_with('.') {
return Err("email domain cannot start or end with a dot".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::validate_email_address_format;
// Valid emails
#[test]
fn test_valid_simple_email() {
assert!(validate_email_address_format("user@example.com").is_ok());
}
#[test]
fn test_valid_email_with_subdomain() {
assert!(validate_email_address_format("user@mail.example.com").is_ok());
}
#[test]
fn test_valid_email_with_plus() {
assert!(validate_email_address_format("user+tag@example.com").is_ok());
}
#[test]
fn test_valid_email_with_dots_in_local() {
assert!(validate_email_address_format("first.last@example.com").is_ok());
}
#[test]
fn test_valid_email_with_numbers() {
assert!(validate_email_address_format("user123@example123.com").is_ok());
}
#[test]
fn test_valid_email_trimmed() {
assert!(validate_email_address_format(" user@example.com ").is_ok());
}
// Empty/whitespace
#[test]
fn test_invalid_empty() {
let result = validate_email_address_format("");
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
#[test]
fn test_invalid_whitespace_only() {
let result = validate_email_address_format(" ");
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
// Length
#[test]
fn test_invalid_too_long() {
let long_email = format!("{}@example.com", "a".repeat(250));
let result = validate_email_address_format(&long_email);
assert!(result.is_err());
assert!(result.unwrap_err().contains("too long"));
}
#[test]
fn test_valid_at_max_length() {
// 255 chars is the max
let local_part = "a".repeat(242); // 242 + @ + example.com (11) = 254
let email = format!("{}@example.com", local_part);
assert_eq!(email.len(), 254);
assert!(validate_email_address_format(&email).is_ok());
}
// @ symbol
#[test]
fn test_invalid_no_at() {
let result = validate_email_address_format("userexample.com");
assert!(result.is_err());
assert!(result.unwrap_err().contains("exactly one @"));
}
#[test]
fn test_invalid_multiple_at() {
let result = validate_email_address_format("user@@example.com");
assert!(result.is_err());
assert!(result.unwrap_err().contains("exactly one @"));
}
#[test]
fn test_invalid_multiple_at_separated() {
let result = validate_email_address_format("user@name@example.com");
assert!(result.is_err());
assert!(result.unwrap_err().contains("exactly one @"));
}
// Local part
#[test]
fn test_invalid_no_local_part() {
let result = validate_email_address_format("@example.com");
assert!(result.is_err());
assert!(result.unwrap_err().contains("content before @"));
}
// Domain part
#[test]
fn test_invalid_no_domain() {
let result = validate_email_address_format("user@");
assert!(result.is_err());
assert!(result.unwrap_err().contains("domain after @"));
}
#[test]
fn test_invalid_domain_no_dot() {
let result = validate_email_address_format("user@localhost");
assert!(result.is_err());
assert!(result.unwrap_err().contains("must contain a dot"));
}
#[test]
fn test_invalid_domain_starts_with_dot() {
let result = validate_email_address_format("user@.example.com");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot start or end with a dot"));
}
#[test]
fn test_invalid_domain_ends_with_dot() {
let result = validate_email_address_format("user@example.com.");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot start or end with a dot"));
}
#[test]
fn test_invalid_domain_only_dot() {
let result = validate_email_address_format("user@.");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot start or end with a dot"));
}
}
-20
View File
@@ -1,20 +0,0 @@
//! users
//!
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)] // NB: It's unsafe to not close/check some things
// Okay to toggle
#![forbid(unreachable_patterns)]
#![forbid(unused_imports)]
#![forbid(unused_mut)]
#![forbid(unused_variables)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod email;
pub mod username;
@@ -1,3 +0,0 @@
/// Username may be up to this many characters, but not more.
pub const USERNAME_MAX_LENGTH: usize = 16;
-2
View File
@@ -1,2 +0,0 @@
pub mod generate_random_username;
pub mod constants;
@@ -64,11 +64,6 @@ pub async fn create_account_from_email_and_password(
// NB: This is just for testing.
maybe_user_token: args.maybe_user_token,
// These flags represent eagerly created users and are false for this flow
is_temporary: false,
email_is_synthetic: false,
was_eagerly_provisioned: false,
},
Transactor::for_pool(mysql_pool),
).await?;
@@ -59,11 +59,6 @@ pub async fn create_account_from_google_sso(
// NB: This is just for testing.
maybe_user_token: None,
// These flags represent eagerly created users and are false for this flow
is_temporary: false,
email_is_synthetic: false,
was_eagerly_provisioned: false,
},
transactor,
).await?;
@@ -1,78 +0,0 @@
// NB: Incrementally getting rid of build warnings...
#![forbid(unused_imports)]
#![forbid(unused_mut)]
#![forbid(unused_variables)]
use crate::queries::users::user::create::create_account_error::CreateAccountError;
use crate::queries::users::user::create::create_account_generic::{create_account_generic, GenericCreateAccountArgs};
use crate::utils::transactor::Transactor;
use enums::by_table::users::user_signup_method::UserSignupMethod;
use enums::by_table::users::user_signup_source::UserSignupSource;
use tokens::tokens::users::UserToken;
/// Stripe checkout sessions do not have a real email address or password.
/// The user will create these once the checkout session is completed.
/// The password hash field is nullable, so we can't leave it null/empty.
const SSO_PASSWORD : &str = "*";
pub struct CreateAccountFromStripeCheckoutArgs<'a> {
// NB: Username will likely be generated by our code, not the user.
pub username: &'a str,
pub display_name: &'a str,
// NB: Email will likely be generated by our code, not the user.
pub email_address: &'a str,
pub email_gravatar_hash: &'a str,
pub ip_address: &'a str,
pub maybe_source: Option<UserSignupSource>,
/// Comma separated string of feature flags.
pub maybe_feature_flags: Option<&'a str>,
}
pub async fn create_account_from_stripe_checkout(
args: CreateAccountFromStripeCheckoutArgs<'_>,
transactor: Transactor<'_, '_>,
) -> Result<UserToken, CreateAccountError>
{
let result= create_account_generic(
GenericCreateAccountArgs {
maybe_signup_method: Some(UserSignupMethod::StripeCheckout),
ip_address: args.ip_address,
maybe_feature_flags: args.maybe_feature_flags,
maybe_source: args.maybe_source,
// These are synthetic user accounts that are being eagerly created
// If the user abandons the onboarding flow, they can be deleted later.
is_temporary: true,
was_eagerly_provisioned: true,
// Stripe checkout users have randomly generated usernames
username: args.username,
display_name: args.display_name,
username_is_generated: true,
username_is_not_customized: true,
// Stripe checkout users have NO REAL EMAIL ADDRESS yet.
// Stripe will tell us what their email address is once
// they complete the checkout (or the user can update it,
// and their write wins.)
email_address: args.email_address,
email_gravatar_hash: args.email_gravatar_hash,
email_confirmed_by_google: false,
email_is_synthetic: true,
// SSO accounts start without a password
password_hash: SSO_PASSWORD,
is_without_password: true,
// NB: This is just for testing.
maybe_user_token: None,
},
transactor,
).await?;
Ok(result.user_token)
}
@@ -23,35 +23,10 @@ pub struct GenericCreateAccountArgs<'a> {
pub email_address: &'a str,
pub email_gravatar_hash: &'a str,
/// Google did email IDV and told us it's legitimate.
pub email_confirmed_by_google: bool,
/// The email address is not real yet. We generated a fake/synthetic value
/// that will hopefully be replaced later. This is for automated user creation
/// flows, like Stripe Checkout onboarding flow.
pub email_is_synthetic: bool,
pub password_hash: &'a str,
pub is_without_password: bool,
/// This is initially TRUE for accounts that were provisioned automatically,
/// without user intervention, e.g. in the eager Stripe Checkout onboarding
/// flow. Accounts in this state have not yet been customized by users and may
/// not in fact represent real users if the users abandon the flow. Once the
/// user finishes setup, this will be set to false.
///
/// Existing and normal setup flow sets this flag to false.
///
/// If enough time has passed without the user interacting with the account,
/// then it might be prudent to delete these records.
pub is_temporary: bool,
/// Whether the account was created eagerly (e.g. via stripe checkout flow)
/// without the user explicitly setting up an account, choosing an email or
/// password, etc. (It's a technical choice to do faster checkout flow.)
/// This is a permanent label on the account.
pub was_eagerly_provisioned: bool,
pub ip_address: &'a str,
pub maybe_source: Option<UserSignupSource>,
@@ -91,9 +66,6 @@ pub async fn create_account_generic(
INSERT INTO users
SET
token = ?,
is_temporary = ?,
username = ?,
display_name = ?,
@@ -105,9 +77,6 @@ SET
email_confirmed = FALSE,
email_confirmed_by_google = ?,
email_is_synthetic = ?,
was_eagerly_provisioned = ?,
profile_markdown = ?,
profile_rendered_html = ?,
@@ -127,9 +96,6 @@ SET
maybe_signup_method = ?
"#,
user_token.as_str(),
args.is_temporary,
args.username,
args.display_name,
@@ -140,9 +106,6 @@ SET
args.email_gravatar_hash,
args.email_confirmed_by_google,
args.email_is_synthetic,
args.was_eagerly_provisioned,
INITIAL_PROFILE_MARKDOWN,
INITIAL_PROFILE_RENDERED_HTML,
@@ -3,4 +3,3 @@ pub mod create_account_from_email_and_password;
pub mod create_account_from_google_sso;
pub (crate) mod create_account_generic;
pub mod create_account_from_stripe_checkout;
@@ -1,123 +0,0 @@
use crate::helpers::boolean_converters::i8_to_bool;
use crate::helpers::transform_optional_result::transform_optional_result;
use anyhow::anyhow;
use errors::AnyhowResult;
use sqlx::pool::PoolConnection;
use sqlx::{Executor, MySql};
use std::fmt::Display;
use tokens::tokens::users::UserToken;
pub struct MaybeUpdateEmailFromSyntheticValueArgs<'a> {
pub user_token: &'a UserToken,
pub email_address: &'a str,
pub email_gravatar_hash: &'a str,
pub mysql_connection: &'a mut PoolConnection<MySql>,
}
pub async fn maybe_update_email_from_synthetic_value(
args: MaybeUpdateEmailFromSyntheticValueArgs<'_>,
) -> AnyhowResult<()> {
let result = sqlx::query_as!(
UserEmailCheckRecordRaw,
r#"
SELECT
email_address,
email_is_synthetic
FROM users
WHERE token = ?
LIMIT 1
"#,
args.user_token.as_str(),
)
.fetch_one(&mut **args.mysql_connection)
.await;
let maybe_record = transform_optional_result(result)?;
let record = match maybe_record {
Some(record) => record,
None => return Err(anyhow!("User not found for token: {}", args.user_token)),
};
let record : UserEmailCheckRecord = record.into();
if !record.email_is_synthetic {
return Ok(());
}
let record_email = record.email_address.to_lowercase();
// NB: So we don't get the format de-synced, let's just trust the boolean flag.
//if !record_email.contains("synthetic") {
// return Ok(());
//}
// Normally we don't have much business logic in this crate, but let's be extra careful since we don't have a change log.
if record_email.contains("gmail")
|| record_email.contains("aol.com")
|| record_email.contains("apple")
|| record_email.contains("att.net")
|| record_email.contains("comcast")
|| record_email.contains("earthlink")
|| record_email.contains("hotmail")
|| record_email.contains("icloud")
|| record_email.contains("live.com")
|| record_email.contains("mac.com")
|| record_email.contains("mail.ru")
|| record_email.contains("me.com")
|| record_email.contains("outlook")
|| record_email.contains("proton")
|| record_email.contains("rocketmail")
|| record_email.contains("sbcglobal")
|| record_email.contains("yahoo")
|| record_email.contains("ymail")
|| record_email.contains("zoho")
{
// NB: Just to super extra careful, in case we change the synthetic email format.
// This obviously won't catch everything.
return Ok(());
}
let update_email = args.email_address.to_lowercase();
let query = sqlx::query!(
r#"
UPDATE users
SET
email_address = ?,
email_gravatar_hash = ?,
email_confirmed = false,
email_confirmed_by_google = false,
email_is_synthetic = false,
version = version + 1
WHERE
token = ?
LIMIT 1
"#,
update_email,
args.email_gravatar_hash,
args.user_token.as_str(),
);
args.mysql_connection.execute(query).await?;
Ok(())
}
struct UserEmailCheckRecordRaw {
email_address: String,
email_is_synthetic: i8,
}
struct UserEmailCheckRecord {
email_address: String,
email_is_synthetic: bool,
}
impl From<UserEmailCheckRecordRaw> for UserEmailCheckRecord {
fn from(raw: UserEmailCheckRecordRaw) -> Self {
Self {
email_address: raw.email_address,
email_is_synthetic: i8_to_bool(raw.email_is_synthetic),
}
}
}
@@ -1,9 +1,6 @@
pub mod maybe_update_email_from_synthetic_value;
pub mod set_can_access_studio_transactional;
pub mod set_user_ban_status;
pub mod set_user_feature_flags;
pub mod set_user_feature_flags_transactional;
pub mod update_email;
pub mod update_password;
pub mod update_user_record_with_new_stripe_customer_id;
pub mod update_username;
@@ -1,83 +0,0 @@
use crate::utils::transactor::Transactor;
use sqlx;
use sqlx::Error::Database;
use std::error::Error;
use std::fmt::{Display, Formatter};
use tokens::tokens::users::UserToken;
pub struct UpdateEmailArgs<'e, 't> {
pub token: &'e UserToken,
pub email_address: &'e str,
pub email_gravatar_hash: &'e str,
pub ip_address: &'e str,
pub transactor: Transactor<'e, 't>,
}
#[derive(Debug)]
pub enum UpdateEmailError {
EmailIsTaken,
DatabaseError { source: sqlx::Error },
}
impl Error for UpdateEmailError {}
impl Display for UpdateEmailError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
UpdateEmailError::EmailIsTaken => {
write!(f, "UpdateEmailError: email is taken")
}
UpdateEmailError::DatabaseError { source } => {
write!(f, "UpdateEmailError: database error: {:?}", source)
}
}
}
}
pub async fn update_email<'e, 't>(
args: UpdateEmailArgs<'e, 't>
) -> Result<(), UpdateEmailError>
{
let query = sqlx::query!(
r#"
UPDATE users
SET
email_address = ?,
email_confirmed = false,
email_confirmed_by_google = false,
email_is_synthetic = false,
email_gravatar_hash = ?,
ip_address_last_update = ?,
version = version + 1
WHERE
token = ?
LIMIT 1
"#,
args.email_address,
args.email_gravatar_hash,
args.ip_address,
args.token,
);
let query_result = args.transactor.execute(query).await;
match query_result {
Ok(_) => Ok(()),
Err(Database(err)) => {
let maybe_code = err.code()
.map(|c| c.into_owned());
// MySQL error code 23000 is a duplicate key constraint violation
if maybe_code.as_deref() == Some("23000")
&& err.message().contains("email_address")
{
return Err(UpdateEmailError::EmailIsTaken);
}
Err(UpdateEmailError::DatabaseError { source: Database(err) })
},
Err(err) => {
Err(UpdateEmailError::DatabaseError { source: err })
},
}
}
@@ -1,39 +0,0 @@
use crate::utils::transactor::Transactor;
use errors::AnyhowResult;
use sqlx;
use std::fmt::Display;
use tokens::tokens::users::UserToken;
pub struct UpdatePasswordArgs<'e, 't> {
pub user_token: &'e UserToken,
pub password_hash: &'e str,
pub ip_address: &'e str,
pub transactor: Transactor<'e, 't>,
}
pub async fn update_password<'e, 't>(
args: UpdatePasswordArgs<'e, 't>
) -> AnyhowResult<()>
{
let query = sqlx::query!(
r#"
UPDATE users
SET
password_hash = ?,
ip_address_last_update = ?,
is_without_password = false,
password_version = password_version + 1,
version = version + 1
WHERE
token = ?
LIMIT 1
"#,
args.password_hash,
args.ip_address,
args.user_token.as_str(),
);
args.transactor.execute(query).await?;
Ok(())
}
@@ -22,9 +22,6 @@ pub async fn change_password_from_password_reset<'a, 'b>(
UPDATE users
SET
email_confirmed = true,
email_is_synthetic = false,
is_without_password = false,
is_temporary = false,
password_hash = ?,
ip_address_last_update = ?,
password_version = password_version + 1,
@@ -20,15 +20,8 @@ pub struct SessionUserRecord {
pub display_name: String,
pub email_address: String,
pub email_gravatar_hash: String,
// ===== ONBOARDING STATE ===== //
pub email_confirmed: bool,
pub email_confirmed_by_google: bool,
pub email_is_synthetic: bool,
pub is_without_password: bool,
pub username_is_not_customized: bool,
pub email_gravatar_hash: String,
// ===== PREMIUM FEATURES ===== //
@@ -118,13 +111,8 @@ SELECT
users.display_name,
users.email_address,
users.email_gravatar_hash,
users.email_confirmed,
users.email_confirmed_by_google,
users.email_is_synthetic,
users.is_without_password,
users.username_is_not_customized,
users.email_gravatar_hash,
users.maybe_stripe_customer_id,
users.maybe_loyalty_program_key,
@@ -185,13 +173,8 @@ WHERE user_sessions.token = ?
username: raw_user_record.username,
display_name: raw_user_record.display_name,
email_address: raw_user_record.email_address,
email_gravatar_hash: raw_user_record.email_gravatar_hash,
// Onboarding
email_confirmed: i8_to_bool(raw_user_record.email_confirmed),
email_confirmed_by_google: i8_to_bool(raw_user_record.email_confirmed_by_google),
email_is_synthetic: i8_to_bool(raw_user_record.email_is_synthetic),
is_without_password: i8_to_bool(raw_user_record.is_without_password),
username_is_not_customized: i8_to_bool(raw_user_record.username_is_not_customized),
email_gravatar_hash: raw_user_record.email_gravatar_hash,
// Premium features
maybe_stripe_customer_id: raw_user_record.maybe_stripe_customer_id,
maybe_loyalty_program_key: raw_user_record.maybe_loyalty_program_key,
@@ -256,13 +239,8 @@ struct SessionUserRawDbRecord {
display_name: String,
email_address: String,
email_gravatar_hash: String,
email_confirmed: i8,
email_confirmed_by_google: i8,
email_is_synthetic: i8,
is_without_password: i8,
username_is_not_customized: i8,
email_gravatar_hash: String,
maybe_stripe_customer_id: Option<String>,
maybe_loyalty_program_key: Option<String>,
@@ -18,11 +18,6 @@ pub enum UserSignupMethod {
/// "Sign in With Google" SSO
GoogleSignIn,
/// Stripe Checkout flow, where we provision user accounts for users with a
/// synthetic/fake email address and no password. After checkout completes,
/// the user gets a real email and password - or the user can set them.
StripeCheckout,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -36,7 +31,6 @@ impl UserSignupMethod {
match self {
Self::EmailPassword => "email_password",
Self::GoogleSignIn=> "google_sign_in",
Self::StripeCheckout => "stripe_checkout",
}
}
@@ -44,7 +38,6 @@ impl UserSignupMethod {
match value {
"email_password" => Ok(Self::EmailPassword),
"google_sign_in" => Ok(Self::GoogleSignIn),
"stripe_checkout" => Ok(Self::StripeCheckout),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -55,7 +48,6 @@ impl UserSignupMethod {
BTreeSet::from([
Self::EmailPassword,
Self::GoogleSignIn,
Self::StripeCheckout,
])
}
}
@@ -72,7 +64,6 @@ mod tests {
fn test_serialization() {
assert_serialization(UserSignupMethod::EmailPassword, "email_password");
assert_serialization(UserSignupMethod::GoogleSignIn, "google_sign_in");
assert_serialization(UserSignupMethod::StripeCheckout, "stripe_checkout");
}
}
@@ -83,14 +74,12 @@ mod tests {
fn to_str() {
assert_eq!(UserSignupMethod::EmailPassword.to_str(), "email_password");
assert_eq!(UserSignupMethod::GoogleSignIn.to_str(), "google_sign_in");
assert_eq!(UserSignupMethod::StripeCheckout.to_str(), "stripe_checkout");
}
#[test]
fn from_str() {
assert_eq!(UserSignupMethod::from_str("email_password").unwrap(), UserSignupMethod::EmailPassword);
assert_eq!(UserSignupMethod::from_str("google_sign_in").unwrap(), UserSignupMethod::GoogleSignIn);
assert_eq!(UserSignupMethod::from_str("stripe_checkout").unwrap(), UserSignupMethod::StripeCheckout);
assert!(UserSignupMethod::from_str("foo").is_err());
}
}
@@ -101,10 +90,9 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = UserSignupMethod::all_variants();
assert_eq!(variants.len(), 3);
assert_eq!(variants.len(), 2);
assert_eq!(variants.pop_first(), Some(UserSignupMethod::EmailPassword));
assert_eq!(variants.pop_first(), Some(UserSignupMethod::GoogleSignIn));
assert_eq!(variants.pop_first(), Some(UserSignupMethod::StripeCheckout));
assert_eq!(variants.pop_first(), None);
}
}
@@ -13,30 +13,9 @@ use utoipa::ToSchema;
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum UserSignupSource {
#[deprecated(since = "2026-01-30", note = "Use other Artcraft* enum variants instead")]
#[serde(rename = "artcraft")]
ArtCraft,
/// ArtCraft Tauri App Onboard Flow
#[serde(rename = "artcraft_app")]
ArtCraftApp,
/// artcraft.ai normal onboard flow
#[serde(rename = "artcraft_ai_web")]
ArtCraftAiWeb,
/// artcraft.ai stripe checkout flow
#[serde(rename = "artcraft_ai_s")]
ArtCraftAiStripe,
/// getartcraft.com normal onboard flow
#[serde(rename = "artcraft_get_web")]
ArtCraftGetWeb,
/// getartcraft.com stripe checkout flow
#[serde(rename = "artcraft_get_s")]
ArtCraftGetStripe,
#[serde(rename = "fakeyou")]
FakeYou,
@@ -54,11 +33,6 @@ impl UserSignupSource {
pub fn to_str(&self) -> &'static str {
match self {
Self::ArtCraft => "artcraft",
Self::ArtCraftApp => "artcraft_app",
Self::ArtCraftAiWeb => "artcraft_ai_web",
Self::ArtCraftAiStripe => "artcraft_ai_s",
Self::ArtCraftGetWeb => "artcraft_get_web",
Self::ArtCraftGetStripe => "artcraft_get_s",
Self::FakeYou => "fakeyou",
Self::Storyteller => "storyteller",
}
@@ -67,11 +41,6 @@ impl UserSignupSource {
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"artcraft" => Ok(Self::ArtCraft),
"artcraft_app" => Ok(Self::ArtCraftApp),
"artcraft_ai_web" => Ok(Self::ArtCraftAiWeb),
"artcraft_ai_s" => Ok(Self::ArtCraftAiStripe),
"artcraft_get_web" => Ok(Self::ArtCraftGetWeb),
"artcraft_get_s" => Ok(Self::ArtCraftGetStripe),
"fakeyou" => Ok(Self::FakeYou),
"storyteller" => Ok(Self::Storyteller),
_ => Err(format!("invalid value: {:?}", value)),
@@ -83,11 +52,6 @@ impl UserSignupSource {
// NB: BTreeSet::from() isn't const, but not worth using LazyStatic, etc.
BTreeSet::from([
Self::ArtCraft,
Self::ArtCraftApp,
Self::ArtCraftAiWeb,
Self::ArtCraftAiStripe,
Self::ArtCraftGetWeb,
Self::ArtCraftGetStripe,
Self::FakeYou,
Self::Storyteller,
])
@@ -105,11 +69,6 @@ mod tests {
#[test]
fn test_serialization() {
assert_serialization(UserSignupSource::ArtCraft, "artcraft");
assert_serialization(UserSignupSource::ArtCraftApp, "artcraft_app");
assert_serialization(UserSignupSource::ArtCraftAiWeb, "artcraft_ai_web");
assert_serialization(UserSignupSource::ArtCraftAiStripe, "artcraft_ai_s");
assert_serialization(UserSignupSource::ArtCraftGetWeb, "artcraft_get_web");
assert_serialization(UserSignupSource::ArtCraftGetStripe, "artcraft_get_s");
assert_serialization(UserSignupSource::FakeYou, "fakeyou");
assert_serialization(UserSignupSource::Storyteller, "storyteller");
}
@@ -121,10 +80,6 @@ mod tests {
#[test]
fn to_str() {
assert_eq!(UserSignupSource::ArtCraft.to_str(), "artcraft");
assert_eq!(UserSignupSource::ArtCraftApp.to_str(), "artcraft_app");
assert_eq!(UserSignupSource::ArtCraftAiWeb.to_str(), "artcraft_ai_web");
assert_eq!(UserSignupSource::ArtCraftAiStripe.to_str(), "artcraft_ai_s");
assert_eq!(UserSignupSource::ArtCraftGetWeb.to_str(), "artcraft_get_web");
assert_eq!(UserSignupSource::FakeYou.to_str(), "fakeyou");
assert_eq!(UserSignupSource::Storyteller.to_str(), "storyteller");
}
@@ -132,11 +87,6 @@ mod tests {
#[test]
fn from_str() {
assert_eq!(UserSignupSource::from_str("artcraft").unwrap(), UserSignupSource::ArtCraft);
assert_eq!(UserSignupSource::from_str("artcraft_app").unwrap(), UserSignupSource::ArtCraftApp);
assert_eq!(UserSignupSource::from_str("artcraft_ai_web").unwrap(), UserSignupSource::ArtCraftAiWeb);
assert_eq!(UserSignupSource::from_str("artcraft_ai_s").unwrap(), UserSignupSource::ArtCraftAiStripe);
assert_eq!(UserSignupSource::from_str("artcraft_get_web").unwrap(), UserSignupSource::ArtCraftGetWeb);
assert_eq!(UserSignupSource::from_str("artcraft_get_s").unwrap(), UserSignupSource::ArtCraftGetStripe);
assert_eq!(UserSignupSource::from_str("fakeyou").unwrap(), UserSignupSource::FakeYou);
assert_eq!(UserSignupSource::from_str("storyteller").unwrap(), UserSignupSource::Storyteller);
assert!(UserSignupSource::from_str("foo").is_err());
@@ -149,13 +99,8 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = UserSignupSource::all_variants();
assert_eq!(variants.len(), 8);
assert_eq!(variants.len(), 3);
assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraft));
assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftApp));
assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftAiWeb));
assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftAiStripe));
assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftGetWeb));
assert_eq!(variants.pop_first(), Some(UserSignupSource::ArtCraftGetStripe));
assert_eq!(variants.pop_first(), Some(UserSignupSource::FakeYou));
assert_eq!(variants.pop_first(), Some(UserSignupSource::Storyteller));
assert_eq!(variants.pop_first(), None);
@@ -21,12 +21,10 @@ normal = ["workspace-build-acceleration"]
workspace-build-acceleration.workspace = true
# Internal
actix_artcraft.workspace = true
artcraft_api_defs.workspace = true
component_traits.workspace = true
enums.workspace = true
errors.workspace = true
http_headers.workspace = true
http_server_common = { path = "../../../lib/deprecated/http_server_common" }
mysql_queries = { path = "../../../schema/database/mysql_queries" }
reusable_types = { path = "../../../schema/public/reusable_types" }
@@ -44,7 +42,6 @@ serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
users.workspace = true
# Money
async-stripe = { version = "1.0.0-alpha.2", default-features = false, features = ["rustls-tls-native"] }
@@ -142,8 +142,4 @@ pub struct SubscriptionPaidEvent {
/// Token to track in the wallet_ledger_events
pub ledger_event_ref: Option<String>,
/// For eagerly created user records (in the new checkout flow), we need to update the user's email.
/// We started creating user accounts without a username, email, and password - so we need to collect it here.
pub customer_email: Option<String>,
}
@@ -97,5 +97,7 @@ pub async fn mark_subscription_as_paid(
transaction
).await?;
// TODO: Insert wallet ledger entry.
Ok(())
}
@@ -44,8 +44,6 @@ pub async fn transactionally_fulfill_artcraft_billing_action(
ArtcraftBillingAction::SubscriptionPaid(paid_details) => {
info!("Completing subscription paid event for user: {:?}", paid_details.owner_user_token);
mark_subscription_as_paid(paid_details, transaction).await?;
//paid_details.customer_email
//paid_details.owner_user_token
}
// ArtcraftBillingAction::CustomerCreated(customer_link) => {
// info!("Linking user to newly created stripe customer: {:?}", customer_link.user_token);

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