Compare commits

..
Author SHA1 Message Date
Brandon Thomas bf2a3ad56f update pricing table 2026-01-27 05:34:18 -05:00
bflatastic 7b2b71eaf0 pricing stuff updates 2026-01-27 05:24:32 -05:00
bflatastic 4883185ad8 updates 2026-01-27 05:24:31 -05:00
bflatastic a3b9cd71ad sign up download flow 2026-01-27 05:24:27 -05:00
416 changed files with 2647 additions and 55006 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
+90 -926
View File
File diff suppressed because it is too large Load Diff
+1 -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",
@@ -90,7 +89,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 +134,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 +185,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 +218,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 +246,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"
@@ -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 -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
-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);
@@ -1,16 +0,0 @@
use stripe_shared::CheckoutSession;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
pub (super) struct CreationPayload {
pub checkout_session: CheckoutSession,
pub maybe_new_user_metadata: Option<UserMetadata>
}
pub (super) struct UserMetadata {
pub user_token: UserToken,
pub session_token: UserSessionToken,
pub username: String,
pub display_name: String,
pub email_address: String,
}
@@ -1,4 +0,0 @@
pub mod stripe_artcraft_create_subscription_checkout_with_user_signup_handler;
pub (super) mod user_exists_case;
pub (super) mod user_creation_case;
pub (super) mod creation_payload;
@@ -1,195 +0,0 @@
use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN};
use crate::configs::subscriptions::get_artcraft_subscription_by_slug_and_env::get_artcraft_subscription_by_slug_and_env;
use crate::endpoints::checkout_with_user_signup::creation_payload::{CreationPayload, UserMetadata};
use crate::endpoints::checkout_with_user_signup::user_creation_case::user_creation_case;
use crate::endpoints::checkout_with_user_signup::user_exists_case::user_exists_case;
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager;
use actix_web::web::{Data, Json};
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::stripe_artcraft::create_create_new_user_account_and_subscription_checkout::{PlanBillingCadence, SessionDetails, StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest, StripeArtcraftCreateSubscriptionCheckoutWithUserSignupResponse, UserDetails};
use component_traits::traits::internal_user_lookup::InternalUserLookup;
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use enums::common::payments_namespace::PaymentsNamespace;
use http_headers::values::content_type::CONTENT_TYPE_APPLICATION_JSON;
use log::{error, info, warn};
use mysql_queries::queries::users::user_stripe_customer_links::find_user_stripe_customer_link::find_user_stripe_customer_link_using_connection;
use mysql_queries::queries::users::user_subscriptions::find_possibly_inactive_first_subscription_for_owner_user::find_possibly_inactive_first_subscription_for_owner_user_using_connection;
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use reusable_types::server_environment::ServerEnvironment;
use sqlx::MySqlPool;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionSavedPaymentMethodOptions, CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters, CreateCheckoutSessionSavedPaymentMethodOptionsPaymentMethodSave, CreateCheckoutSessionSubscriptionData};
use stripe_checkout::CheckoutSessionMode;
use stripe_core::CustomerId;
use stripe_shared::{CheckoutSession, PriceId};
use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge;
// /// Create a new user account and Stripe Checkout session and return the redirect URL in Json.
// #[utoipa::path(
// get,
// tag = "Stripe (Artcraft)",
// path = "/v1/stripe_artcraft/user_signup_subscription_checkout",
// params(
// ("request" = CreateCheckoutSessionRequest, description = "Payload for Request"),
// ),
// responses(
// (status = 200, description = "Success Delete", body = CreateCheckoutSessionSuccessResponse),
// ),
// )]
/// Create a Stripe Checkout session *with* user signup and return the redirect URL in Json.
/// If the user is already logged in, we just attach it to that record instead.
pub async fn stripe_artcraft_create_subscription_checkout_with_user_signup_handler(
http_request: HttpRequest,
request: Json<StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest>,
stripe_config: Data<ArtcraftStripeConfigWithClient>,
server_environment: Data<ServerEnvironment>,
session_cookie_manager: web::Data<HttpUserSessionManager>,
internal_user_lookup: Data<dyn InternalUserLookup>,
internal_session_cache_purge: Data<dyn InternalSessionCachePurge>,
mysql_pool: Data<MySqlPool>,
) -> Result<HttpResponse, CommonWebError>
{
let slug = match request.plan {
None => return Err(CommonWebError::BadInputWithSimpleMessage("no plan supplied".to_string())),
Some(slug) => slug,
};
let cadence = match request.cadence {
None => return Err(CommonWebError::BadInputWithSimpleMessage("no cadence supplied".to_string())),
Some(cadence) => cadence,
};
let plan = get_artcraft_subscription_by_slug_and_env(slug, **server_environment);
let mut mysql_connection = mysql_pool
.acquire()
.await
.map_err(|err| {
error!("Could not acquire mysql connection: {:?}", err);
CommonWebError::ServerError
})?;
let price_id = match cadence {
PlanBillingCadence::Monthly => plan.monthly_price_id.clone(),
PlanBillingCadence::Yearly => plan.yearly_price_id.clone(),
};
let price_id = PriceId::from_str(&price_id)
.map_err(|err| {
error!("Error parsing price id: {:?}", err);
CommonWebError::ServerError
})?;
let maybe_user_metadata = internal_user_lookup
.lookup_user_from_http_request_and_mysql_connection(&http_request, &mut mysql_connection)
.await
.map_err(|err| {
error!("Error looking up user: {:?}", err);
CommonWebError::ServerError // NB: This was probably *our* fault.
})?;
let creation_payload= match maybe_user_metadata {
None => {
info!("Creating new user, then creating checkout session...");
user_creation_case(
&http_request,
&price_id,
&mut mysql_connection,
&stripe_config,
).await?
},
Some(user_metadata) => {
info!("Creating checkout session for user: {:?}", user_metadata.user_token_typed);
user_exists_case(
&price_id,
&user_metadata,
&mut mysql_connection,
&stripe_config,
).await?
},
};
// Best effort to delete Redis session cache
internal_session_cache_purge.best_effort_purge_session_cache(&http_request);
match creation_payload.maybe_new_user_metadata {
None => {
info!("delevering response for existing user...");
create_http_response_existing_user(creation_payload.checkout_session)
}
Some(user_metadata) => {
info!("delevering response for new user...");
create_http_response_new_user(&session_cookie_manager, creation_payload.checkout_session, user_metadata)
}
}
}
pub fn create_http_response_existing_user(
checkout_session: CheckoutSession,
) -> Result<HttpResponse, CommonWebError> {
let url = checkout_session.url.ok_or(CommonWebError::ServerError)?;
let response = StripeArtcraftCreateSubscriptionCheckoutWithUserSignupResponse {
success: true,
stripe_checkout_redirect_url: url,
generated_user: None,
session: None,
};
let body = serde_json::to_string(&response)
.map_err(|_e| CommonWebError::ServerError)?;
Ok(HttpResponse::Ok()
.content_type(CONTENT_TYPE_APPLICATION_JSON)
.body(body))
}
pub fn create_http_response_new_user(
session_cookie_manager: &HttpUserSessionManager,
checkout_session: CheckoutSession,
user_metadata: UserMetadata,
) -> Result<HttpResponse, CommonWebError> {
let session_cookie = match session_cookie_manager.create_cookie(&user_metadata.session_token, &user_metadata.user_token) {
Ok(cookie) => cookie,
Err(err) => {
error!("Error creating session cookie: {:?}", err);
return Err(CommonWebError::ServerError)
},
};
let signed_session = match session_cookie_manager.encode_session_payload(&user_metadata.session_token, &user_metadata.user_token) {
Ok(payload) => payload,
Err(err) => {
error!("Error encoding session payload: {:?}", err);
return Err(CommonWebError::ServerError)
},
};
let url = checkout_session.url.ok_or(CommonWebError::ServerError)?;
let response = StripeArtcraftCreateSubscriptionCheckoutWithUserSignupResponse {
success: true,
stripe_checkout_redirect_url: url,
generated_user: Some(UserDetails {
username: user_metadata.username,
display_name: user_metadata.display_name,
}),
session: Some(SessionDetails {
signed_session,
}),
};
let body = serde_json::to_string(&response)
.map_err(|_e| CommonWebError::ServerError)?;
Ok(HttpResponse::Ok()
.cookie(session_cookie)
.content_type(CONTENT_TYPE_APPLICATION_JSON)
.body(body))
}
@@ -1,165 +0,0 @@
use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN};
use crate::endpoints::checkout_with_user_signup::creation_payload::{CreationPayload, UserMetadata};
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
use crate::utils::create_checkout::create_subscription_checkout_session::{create_subscription_checkout_session, CreateSubscriptionCheckoutSessionArgs};
use actix_artcraft::requests::get_request_signup_source_enum::get_request_signup_source_enum;
use actix_web::HttpRequest;
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
use enums::by_table::users::user_signup_source::UserSignupSource;
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::users::user::create::create_account_error::CreateAccountError;
use mysql_queries::queries::users::user::create::create_account_from_google_sso::{create_account_from_google_sso, CreateAccountFromGoogleSsoArgs};
use mysql_queries::queries::users::user::create::create_account_from_stripe_checkout::{create_account_from_stripe_checkout, CreateAccountFromStripeCheckoutArgs};
use mysql_queries::queries::users::user_sessions::create_user_session_with_transactor::create_user_session_with_transactor;
use mysql_queries::utils::transactor::Transactor;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
use std::collections::HashMap;
use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionSavedPaymentMethodOptions, CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters, CreateCheckoutSessionSavedPaymentMethodOptionsPaymentMethodSave, CreateCheckoutSessionSubscriptionData};
use stripe_shared::{CheckoutSession, CheckoutSessionMode, PriceId};
use users::email::email_to_gravatar_hash::email_to_gravatar_hash;
use users::email::generate_random_synthetic_email::generate_random_synthetic_email;
use users::username::generate_random_username::generate_random_username;
pub (super) async fn user_creation_case(
http_request: &HttpRequest,
price_id: &PriceId,
mysql_connection: &mut PoolConnection<MySql>,
stripe_config: &ArtcraftStripeConfigWithClient,
) -> Result<CreationPayload, CommonWebError> {
let mut transaction = mysql_connection.begin()
.await
.map_err(|e| {
warn!("Could not begin transaction: {:?}", e);
CommonWebError::ServerError
})?;
let ip_address = get_request_ip(&http_request);
let maybe_source = get_request_signup_source_enum(&http_request);
// NB: Adjust the signup source to the Stripe onboarding equivalent.
let maybe_source = match maybe_source {
Some(UserSignupSource::ArtCraftAiWeb) => Some(UserSignupSource::ArtCraftAiStripe),
Some(UserSignupSource::ArtCraftGetWeb) => Some(UserSignupSource::ArtCraftGetStripe),
_ => maybe_source,
};
// NB: Not sure if these lock important functionality, so we're keeping this
let user_feature_flags = vec![
UserFeatureFlag::Studio,
UserFeatureFlag::VideoStyleTransfer,
].into_iter()
.map(|flag| flag.to_string())
.collect::<Vec<String>>()
.join(",");
let mut final_user_token = None;
let mut final_username = None;
let mut final_display_name = None;
let mut final_email_address = None;
for _ in 0..3 {
// NB: We try a few times to make sure we don't hit an email/username collision.
let user_email_address = generate_random_synthetic_email();
let user_email_gravatar_hash = email_to_gravatar_hash(&user_email_address);
let display_name = generate_random_username();
let username = display_name.trim().to_lowercase();
info!("generated username: {} ; generated email: {}", username, user_email_address);
let result = create_account_from_stripe_checkout(
CreateAccountFromStripeCheckoutArgs {
username: &username,
display_name: &display_name,
email_address: &user_email_address,
email_gravatar_hash: &user_email_gravatar_hash,
maybe_feature_flags: Some(&user_feature_flags),
ip_address: &ip_address,
maybe_source,
},
Transactor::for_transaction(&mut transaction),
).await;
match result {
Ok(token) => {
final_user_token = Some(token);
final_username = Some(username);
final_display_name = Some(display_name);
final_email_address = Some(user_email_address);
break;
},
Err(CreateAccountError::UsernameIsTaken) => {
continue; // NB: We'll try again with a new username.
},
Err(err) => {
warn!("error creating account from google sso: {:?}", err);
return Err(CommonWebError::ServerError);
},
}
}
let final_user_token = final_user_token.ok_or_else(|| {
error!("Could not generate user token after several attempts");
CommonWebError::ServerError
})?;
let final_username = final_username.ok_or_else(|| {
error!("Could not generate username after several attempts");
CommonWebError::ServerError
})?;
let final_display_name = final_display_name.ok_or_else(|| {
error!("Could not generate display name after several attempts");
CommonWebError::ServerError
})?;
let final_email_address = final_email_address.ok_or_else(|| {
error!("Could not generate email address after several attempts");
CommonWebError::ServerError
})?;
transaction.commit()
.await
.map_err(|e| {
warn!("Could not commit transaction: {:?}", e);
CommonWebError::ServerError
})?;
let session_token = create_user_session_with_transactor(
&final_user_token,
&ip_address,
Transactor::for_connection(mysql_connection))
.await
.map_err(|e| {
warn!("error creating user session: {:?}", e);
CommonWebError::ServerError
})?;
let checkout_session = create_subscription_checkout_session(CreateSubscriptionCheckoutSessionArgs {
subscription_price_id: price_id,
maybe_existing_stripe_customer_id: None,
user_token: &final_user_token,
username: None,
user_email: None,
stripe_client: &stripe_config.client,
success_url: &stripe_config.checkout_success_url,
cancel_url: &stripe_config.checkout_cancel_url,
}).await?;
Ok(CreationPayload {
checkout_session,
maybe_new_user_metadata: Some(UserMetadata {
user_token: final_user_token,
session_token,
username: final_username,
display_name: final_display_name,
email_address: final_email_address,
})
})
}
@@ -1,89 +0,0 @@
use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN};
use crate::endpoints::checkout_with_user_signup::creation_payload::CreationPayload;
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
use crate::utils::create_checkout::create_subscription_checkout_session::{create_subscription_checkout_session, CreateSubscriptionCheckoutSessionArgs};
use actix_web::web::Data;
use component_traits::traits::internal_user_lookup::UserMetadata;
use enums::common::payments_namespace::PaymentsNamespace;
use log::{error, info, warn};
use mysql_queries::queries::users::user_stripe_customer_links::find_user_stripe_customer_link::find_user_stripe_customer_link_using_connection;
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use std::collections::HashMap;
use std::str::FromStr;
use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionSavedPaymentMethodOptions, CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters, CreateCheckoutSessionSavedPaymentMethodOptionsPaymentMethodSave, CreateCheckoutSessionSubscriptionData};
use stripe_shared::{CheckoutSession, CheckoutSessionMode, CustomerId, PriceId};
pub (super) async fn user_exists_case(
price_id: &PriceId,
user_metadata: &UserMetadata,
mysql_connection: &mut PoolConnection<MySql>,
stripe_config: &ArtcraftStripeConfigWithClient,
) -> Result<CreationPayload, CommonWebError> {
// NB: Currently the stripe customer id field in the `users` table is only for FakeYou subscriptions,
// so we need to look up any existing Artcraft subscription separately. This is needed to pre-fill
// the Stripe billing form.
let maybe_active_subscription = find_subscription_for_owner_user_using_connection(
&user_metadata.user_token_typed,
PaymentsNamespace::Artcraft,
mysql_connection
).await.map_err(|err| {
error!("Error looking up user's ({}) existing subscription: {:?}", &user_metadata.user_token_typed, err);
CommonWebError::ServerError // NB: This was probably *our* fault.
})?;
if maybe_active_subscription.is_some() {
return Err(CommonWebError::BadInputWithSimpleMessage(
"user already has an active subscription plan; use the portal to update the plan"
.to_string()))
}
// NB: This works, but it feels really weird to have the customer's old email shown in a
// completely immutable state in the stripe checkout form.
//
// let maybe_inactive_subscription = find_possibly_inactive_first_subscription_for_owner_user_using_connection(
// &user_metadata.user_token_typed,
// PaymentsNamespace::Artcraft,
// &mut mysql_connection
// ).await.map_err(|err| {
// error!("Error looking up user's ({}) possibly existing inactive subscription: {:?}",
// &user_metadata.user_token_typed, err);
// CommonWebError::ServerError // NB: This was probably *our* fault.
// })?;
// let maybe_existing_inactive_stripe_customer_id = maybe_inactive_subscription.as_ref()
// .map(|sub| sub.stripe_customer_id.as_str());
let mut maybe_existing_stripe_customer_id = None;
let result = find_user_stripe_customer_link_using_connection(
&user_metadata.user_token_typed,
PaymentsNamespace::Artcraft,
mysql_connection
).await;
// NB: Fail silently.
if let Ok(Some(link)) = result {
maybe_existing_stripe_customer_id = CustomerId::from_str(&link.stripe_customer_id).ok();
} else if let Err(err) = result {
warn!("Error looking up user's ({}) existing stripe customer link: {:?}", &user_metadata.user_token_typed, err);
}
let checkout_session = create_subscription_checkout_session(CreateSubscriptionCheckoutSessionArgs {
subscription_price_id: price_id,
maybe_existing_stripe_customer_id: maybe_existing_stripe_customer_id.as_ref(),
user_token: &user_metadata.user_token_typed,
username: user_metadata.username.as_deref(),
user_email: user_metadata.user_email.as_deref(),
stripe_client: &stripe_config.client,
success_url: &stripe_config.checkout_success_url,
cancel_url: &stripe_config.checkout_cancel_url,
}).await?;
Ok(CreationPayload {
checkout_session,
maybe_new_user_metadata: None,
})
}
@@ -1,7 +1,6 @@
use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN};
use crate::configs::subscriptions::get_artcraft_subscription_by_slug_and_env::get_artcraft_subscription_by_slug_and_env;
use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::stripe_requests::stripe_lookup_subscription_from_subscription_id::stripe_lookup_subscription_from_subscription_id;
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
use actix_web::web::{Data, Json};
@@ -25,6 +24,7 @@ use stripe_checkout::CheckoutSessionMode;
use stripe_core::CustomerId;
use tokens::tokens::users::UserToken;
use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge;
use crate::requests::lookup_subscription_from_subscription_id::lookup_subscription_from_subscription_id;
pub async fn stripe_artcraft_customer_portal_switch_plan_handler(
http_request: HttpRequest,
@@ -130,7 +130,7 @@ async fn update_confirm(
let existing_subscription_id = user_subscription.stripe_subscription_id.clone();
let existing_product_id = user_subscription.stripe_product_id.clone();
let existing_subscription = stripe_lookup_subscription_from_subscription_id(
let existing_subscription = lookup_subscription_from_subscription_id(
&existing_subscription_id,
&stripe_config.client
).await.map_err(|err| {
@@ -1,7 +1,7 @@
use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN};
use crate::configs::subscriptions::get_artcraft_subscription_by_slug_and_env::get_artcraft_subscription_by_slug_and_env;
use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::stripe_requests::stripe_lookup_subscription_from_subscription_id::stripe_lookup_subscription_from_subscription_id;
use crate::requests::lookup_subscription_from_subscription_id::lookup_subscription_from_subscription_id;
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
use actix_web::web::{Data, Json};
@@ -1,4 +1,3 @@
pub mod checkout;
pub mod checkout_with_user_signup;
pub mod customer_portal;
pub mod webhook;
@@ -1,7 +1,6 @@
use actix_web::web::{Bytes, Data, Json};
use actix_web::{web, HttpRequest, HttpResponse};
use crate::billing_action_fulfillment::artcraft_billing_action::ArtcraftBillingAction;
use crate::billing_action_fulfillment::transactionally_fulfill_artcraft_billing_action::transactionally_fulfill_artcraft_billing_action;
use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent;
use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
@@ -14,14 +13,13 @@ use http_server_common::request::get_request_header_optional::get_request_header
use log::{error, info, warn};
use mysql_queries::queries::billing::stripe::get_stripe_webhook_event_log_by_id::{get_stripe_webhook_event_log_by_id, get_stripe_webhook_event_log_by_id_with_connection};
use mysql_queries::queries::billing::stripe::insert_stripe_webhook_event_log::InsertStripeWebhookEventLog;
use mysql_queries::queries::users::user::update::maybe_update_email_from_synthetic_value::{maybe_update_email_from_synthetic_value, MaybeUpdateEmailFromSyntheticValueArgs};
use reusable_types::server_environment::ServerEnvironment;
use serde_derive::Serialize;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql, MySqlConnection, MySqlPool, Transaction};
use std::sync::Arc;
use stripe_webhook::{Event, EventObject, Webhook};
use users::email::email_to_gravatar_hash::email_to_gravatar_hash;
/*
For one-off payments (eg. credits packs), we see this typical sequence of events:
@@ -142,7 +140,7 @@ pub async fn stripe_artcraft_webhook_handler(
let result = process_enriched_event(
**server_environment,
&stripe_config,
&enriched_event,
enriched_event,
stripe_event_descriptor.clone(),
stripe_event_id.clone(),
stripe_event_created_at,
@@ -166,11 +164,6 @@ pub async fn stripe_artcraft_webhook_handler(
}
}
if let Err(err) = maybe_update_user_email(&enriched_event, &mut mysql_connection).await {
// NB: Don't fail to process the stripe event if we couldn't update the user's email.
error!("Error updating user email: {:?}", err);
}
Ok(Json(StripeArtcraftWebhookSuccessResponse {
success: true,
}))
@@ -179,7 +172,7 @@ pub async fn stripe_artcraft_webhook_handler(
async fn process_enriched_event(
server_environment: ServerEnvironment,
stripe_config: &ArtcraftStripeConfigWithClient,
artcraft_event: &EnrichedWebhookEvent,
artcraft_event: EnrichedWebhookEvent,
stripe_event_descriptor: StripeEventDescriptor,
stripe_event_id: String,
stripe_event_created_at: NaiveDateTime,
@@ -204,11 +197,11 @@ async fn process_enriched_event(
let query = InsertStripeWebhookEventLog {
stripe_event_id,
stripe_event_type,
maybe_stripe_event_entity_id: artcraft_event.webhook_event_log_summary.maybe_event_entity_id.clone(),
maybe_stripe_customer_id: artcraft_event.webhook_event_log_summary.maybe_stripe_customer_id.clone(),
maybe_stripe_event_entity_id: artcraft_event.webhook_event_log_summary.maybe_event_entity_id,
maybe_stripe_customer_id: artcraft_event.webhook_event_log_summary.maybe_stripe_customer_id,
stripe_event_created_at,
stripe_is_production,
maybe_user_token: artcraft_event.webhook_event_log_summary.maybe_user_token.as_ref().map(|t| t.to_string()),
maybe_user_token: artcraft_event.webhook_event_log_summary.maybe_user_token.map(|t| t.to_string()),
action_was_taken: artcraft_event.webhook_event_log_summary.action_was_taken,
should_ignore_retry: artcraft_event.webhook_event_log_summary.should_ignore_retry,
};
@@ -223,45 +216,3 @@ async fn process_enriched_event(
Ok(())
}
/// For users eagerly created in the new "Stripe user creation flow", we didn't ask for their email, password, or username.
/// Stripe will have these details for us. Update if the user hasn't already customized their email yet.
async fn maybe_update_user_email(
artcraft_event: &EnrichedWebhookEvent,
mysql_connection: &mut PoolConnection<MySql>
) -> Result<(), StripeArtcraftWebhookError> {
let action = match artcraft_event.maybe_billing_action.as_ref() {
None => return Ok(()),
Some(action) => action,
};
let user_token;
let user_email;
match action {
ArtcraftBillingAction::SubscriptionPaid(paid) => {
user_token = paid.owner_user_token.clone();
match paid.customer_email.as_ref() {
Some(email) => user_email = email.clone(),
None => return Ok(()),
}
}
_ => return Ok(()),
}
let user_email = user_email.trim().to_lowercase();
let gravatar_hash = email_to_gravatar_hash(&user_email);
info!("Attempting to update user email from synthetic value for user {} to: {}", &user_token, &user_email);
maybe_update_email_from_synthetic_value(MaybeUpdateEmailFromSyntheticValueArgs {
user_token: &user_token,
email_address: &user_email,
email_gravatar_hash: &gravatar_hash,
mysql_connection,
}).await?;
info!("Updated user email from synthetic value for user {} to: {}", &user_token, &user_email);
Ok(())
}
@@ -4,16 +4,15 @@ use crate::configs::stripe_artcraft_generic_product_info::StripeArtcraftGenericP
use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent;
use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary;
use crate::stripe_requests::stripe_lookup_subscription_from_subscription_id::stripe_lookup_subscription_from_subscription_id;
use crate::requests::lookup_subscription_from_subscription_id::lookup_subscription_from_subscription_id;
use crate::utils::expand_ids::expand_customer_id::expand_customer_id;
use crate::utils::expand_ids::expand_subscription_id::expand_subscription_id;
use crate::utils::metadata::get_metadata_user_token::get_metadata_user_token;
use crate::utils::stripe_event_descriptor::StripeEventDescriptor;
use log::{error, info, warn};
use reusable_types::server_environment::ServerEnvironment;
use stripe::Client;
use stripe_shared::{Invoice, InvoiceBillingReason, InvoiceStatus};
use stripe_types::Expandable;
use crate::utils::stripe_event_descriptor::StripeEventDescriptor;
// Handle event type: 'invoice.paid'
//
@@ -102,7 +101,7 @@ pub async fn invoice_paid_extractor(
info!("Calling Stripe to look up subscription info...");
let subscription = stripe_lookup_subscription_from_subscription_id(
let subscription = lookup_subscription_from_subscription_id(
&subscription_id, stripe_client).await?;
// TODO: Multiple ways to get this; better ways to get this
@@ -156,7 +155,6 @@ pub async fn invoice_paid_extractor(
maybe_cancel_at: subscription.maybe_cancel_at,
maybe_canceled_at: subscription.maybe_canceled_at,
ledger_event_ref: Some(ledger_event_ref),
customer_email: invoice.customer_email.clone(),
})),
webhook_event_log_summary: event_log_summary,
})
@@ -5,7 +5,7 @@ use crate::configs::subscriptions::stripe_artcraft_subscription_info::StripeArtc
use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent;
use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary;
use crate::stripe_requests::stripe_lookup_purchase_from_payment_intent_success::stripe_lookup_purchase_from_payment_intent_success;
use crate::requests::lookup_purchase_from_payment_intent_success::lookup_purchase_from_payment_intent_success;
use crate::utils::expand_ids::expand_customer_id::expand_customer_id;
use crate::utils::metadata::get_metadata_user_token::get_metadata_user_token;
use crate::utils::metadata::get_metadata_wallet_token::get_metadata_wallet_token;
@@ -72,7 +72,7 @@ pub async fn payment_intent_succeeded_extractor(
info!("Payment intent succeeded. Looking up payment...");
let purchase = stripe_lookup_purchase_from_payment_intent_success(&payment_intent_id, stripe_client)
let purchase = lookup_purchase_from_payment_intent_success(&payment_intent_id, stripe_client)
.await
.map_err(|err| {
error!("Error looking up purchase from payment intent {}: {:?}", &payment_intent_id, err);
@@ -1,5 +1,5 @@
pub mod billing_action_fulfillment;
pub mod configs;
pub mod endpoints;
pub mod stripe_requests;
pub mod requests;
pub mod utils;
@@ -17,7 +17,7 @@ pub struct PurchaseSummary {
}
// This assumes the payment came from a checkout session.
pub async fn stripe_lookup_purchase_from_payment_intent_success(
pub async fn lookup_purchase_from_payment_intent_success(
payment_intent_id: &str,
stripe_client: &Client
) -> anyhow::Result<PurchaseSummary> {
@@ -1,5 +1,5 @@
use crate::configs::stripe_client_retry_strategy::STRIPE_CLIENT_RETRY_STRATEGY;
use crate::stripe_requests::stripe_lookup_purchase_from_payment_intent_success::PurchaseSummary;
use crate::requests::lookup_purchase_from_payment_intent_success::PurchaseSummary;
use crate::utils::enum_conversion::recurring_interval_to_reusable_type::recurring_interval_to_reusable_type;
use crate::utils::enum_conversion::subscription_status_to_reusable_type::subscription_status_to_reusable_type;
use crate::utils::expand_ids::expand_customer_id::expand_customer_id;
@@ -63,7 +63,7 @@ pub struct SubscriptionAndProduct {
pub maybe_canceled_at: Option<NaiveDateTime>,
}
pub async fn stripe_lookup_subscription_from_subscription_id(
pub async fn lookup_subscription_from_subscription_id(
subscription_id: &str,
stripe_client: &Client
) -> anyhow::Result<SubscriptionAndProduct> {
@@ -0,0 +1,2 @@
pub mod lookup_purchase_from_payment_intent_success;
pub mod lookup_subscription_from_subscription_id;
@@ -1,2 +0,0 @@
pub mod stripe_lookup_purchase_from_payment_intent_success;
pub mod stripe_lookup_subscription_from_subscription_id;
@@ -1,123 +0,0 @@
use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN};
use crate::utils::common_web_error::CommonWebError;
use log::{error, info};
use std::collections::HashMap;
use stripe::Client;
use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionSavedPaymentMethodOptions, CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters, CreateCheckoutSessionSavedPaymentMethodOptionsPaymentMethodSave, CreateCheckoutSessionSubscriptionData};
use stripe_shared::{CheckoutSession, CheckoutSessionMode, CustomerId, PriceId};
use tokens::tokens::users::UserToken;
pub struct CreateSubscriptionCheckoutSessionArgs<'a> {
/// Subscription is required
pub subscription_price_id: &'a PriceId,
/// Optional: Existing user's Stripe customer id, if it exists.
/// You MUST look this up.
pub maybe_existing_stripe_customer_id: Option<&'a CustomerId>,
/// User is required
pub user_token: &'a UserToken,
/// Username is optional in the case we eagerly created a user account for the user.
pub username: Option<&'a str>,
/// User email is optional in the case we eagerly created a user account for the user.
pub user_email: Option<&'a str>,
pub stripe_client: &'a Client,
pub success_url: &'a str,
pub cancel_url: &'a str,
}
pub async fn create_subscription_checkout_session(args: CreateSubscriptionCheckoutSessionArgs<'_>) -> Result<CheckoutSession, CommonWebError> {
let checkout_session = {
// `client_reference_id`
// Stripe Docs:
// A unique string to reference the Checkout Session.
// This can be a customer ID, a cart ID, or similar, and can be used to reconcile the session
// with your internal systems.
//
// Our Notes:
// This gets reported back in the Checkout Session (and related webhooks) as
// `client_reference_id`. Passing the same ID on multiple checkouts does not unify or
// cross-correlate customers and only seems to be metadata for the checkout session itself.
// This is probably only useful for tracking checkout session engagement.
//params.client_reference_id = Some("SOME_INTERNAL_ID");
// `customer_email`
// Stripe Docs:
// If provided, this value will be used when the Customer object is created. If not provided,
// customers will be asked to enter their email address. Use this parameter to prefill
// customer data if you already have an email on file. To access information about the
// customer once a session is complete, use the customer field.
//
// Our Notes:
// This does not look up previous customers with the same email and will not unify or
// cross-correlate customers. By default the field will be un-editable in the checkout flow
// if this is specified.
//params.customer_email = Some("email@example.com");
let mut metadata = HashMap::new();
metadata.insert(STRIPE_ARTCRAFT_METADATA_USER_TOKEN.to_string(), args.user_token.to_string());
if let Some(username) = args.username.as_deref() {
metadata.insert(STRIPE_ARTCRAFT_METADATA_USERNAME.to_string(), username.to_string());
}
if let Some(user_email) = args.user_email.as_deref() {
metadata.insert(STRIPE_ARTCRAFT_METADATA_EMAIL.to_string(), user_email.to_string());
}
let mut checkout_builder = CreateCheckoutSession::new()
.success_url(args.success_url)
.cancel_url(args.cancel_url)
.mode(CheckoutSessionMode::Subscription)
.line_items(vec![
CreateCheckoutSessionLineItems {
price: Some(args.subscription_price_id.to_string()),
quantity: Some(1),
..Default::default()
}
])
.saved_payment_method_options(CreateCheckoutSessionSavedPaymentMethodOptions {
allow_redisplay_filters: Some(vec![
CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters::Always,
CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters::Limited,
CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters::Unspecified,
]),
// The user can choose to tick a checkbox that saves their card for redisplay later.
payment_method_save: Some(CreateCheckoutSessionSavedPaymentMethodOptionsPaymentMethodSave::Enabled),
})
.allow_promotion_codes(true) // Allow promo codes / coupons
.automatic_tax(CreateCheckoutSessionAutomaticTax {
enabled: true, // This will ask for the customer's location
liability: None,
})
.metadata(metadata.clone())
.subscription_data(CreateCheckoutSessionSubscriptionData {
metadata: Some(metadata),
..Default::default()
})
;
if let Some(customer_id) = args.maybe_existing_stripe_customer_id {
info!("Adding existing stripe customer id to checkout session: {}", customer_id.as_str());
checkout_builder = checkout_builder.customer(customer_id);
}
let checkout_session = checkout_builder
.send(args.stripe_client)
.await
.map_err(|err| {
error!("Stripe Error: {:?}", err);
CommonWebError::ServerError
})?;
checkout_session
};
Ok(checkout_session)
}
@@ -1 +0,0 @@
pub mod create_subscription_checkout_session;

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