diff --git a/.sqlx/query-03761e50f94f2805f1c5c64f598a23af8d708e514a01c6090570b2e0bfabfe2c.json b/.sqlx/query-03761e50f94f2805f1c5c64f598a23af8d708e514a01c6090570b2e0bfabfe2c.json new file mode 100644 index 0000000000..d5c6289e2e --- /dev/null +++ b/.sqlx/query-03761e50f94f2805f1c5c64f598a23af8d708e514a01c6090570b2e0bfabfe2c.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-05de463f1c408793d64fb011b1732e3c1b3cf485076b3e1a4a50d16683b99e46.json b/.sqlx/query-05de463f1c408793d64fb011b1732e3c1b3cf485076b3e1a4a50d16683b99e46.json deleted file mode 100644 index 3f33d65047..0000000000 --- a/.sqlx/query-05de463f1c408793d64fb011b1732e3c1b3cf485076b3e1a4a50d16683b99e46.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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" -} diff --git a/.sqlx/query-1c32bdb79ed2bc1b8988bbb6ea3f2a1db4ed4552d53cb4f31ff4b08de01691f7.json b/.sqlx/query-3ca99449c071cb0a8d90a735e1a767a94be224a02e0d16e81997d569af8d4cbb.json similarity index 75% rename from .sqlx/query-1c32bdb79ed2bc1b8988bbb6ea3f2a1db4ed4552d53cb4f31ff4b08de01691f7.json rename to .sqlx/query-3ca99449c071cb0a8d90a735e1a767a94be224a02e0d16e81997d569af8d4cbb.json index fb0c810712..933299b632 100644 --- a/.sqlx/query-1c32bdb79ed2bc1b8988bbb6ea3f2a1db4ed4552d53cb4f31ff4b08de01691f7.json +++ b/.sqlx/query-3ca99449c071cb0a8d90a735e1a767a94be224a02e0d16e81997d569af8d4cbb.json @@ -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_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 ", + "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 ", "describe": { "columns": [ { @@ -45,16 +45,6 @@ }, { "ordinal": 4, - "name": "email_confirmed", - "type_info": { - "type": "Tiny", - "flags": "NOT_NULL", - "char_set": 63, - "max_size": 1 - } - }, - { - "ordinal": 5, "name": "email_gravatar_hash", "type_info": { "type": "String", @@ -64,28 +54,38 @@ } }, { - "ordinal": 6, - "name": "maybe_stripe_customer_id", + "ordinal": 5, + "name": "email_confirmed", "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 1020 + "type": "Tiny", + "flags": "NOT_NULL", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 6, + "name": "email_confirmed_by_google", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL", + "char_set": 63, + "max_size": 1 } }, { "ordinal": 7, - "name": "maybe_loyalty_program_key", + "name": "email_is_synthetic", "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 128 + "type": "Tiny", + "flags": "NOT_NULL", + "char_set": 63, + "max_size": 1 } }, { "ordinal": 8, - "name": "disable_gravatar", + "name": "is_without_password", "type_info": { "type": "Tiny", "flags": "NOT_NULL", @@ -95,6 +95,46 @@ }, { "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", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 11, + "name": "maybe_loyalty_program_key", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 12, + "name": "disable_gravatar", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 13, "name": "auto_play_audio_preference", "type_info": { "type": "Tiny", @@ -104,7 +144,7 @@ } }, { - "ordinal": 10, + "ordinal": 14, "name": "auto_play_video_preference", "type_info": { "type": "Tiny", @@ -114,7 +154,7 @@ } }, { - "ordinal": 11, + "ordinal": 15, "name": "preferred_tts_result_visibility: enums::common::visibility::Visibility", "type_info": { "type": "String", @@ -124,7 +164,7 @@ } }, { - "ordinal": 12, + "ordinal": 16, "name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility", "type_info": { "type": "String", @@ -134,7 +174,7 @@ } }, { - "ordinal": 13, + "ordinal": 17, "name": "user_role_slug", "type_info": { "type": "VarString", @@ -144,7 +184,7 @@ } }, { - "ordinal": 14, + "ordinal": 18, "name": "is_banned", "type_info": { "type": "Tiny", @@ -154,7 +194,7 @@ } }, { - "ordinal": 15, + "ordinal": 19, "name": "can_access_studio", "type_info": { "type": "Tiny", @@ -164,7 +204,7 @@ } }, { - "ordinal": 16, + "ordinal": 20, "name": "maybe_feature_flags", "type_info": { "type": "VarString", @@ -174,7 +214,7 @@ } }, { - "ordinal": 17, + "ordinal": 21, "name": "can_use_tts", "type_info": { "type": "Tiny", @@ -184,7 +224,7 @@ } }, { - "ordinal": 18, + "ordinal": 22, "name": "can_use_w2l", "type_info": { "type": "Tiny", @@ -194,7 +234,7 @@ } }, { - "ordinal": 19, + "ordinal": 23, "name": "can_delete_own_tts_results", "type_info": { "type": "Tiny", @@ -204,7 +244,7 @@ } }, { - "ordinal": 20, + "ordinal": 24, "name": "can_delete_own_w2l_results", "type_info": { "type": "Tiny", @@ -214,7 +254,7 @@ } }, { - "ordinal": 21, + "ordinal": 25, "name": "can_delete_own_account", "type_info": { "type": "Tiny", @@ -224,7 +264,7 @@ } }, { - "ordinal": 22, + "ordinal": 26, "name": "can_upload_tts_models", "type_info": { "type": "Tiny", @@ -234,7 +274,7 @@ } }, { - "ordinal": 23, + "ordinal": 27, "name": "can_upload_w2l_templates", "type_info": { "type": "Tiny", @@ -244,7 +284,7 @@ } }, { - "ordinal": 24, + "ordinal": 28, "name": "can_delete_own_tts_models", "type_info": { "type": "Tiny", @@ -254,7 +294,7 @@ } }, { - "ordinal": 25, + "ordinal": 29, "name": "can_delete_own_w2l_templates", "type_info": { "type": "Tiny", @@ -264,7 +304,7 @@ } }, { - "ordinal": 26, + "ordinal": 30, "name": "can_approve_w2l_templates", "type_info": { "type": "Tiny", @@ -274,7 +314,7 @@ } }, { - "ordinal": 27, + "ordinal": 31, "name": "can_edit_other_users_profiles", "type_info": { "type": "Tiny", @@ -284,7 +324,7 @@ } }, { - "ordinal": 28, + "ordinal": 32, "name": "can_edit_other_users_tts_models", "type_info": { "type": "Tiny", @@ -294,7 +334,7 @@ } }, { - "ordinal": 29, + "ordinal": 33, "name": "can_edit_other_users_w2l_templates", "type_info": { "type": "Tiny", @@ -304,7 +344,7 @@ } }, { - "ordinal": 30, + "ordinal": 34, "name": "can_delete_other_users_tts_models", "type_info": { "type": "Tiny", @@ -314,7 +354,7 @@ } }, { - "ordinal": 31, + "ordinal": 35, "name": "can_delete_other_users_tts_results", "type_info": { "type": "Tiny", @@ -324,7 +364,7 @@ } }, { - "ordinal": 32, + "ordinal": 36, "name": "can_delete_other_users_w2l_templates", "type_info": { "type": "Tiny", @@ -334,7 +374,7 @@ } }, { - "ordinal": 33, + "ordinal": 37, "name": "can_delete_other_users_w2l_results", "type_info": { "type": "Tiny", @@ -344,7 +384,7 @@ } }, { - "ordinal": 34, + "ordinal": 38, "name": "can_ban_users", "type_info": { "type": "Tiny", @@ -354,7 +394,7 @@ } }, { - "ordinal": 35, + "ordinal": 39, "name": "can_delete_users", "type_info": { "type": "Tiny", @@ -374,6 +414,10 @@ false, false, false, + false, + false, + false, + false, true, true, false, @@ -406,5 +450,5 @@ true ] }, - "hash": "1c32bdb79ed2bc1b8988bbb6ea3f2a1db4ed4552d53cb4f31ff4b08de01691f7" + "hash": "3ca99449c071cb0a8d90a735e1a767a94be224a02e0d16e81997d569af8d4cbb" } diff --git a/.sqlx/query-453f0386cfe34e533501ba29dc2022fc449266f9858f16886bde9a0df6b82026.json b/.sqlx/query-453f0386cfe34e533501ba29dc2022fc449266f9858f16886bde9a0df6b82026.json deleted file mode 100644 index 750adcebb2..0000000000 --- a/.sqlx/query-453f0386cfe34e533501ba29dc2022fc449266f9858f16886bde9a0df6b82026.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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" -} diff --git a/.sqlx/query-4737c44e7b1c73b21a245dd9d1f96aecfc4c614ed19ac4b590a26809c98fee03.json b/.sqlx/query-4737c44e7b1c73b21a245dd9d1f96aecfc4c614ed19ac4b590a26809c98fee03.json new file mode 100644 index 0000000000..de43bfa08b --- /dev/null +++ b/.sqlx/query-4737c44e7b1c73b21a245dd9d1f96aecfc4c614ed19ac4b590a26809c98fee03.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-56746bcb993cb33f5492f401ce4fc3fd44d91fed660c2f106cb22e482dffa740.json b/.sqlx/query-56746bcb993cb33f5492f401ce4fc3fd44d91fed660c2f106cb22e482dffa740.json new file mode 100644 index 0000000000..09304da9e5 --- /dev/null +++ b/.sqlx/query-56746bcb993cb33f5492f401ce4fc3fd44d91fed660c2f106cb22e482dffa740.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-6fa90a16784c7b7e3436877c2a751bf0b60054640ebe69cb5f33938238def0fe.json b/.sqlx/query-6fa90a16784c7b7e3436877c2a751bf0b60054640ebe69cb5f33938238def0fe.json new file mode 100644 index 0000000000..0f380d368e --- /dev/null +++ b/.sqlx/query-6fa90a16784c7b7e3436877c2a751bf0b60054640ebe69cb5f33938238def0fe.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/.sqlx/query-b8795466b4251d869bdcc644324c6a873791ad1ef0d6d43f77ad2569accec3a4.json b/.sqlx/query-b8795466b4251d869bdcc644324c6a873791ad1ef0d6d43f77ad2569accec3a4.json new file mode 100644 index 0000000000..b8edc43dfd --- /dev/null +++ b/.sqlx/query-b8795466b4251d869bdcc644324c6a873791ad1ef0d6d43f77ad2569accec3a4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nUPDATE users\nSET\n email_confirmed = true,\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": "b8795466b4251d869bdcc644324c6a873791ad1ef0d6d43f77ad2569accec3a4" +} diff --git a/.sqlx/query-c2dd5120b34d62153772acc94f97c3b7a22b7c53216721e54882c0019d2467e6.json b/.sqlx/query-c2dd5120b34d62153772acc94f97c3b7a22b7c53216721e54882c0019d2467e6.json new file mode 100644 index 0000000000..25df35ccb6 --- /dev/null +++ b/.sqlx/query-c2dd5120b34d62153772acc94f97c3b7a22b7c53216721e54882c0019d2467e6.json @@ -0,0 +1,36 @@ +{ + "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" +} diff --git a/Cargo.lock b/Cargo.lock index 9987622d95..d3dba4b93c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,24 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "actix_artcraft" +version = "0.0.1" +dependencies = [ + "actix-http", + "actix-service", + "actix-web", + "actix_helpers", + "anyhow", + "cookies", + "enums", + "errors", + "http_headers", + "log", + "tokens", + "workspace-build-acceleration", +] + [[package]] name = "actix_cors_configs" version = "0.0.1" @@ -1351,6 +1369,7 @@ version = "0.0.1" dependencies = [ "actix-http", "actix-web", + "actix_artcraft", "anyhow", "artcraft_api_defs", "async-stripe 1.0.0-alpha.2", @@ -1364,6 +1383,7 @@ dependencies = [ "component_traits", "enums", "errors", + "http_headers", "http_server_common", "log", "mockall", @@ -1377,6 +1397,7 @@ dependencies = [ "tokens", "tokio", "user_traits_component", + "users", "workspace-build-acceleration", ] @@ -10718,6 +10739,7 @@ dependencies = [ "actix-web", "actix-web-actors", "actix-web-lab", + "actix_artcraft", "actix_cors_configs", "actix_helpers", "anyhow", @@ -10807,6 +10829,7 @@ dependencies = [ "url_config", "user_input_common", "user_traits_component", + "users", "utoipa", "utoipa-swagger-ui", "videos", @@ -12673,6 +12696,18 @@ dependencies = [ "workspace-build-acceleration", ] +[[package]] +name = "users" +version = "0.0.1" +dependencies = [ + "collections", + "md-5 0.10.6", + "once_cell", + "primitives", + "rand 0.9.1", + "workspace-build-acceleration", +] + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index af82d99ee2..6e7d26c088 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ members = [ "crates/desktop/artcraft", # Shared libraries + "crates/lib/actix_artcraft", "crates/lib/actix_cors_configs", "crates/lib/actix_helpers", "crates/lib/bootstrap", @@ -89,6 +90,7 @@ 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", @@ -185,13 +187,17 @@ 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" } @@ -218,6 +224,7 @@ 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" } @@ -246,6 +253,7 @@ 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" diff --git a/_database/sql/migrations/2026-01-28-232018_alter_users_add_was_eagerly_provisioned/down.sql b/_database/sql/migrations/2026-01-28-232018_alter_users_add_was_eagerly_provisioned/down.sql new file mode 100644 index 0000000000..72a7d3a662 --- /dev/null +++ b/_database/sql/migrations/2026-01-28-232018_alter_users_add_was_eagerly_provisioned/down.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE users +DROP COLUMN +was_eagerly_provisioned; diff --git a/_database/sql/migrations/2026-01-28-232018_alter_users_add_was_eagerly_provisioned/up.sql b/_database/sql/migrations/2026-01-28-232018_alter_users_add_was_eagerly_provisioned/up.sql new file mode 100644 index 0000000000..2e1015b685 --- /dev/null +++ b/_database/sql/migrations/2026-01-28-232018_alter_users_add_was_eagerly_provisioned/up.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE users +ADD COLUMN +was_eagerly_provisioned BOOLEAN NOT NULL DEFAULT false; diff --git a/_database/sql/migrations/2026-01-28-232025_alter_users_add_is_temporary/down.sql b/_database/sql/migrations/2026-01-28-232025_alter_users_add_is_temporary/down.sql new file mode 100644 index 0000000000..805d6441e4 --- /dev/null +++ b/_database/sql/migrations/2026-01-28-232025_alter_users_add_is_temporary/down.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE users +DROP COLUMN +is_temporary; diff --git a/_database/sql/migrations/2026-01-28-232025_alter_users_add_is_temporary/up.sql b/_database/sql/migrations/2026-01-28-232025_alter_users_add_is_temporary/up.sql new file mode 100644 index 0000000000..4e37896922 --- /dev/null +++ b/_database/sql/migrations/2026-01-28-232025_alter_users_add_is_temporary/up.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE users +ADD COLUMN +is_temporary BOOLEAN NOT NULL DEFAULT false; diff --git a/_database/sql/migrations/2026-01-28-232044_alter_users_add_email_is_synthetic/down.sql b/_database/sql/migrations/2026-01-28-232044_alter_users_add_email_is_synthetic/down.sql new file mode 100644 index 0000000000..7a012a5073 --- /dev/null +++ b/_database/sql/migrations/2026-01-28-232044_alter_users_add_email_is_synthetic/down.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE users +DROP COLUMN +email_is_synthetic; diff --git a/_database/sql/migrations/2026-01-28-232044_alter_users_add_email_is_synthetic/up.sql b/_database/sql/migrations/2026-01-28-232044_alter_users_add_email_is_synthetic/up.sql new file mode 100644 index 0000000000..a99ef71e13 --- /dev/null +++ b/_database/sql/migrations/2026-01-28-232044_alter_users_add_email_is_synthetic/up.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE users +ADD COLUMN +email_is_synthetic BOOLEAN NOT NULL DEFAULT false; diff --git a/_database/sql/migrations_squashed/user_tables/users.sql b/_database/sql/migrations_squashed/user_tables/users.sql index 76c570bf39..7cfbfeb39e 100644 --- a/_database/sql/migrations_squashed/user_tables/users.sql +++ b/_database/sql/migrations_squashed/user_tables/users.sql @@ -12,6 +12,14 @@ 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, @@ -30,6 +38,8 @@ 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. @@ -39,6 +49,11 @@ 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, @@ -86,6 +101,7 @@ 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 ========== @@ -191,10 +207,19 @@ CREATE TABLE users ( maybe_source VARCHAR(255) DEFAULT NULL, -- How users created their account - -- 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" + -- 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) 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. diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs index 7f68f5f072..d1b178bb3a 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -7,4 +7,5 @@ pub mod media_file; pub mod prompts; pub mod stripe_artcraft; pub mod subscriptions; +pub mod users; pub mod utils; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs new file mode 100644 index 0000000000..6a47dbbf21 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/create_create_new_user_account_and_subscription_checkout.rs @@ -0,0 +1,54 @@ +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, + + pub cadence: Option, +} + +#[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, + + /// If a session was created, these are the details. + pub session: Option, +} + +#[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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/mod.rs index 3a5d3a98cb..0083af57ca 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/stripe_artcraft/mod.rs @@ -1,3 +1,4 @@ +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; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/change_password.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/change_password.rs new file mode 100644 index 0000000000..0c24c08eb3 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/change_password.rs @@ -0,0 +1,13 @@ +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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs new file mode 100644 index 0000000000..ea91726cf5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs @@ -0,0 +1,12 @@ +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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_username.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_username.rs new file mode 100644 index 0000000000..427aa14ddf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_username.rs @@ -0,0 +1,12 @@ +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, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs new file mode 100644 index 0000000000..72c2789523 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs @@ -0,0 +1,4 @@ +pub mod change_password; +pub mod edit_email; +pub mod edit_username; +pub mod session_info; \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs new file mode 100644 index 0000000000..a4baac7c97 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs @@ -0,0 +1,99 @@ +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, + + // 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, +} diff --git a/crates/lib/actix_artcraft/Cargo.toml b/crates/lib/actix_artcraft/Cargo.toml new file mode 100644 index 0000000000..3f05da3ec5 --- /dev/null +++ b/crates/lib/actix_artcraft/Cargo.toml @@ -0,0 +1,40 @@ +# 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 ", + "Brandon Thomas ", +] +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) diff --git a/crates/lib/actix_artcraft/src/lib.rs b/crates/lib/actix_artcraft/src/lib.rs new file mode 100644 index 0000000000..9e41e41f35 --- /dev/null +++ b/crates/lib/actix_artcraft/src/lib.rs @@ -0,0 +1,21 @@ +//! 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; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/requests/get_request_signup_source.rs b/crates/lib/actix_artcraft/src/requests/get_request_signup_source.rs similarity index 59% rename from crates/service/web/storyteller_web/src/http_server/requests/get_request_signup_source.rs rename to crates/lib/actix_artcraft/src/requests/get_request_signup_source.rs index ee9d3b36e3..247fb19ecb 100644 --- a/crates/service/web/storyteller_web/src/http_server/requests/get_request_signup_source.rs +++ b/crates/lib/actix_artcraft/src/requests/get_request_signup_source.rs @@ -1,6 +1,5 @@ use actix_helpers::extractors::get_request_origin_uri::get_request_origin_uri; use actix_web::HttpRequest; -use enums::by_table::users::user_signup_source::UserSignupSource; use log::warn; const ARTCRAFT : &str = "artcraft"; @@ -50,10 +49,6 @@ pub fn get_request_signup_source(http_request: &HttpRequest) -> Option<&'static None } -pub fn get_request_signup_source_enum(http_request: &HttpRequest) -> Option { - get_request_signup_source(http_request) - .and_then(|source_str| UserSignupSource::from_str(source_str).ok()) -} #[cfg(test)] mod tests { @@ -61,7 +56,7 @@ mod tests { mod origin_header_str { use super::*; - use crate::http_server::requests::get_request_signup_source::get_request_signup_source; + use crate::requests::get_request_signup_source::get_request_signup_source; #[test] fn artcraft_dot_ai() { @@ -119,66 +114,4 @@ mod tests { assert_eq!(get_request_signup_source(&request), Some("storyteller")); } } - - mod origin_header_enum { - use super::*; - use crate::http_server::requests::get_request_signup_source::get_request_signup_source_enum; - use enums::by_table::users::user_signup_source::UserSignupSource; - - #[test] - fn artcraft_dot_ai() { - let request = TestRequest::get() - .insert_header(("origin", "https://artcraft.ai")) - .to_http_request(); - assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft)); - } - - #[test] - fn get_artcraft_dot_com() { - let request = TestRequest::get() - .insert_header(("origin", "https://getartcraft.com")) - .to_http_request(); - assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft)); - } - - #[test] - fn api_dot_get_artcraft_dot_com() { - let request = TestRequest::get() - .insert_header(("origin", "https://api.getartcraft.com")) - .to_http_request(); - assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft)); - } - - #[test] - fn fakeyou_dot_com() { - let request = TestRequest::get() - .insert_header(("origin", "https://fakeyou.com")) - .to_http_request(); - assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::FakeYou)); - } - - #[test] - fn api_dot_fakeyou_dot_com() { - let request = TestRequest::get() - .insert_header(("origin", "https://api.fakeyou.com")) - .to_http_request(); - assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::FakeYou)); - } - - #[test] - fn storyteller_dot_ai() { - let request = TestRequest::get() - .insert_header(("origin", "https://storyteller.ai")) - .to_http_request(); - assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::Storyteller)); - } - - #[test] - fn api_dot_storyteller_dot_ai() { - let request = TestRequest::get() - .insert_header(("origin", "https://api.storyteller.ai")) - .to_http_request(); - assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::Storyteller)); - } - } } diff --git a/crates/lib/actix_artcraft/src/requests/get_request_signup_source_enum.rs b/crates/lib/actix_artcraft/src/requests/get_request_signup_source_enum.rs new file mode 100644 index 0000000000..2d1bfaadf8 --- /dev/null +++ b/crates/lib/actix_artcraft/src/requests/get_request_signup_source_enum.rs @@ -0,0 +1,75 @@ +use crate::requests::get_request_signup_source::get_request_signup_source; +use actix_web::HttpRequest; +use enums::by_table::users::user_signup_source::UserSignupSource; + +pub fn get_request_signup_source_enum(http_request: &HttpRequest) -> Option { + get_request_signup_source(http_request) + .and_then(|source_str| UserSignupSource::from_str(source_str).ok()) +} + +#[cfg(test)] +mod tests { + use actix_web::test::TestRequest; + + mod origin_header_enum { + use super::*; + use crate::requests::get_request_signup_source_enum::get_request_signup_source_enum; + use enums::by_table::users::user_signup_source::UserSignupSource; + + #[test] + fn artcraft_dot_ai() { + let request = TestRequest::get() + .insert_header(("origin", "https://artcraft.ai")) + .to_http_request(); + assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft)); + } + + #[test] + fn get_artcraft_dot_com() { + let request = TestRequest::get() + .insert_header(("origin", "https://getartcraft.com")) + .to_http_request(); + assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft)); + } + + #[test] + fn api_dot_get_artcraft_dot_com() { + let request = TestRequest::get() + .insert_header(("origin", "https://api.getartcraft.com")) + .to_http_request(); + assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft)); + } + + #[test] + fn fakeyou_dot_com() { + let request = TestRequest::get() + .insert_header(("origin", "https://fakeyou.com")) + .to_http_request(); + assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::FakeYou)); + } + + #[test] + fn api_dot_fakeyou_dot_com() { + let request = TestRequest::get() + .insert_header(("origin", "https://api.fakeyou.com")) + .to_http_request(); + assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::FakeYou)); + } + + #[test] + fn storyteller_dot_ai() { + let request = TestRequest::get() + .insert_header(("origin", "https://storyteller.ai")) + .to_http_request(); + assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::Storyteller)); + } + + #[test] + fn api_dot_storyteller_dot_ai() { + let request = TestRequest::get() + .insert_header(("origin", "https://api.storyteller.ai")) + .to_http_request(); + assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::Storyteller)); + } + } +} diff --git a/crates/lib/actix_artcraft/src/requests/mod.rs b/crates/lib/actix_artcraft/src/requests/mod.rs new file mode 100644 index 0000000000..022f6662f6 --- /dev/null +++ b/crates/lib/actix_artcraft/src/requests/mod.rs @@ -0,0 +1,2 @@ +pub mod get_request_signup_source; +pub mod get_request_signup_source_enum; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/session/http/http_user_session_manager.rs b/crates/lib/actix_artcraft/src/sessions/http_user_session_manager.rs similarity index 96% rename from crates/service/web/storyteller_web/src/http_server/session/http/http_user_session_manager.rs rename to crates/lib/actix_artcraft/src/sessions/http_user_session_manager.rs index f9b4ae6aca..5ab376525a 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/http/http_user_session_manager.rs +++ b/crates/lib/actix_artcraft/src/sessions/http_user_session_manager.rs @@ -9,13 +9,12 @@ use actix_web::HttpRequest; use anyhow::anyhow; use log::warn; +use crate::sessions::http_user_session_payload::HttpUserSessionPayload; +use crate::sessions::payload_signer::HttpUserSessionPayloadSigner; use errors::AnyhowResult; use tokens::tokens::user_sessions::UserSessionToken; use tokens::tokens::users::UserToken; -use crate::http_server::session::http::http_user_session_payload::HttpUserSessionPayload; -use crate::http_server::session::http::payload_signer::HttpUserSessionPayloadSigner; - /// Name of the HTTP cookie that carries the session payload const SESSION_COOKIE_NAME : &str = "session"; @@ -139,7 +138,7 @@ mod tests { use tokens::tokens::user_sessions::UserSessionToken; use tokens::tokens::users::UserToken; - use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; + use crate::sessions::http_user_session_manager::HttpUserSessionManager; #[test] fn test_create_cookie_payload() { diff --git a/crates/service/web/storyteller_web/src/http_server/session/http/http_user_session_payload.rs b/crates/lib/actix_artcraft/src/sessions/http_user_session_payload.rs similarity index 100% rename from crates/service/web/storyteller_web/src/http_server/session/http/http_user_session_payload.rs rename to crates/lib/actix_artcraft/src/sessions/http_user_session_payload.rs diff --git a/crates/service/web/storyteller_web/src/http_server/session/http/mod.rs b/crates/lib/actix_artcraft/src/sessions/mod.rs similarity index 74% rename from crates/service/web/storyteller_web/src/http_server/session/http/mod.rs rename to crates/lib/actix_artcraft/src/sessions/mod.rs index e1a7f9970e..17470551dd 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/http/mod.rs +++ b/crates/lib/actix_artcraft/src/sessions/mod.rs @@ -1,3 +1,3 @@ pub mod http_user_session_manager; pub mod http_user_session_payload; -pub mod payload_signer; +pub mod payload_signer; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/session/http/payload_signer.rs b/crates/lib/actix_artcraft/src/sessions/payload_signer.rs similarity index 95% rename from crates/service/web/storyteller_web/src/http_server/session/http/payload_signer.rs rename to crates/lib/actix_artcraft/src/sessions/payload_signer.rs index 52375cbbf9..a16c67e80a 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/http/payload_signer.rs +++ b/crates/lib/actix_artcraft/src/sessions/payload_signer.rs @@ -1,12 +1,11 @@ use std::collections::BTreeMap; +use crate::sessions::http_user_session_payload::HttpUserSessionPayload; use cookies::jwt_signer::JwtSigner; use errors::AnyhowResult; use tokens::tokens::user_sessions::UserSessionToken; use tokens::tokens::users::UserToken; -use crate::http_server::session::http::http_user_session_payload::HttpUserSessionPayload; - /** * Session payload version history * @@ -57,11 +56,10 @@ impl HttpUserSessionPayloadSigner { #[cfg(test)] mod tests { + use crate::sessions::payload_signer::HttpUserSessionPayloadSigner; use tokens::tokens::user_sessions::UserSessionToken; use tokens::tokens::users::UserToken; - use crate::http_server::session::http::payload_signer::HttpUserSessionPayloadSigner; - #[test] fn test_encode() { let signer = HttpUserSessionPayloadSigner::new("fake_secret").unwrap(); diff --git a/crates/lib/users/Cargo.toml b/crates/lib/users/Cargo.toml new file mode 100644 index 0000000000..bc90c6defb --- /dev/null +++ b/crates/lib/users/Cargo.toml @@ -0,0 +1,29 @@ +[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) diff --git a/crates/lib/users/src/email/email_to_gravatar_hash.rs b/crates/lib/users/src/email/email_to_gravatar_hash.rs new file mode 100644 index 0000000000..c539db7ef8 --- /dev/null +++ b/crates/lib/users/src/email/email_to_gravatar_hash.rs @@ -0,0 +1,34 @@ +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()); + } +} \ No newline at end of file diff --git a/crates/lib/users/src/email/generate_random_synthetic_email.rs b/crates/lib/users/src/email/generate_random_synthetic_email.rs new file mode 100644 index 0000000000..408c873651 --- /dev/null +++ b/crates/lib/users/src/email/generate_random_synthetic_email.rs @@ -0,0 +1,36 @@ +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(".")); + } +} \ No newline at end of file diff --git a/crates/lib/users/src/email/mod.rs b/crates/lib/users/src/email/mod.rs new file mode 100644 index 0000000000..e7b17a2090 --- /dev/null +++ b/crates/lib/users/src/email/mod.rs @@ -0,0 +1,3 @@ +pub mod email_to_gravatar_hash; +pub mod generate_random_synthetic_email; +pub mod validate_email_address_format; \ No newline at end of file diff --git a/crates/lib/users/src/email/validate_email_address_format.rs b/crates/lib/users/src/email/validate_email_address_format.rs new file mode 100644 index 0000000000..8644615afb --- /dev/null +++ b/crates/lib/users/src/email/validate_email_address_format.rs @@ -0,0 +1,182 @@ + +/// 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")); + } +} \ No newline at end of file diff --git a/crates/lib/users/src/lib.rs b/crates/lib/users/src/lib.rs new file mode 100644 index 0000000000..fbf378636b --- /dev/null +++ b/crates/lib/users/src/lib.rs @@ -0,0 +1,20 @@ +//! 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; \ No newline at end of file diff --git a/crates/lib/users/src/username/constants.rs b/crates/lib/users/src/username/constants.rs new file mode 100644 index 0000000000..e38793f714 --- /dev/null +++ b/crates/lib/users/src/username/constants.rs @@ -0,0 +1,3 @@ + +/// Username may be up to this many characters, but not more. +pub const USERNAME_MAX_LENGTH: usize = 16; diff --git a/crates/service/web/storyteller_web/src/util/generate_random_username.rs b/crates/lib/users/src/username/generate_random_username.rs similarity index 89% rename from crates/service/web/storyteller_web/src/util/generate_random_username.rs rename to crates/lib/users/src/username/generate_random_username.rs index c006620a00..e8cb5755f5 100644 --- a/crates/service/web/storyteller_web/src/util/generate_random_username.rs +++ b/crates/lib/users/src/username/generate_random_username.rs @@ -1,15 +1,15 @@ -use crate::http_server::validations::validate_username::USERNAME_MAX_LENGTH; +use crate::username::constants::USERNAME_MAX_LENGTH; use collections::random_from_array::random_from_array; use once_cell::sync::Lazy; use primitives::iterators::iterate_trimmed_lines_without_comments::iterate_trimmed_lines_without_comments; use primitives::str::first_letter_uppercase::first_letter_uppercase; +use rand::distr::{Distribution, StandardUniform}; use rand::Rng; use std::collections::HashSet; -use rand::distr::{Distribution, StandardUniform}; -pub const ADJECTIVES : &str = include_str!("../../../../../../includes/binary_includes/usernames/atoms/username_adjectives.txt"); -pub const NOUNS : &str = include_str!("../../../../../../includes/binary_includes/usernames/atoms/username_nouns.txt"); -pub const NOUNS_ANIMALS: &str = include_str!("../../../../../../includes/binary_includes/usernames/atoms/username_nouns_animals.txt"); +pub const ADJECTIVES : &str = include_str!("../../../../../includes/binary_includes/usernames/atoms/username_adjectives.txt"); +pub const NOUNS : &str = include_str!("../../../../../includes/binary_includes/usernames/atoms/username_nouns.txt"); +pub const NOUNS_ANIMALS: &str = include_str!("../../../../../includes/binary_includes/usernames/atoms/username_nouns_animals.txt"); static ALL_NOUNS : Lazy> = Lazy::new(|| { iterate_trimmed_lines_without_comments(NOUNS.lines()) @@ -120,7 +120,7 @@ fn maybe_random_safe_digit() -> Option { #[cfg(test)] mod tests { use std::collections::HashSet; - use crate::util::generate_random_username::generate_random_username; + use crate::username::generate_random_username::generate_random_username; #[test] fn test_base_case_success() { diff --git a/crates/lib/users/src/username/mod.rs b/crates/lib/users/src/username/mod.rs new file mode 100644 index 0000000000..97b1c91b0b --- /dev/null +++ b/crates/lib/users/src/username/mod.rs @@ -0,0 +1,2 @@ +pub mod generate_random_username; +pub mod constants; \ No newline at end of file diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_email_and_password.rs b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_email_and_password.rs index b882ebf0ce..8e99bbc6d6 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_email_and_password.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_email_and_password.rs @@ -64,6 +64,11 @@ 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?; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_google_sso.rs b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_google_sso.rs index 041c617e19..f1d9938663 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_google_sso.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_google_sso.rs @@ -59,6 +59,11 @@ 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?; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_stripe_checkout.rs b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_stripe_checkout.rs new file mode 100644 index 0000000000..eb93d17ef8 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_from_stripe_checkout.rs @@ -0,0 +1,78 @@ +// 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, + + /// 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 +{ + 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) +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_generic.rs b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_generic.rs index 9e3aac025d..a660cd6023 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_generic.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user/create/create_account_generic.rs @@ -23,10 +23,35 @@ 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, @@ -66,6 +91,9 @@ pub async fn create_account_generic( INSERT INTO users SET token = ?, + + is_temporary = ?, + username = ?, display_name = ?, @@ -77,6 +105,9 @@ SET email_confirmed = FALSE, email_confirmed_by_google = ?, + email_is_synthetic = ?, + + was_eagerly_provisioned = ?, profile_markdown = ?, profile_rendered_html = ?, @@ -96,6 +127,9 @@ SET maybe_signup_method = ? "#, user_token.as_str(), + + args.is_temporary, + args.username, args.display_name, @@ -106,6 +140,9 @@ 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, diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/create/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user/create/mod.rs index 3254fb2018..206e73d510 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user/create/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user/create/mod.rs @@ -3,3 +3,4 @@ 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; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/update/maybe_update_email_from_synthetic_value.rs b/crates/schema/database/mysql_queries/src/queries/users/user/update/maybe_update_email_from_synthetic_value.rs new file mode 100644 index 0000000000..55669493fc --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user/update/maybe_update_email_from_synthetic_value.rs @@ -0,0 +1,123 @@ +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, +} +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 for UserEmailCheckRecord { + fn from(raw: UserEmailCheckRecordRaw) -> Self { + Self { + email_address: raw.email_address, + email_is_synthetic: i8_to_bool(raw.email_is_synthetic), + } + } +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs index 1d0def93ff..d9685a9ee0 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs @@ -1,6 +1,9 @@ +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; \ No newline at end of file diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/update/update_email.rs b/crates/schema/database/mysql_queries/src/queries/users/user/update/update_email.rs new file mode 100644 index 0000000000..3f7dd66a6e --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user/update/update_email.rs @@ -0,0 +1,83 @@ +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 }) + }, + } +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/update/update_password.rs b/crates/schema/database/mysql_queries/src/queries/users/user/update/update_password.rs new file mode 100644 index 0000000000..2adec76739 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user/update/update_password.rs @@ -0,0 +1,39 @@ +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(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_password_resets/change_password_from_password_reset.rs b/crates/schema/database/mysql_queries/src/queries/users/user_password_resets/change_password_from_password_reset.rs index 630d297d12..71e856664c 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_password_resets/change_password_from_password_reset.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_password_resets/change_password_from_password_reset.rs @@ -24,6 +24,7 @@ SET email_confirmed = true, password_hash = ?, ip_address_last_update = ?, + is_without_password = false, password_version = password_version + 1, version = version + 1 WHERE diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/get_user_session_by_token.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/get_user_session_by_token.rs index f49a55caf7..86466fa45e 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/get_user_session_by_token.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/get_user_session_by_token.rs @@ -20,8 +20,15 @@ pub struct SessionUserRecord { pub display_name: String, pub email_address: String, - pub email_confirmed: bool, 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, // ===== PREMIUM FEATURES ===== // @@ -111,8 +118,13 @@ SELECT users.display_name, users.email_address, - users.email_confirmed, 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.maybe_stripe_customer_id, users.maybe_loyalty_program_key, @@ -173,8 +185,13 @@ WHERE user_sessions.token = ? username: raw_user_record.username, display_name: raw_user_record.display_name, email_address: raw_user_record.email_address, - email_confirmed: i8_to_bool(raw_user_record.email_confirmed), 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), // Premium features maybe_stripe_customer_id: raw_user_record.maybe_stripe_customer_id, maybe_loyalty_program_key: raw_user_record.maybe_loyalty_program_key, @@ -239,9 +256,14 @@ struct SessionUserRawDbRecord { display_name: String, email_address: String, - email_confirmed: i8, 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, + maybe_stripe_customer_id: Option, maybe_loyalty_program_key: Option, diff --git a/crates/schema/public/enums/src/by_table/users/user_signup_method.rs b/crates/schema/public/enums/src/by_table/users/user_signup_method.rs index a5be1c6ce9..57e62ed6b5 100644 --- a/crates/schema/public/enums/src/by_table/users/user_signup_method.rs +++ b/crates/schema/public/enums/src/by_table/users/user_signup_method.rs @@ -18,6 +18,11 @@ 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! @@ -31,6 +36,7 @@ impl UserSignupMethod { match self { Self::EmailPassword => "email_password", Self::GoogleSignIn=> "google_sign_in", + Self::StripeCheckout => "stripe_checkout", } } @@ -38,6 +44,7 @@ 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)), } } @@ -48,6 +55,7 @@ impl UserSignupMethod { BTreeSet::from([ Self::EmailPassword, Self::GoogleSignIn, + Self::StripeCheckout, ]) } } @@ -64,6 +72,7 @@ mod tests { fn test_serialization() { assert_serialization(UserSignupMethod::EmailPassword, "email_password"); assert_serialization(UserSignupMethod::GoogleSignIn, "google_sign_in"); + assert_serialization(UserSignupMethod::StripeCheckout, "stripe_checkout"); } } @@ -74,12 +83,14 @@ 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()); } } @@ -90,9 +101,10 @@ mod tests { #[test] fn all_variants() { let mut variants = UserSignupMethod::all_variants(); - assert_eq!(variants.len(), 2); + assert_eq!(variants.len(), 3); 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); } } diff --git a/crates/schema/public/enums/src/by_table/users/user_signup_source.rs b/crates/schema/public/enums/src/by_table/users/user_signup_source.rs index 4e98f8cdc1..ee967f0b23 100644 --- a/crates/schema/public/enums/src/by_table/users/user_signup_source.rs +++ b/crates/schema/public/enums/src/by_table/users/user_signup_source.rs @@ -13,9 +13,30 @@ 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, @@ -33,6 +54,11 @@ 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", } @@ -41,6 +67,11 @@ impl UserSignupSource { pub fn from_str(value: &str) -> Result { 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)), @@ -52,6 +83,11 @@ 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, ]) @@ -69,6 +105,11 @@ 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"); } @@ -80,6 +121,10 @@ 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"); } @@ -87,6 +132,11 @@ 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()); @@ -99,8 +149,13 @@ mod tests { #[test] fn all_variants() { let mut variants = UserSignupSource::all_variants(); - assert_eq!(variants.len(), 3); + assert_eq!(variants.len(), 8); 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); diff --git a/crates/service/plugins/billing_artcraft/Cargo.toml b/crates/service/plugins/billing_artcraft/Cargo.toml index 60b67b8bc5..dd55a970ef 100644 --- a/crates/service/plugins/billing_artcraft/Cargo.toml +++ b/crates/service/plugins/billing_artcraft/Cargo.toml @@ -21,10 +21,12 @@ 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" } @@ -42,6 +44,7 @@ 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"] } diff --git a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs index 8bc4f98255..7c3eacd320 100644 --- a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs +++ b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs @@ -142,4 +142,8 @@ pub struct SubscriptionPaidEvent { /// Token to track in the wallet_ledger_events pub ledger_event_ref: Option, + + /// 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, } diff --git a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/mark_subscription_as_paid.rs b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/mark_subscription_as_paid.rs index bd78176990..1774e5cc98 100644 --- a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/mark_subscription_as_paid.rs +++ b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/mark_subscription_as_paid.rs @@ -97,7 +97,5 @@ pub async fn mark_subscription_as_paid( transaction ).await?; - // TODO: Insert wallet ledger entry. - Ok(()) } diff --git a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs index 099388a242..1b510939d1 100644 --- a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs +++ b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs @@ -44,6 +44,8 @@ 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); diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/creation_payload.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/creation_payload.rs new file mode 100644 index 0000000000..0cc8c9b7c4 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/creation_payload.rs @@ -0,0 +1,16 @@ +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 +} + +pub (super) struct UserMetadata { + pub user_token: UserToken, + pub session_token: UserSessionToken, + pub username: String, + pub display_name: String, + pub email_address: String, +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/mod.rs new file mode 100644 index 0000000000..0dac35524d --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/mod.rs @@ -0,0 +1,4 @@ +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; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/stripe_artcraft_create_subscription_checkout_with_user_signup_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/stripe_artcraft_create_subscription_checkout_with_user_signup_handler.rs new file mode 100644 index 0000000000..1621db0747 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/stripe_artcraft_create_subscription_checkout_with_user_signup_handler.rs @@ -0,0 +1,195 @@ +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, + stripe_config: Data, + server_environment: Data, + session_cookie_manager: web::Data, + internal_user_lookup: Data, + internal_session_cache_purge: Data, + mysql_pool: Data, +) -> Result +{ + 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 { + 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 { + + 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)) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_creation_case.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_creation_case.rs new file mode 100644 index 0000000000..321f02a37d --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_creation_case.rs @@ -0,0 +1,156 @@ +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 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, + stripe_config: &ArtcraftStripeConfigWithClient, +) -> Result { + + 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: 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::>() + .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, + }) + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_exists_case.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_exists_case.rs new file mode 100644 index 0000000000..2167678b2d --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_exists_case.rs @@ -0,0 +1,89 @@ +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, + stripe_config: &ArtcraftStripeConfigWithClient, +) -> Result { + + // 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, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_switch_plan_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_switch_plan_handler.rs index 2b2173dc79..6bfbcee1a8 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_switch_plan_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_switch_plan_handler.rs @@ -1,6 +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::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; use crate::utils::common_web_error::CommonWebError; use actix_web::web::{Data, Json}; @@ -24,7 +25,6 @@ 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 = lookup_subscription_from_subscription_id( + let existing_subscription = stripe_lookup_subscription_from_subscription_id( &existing_subscription_id, &stripe_config.client ).await.map_err(|err| { diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_update_payment_method_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_update_payment_method_handler.rs index d9041e0eab..41903b81be 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_update_payment_method_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_customer_portal_update_payment_method_handler.rs @@ -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::requests::lookup_subscription_from_subscription_id::lookup_subscription_from_subscription_id; +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}; diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs index 7cb14865dc..c5feabace3 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs @@ -1,3 +1,4 @@ pub mod checkout; +pub mod checkout_with_user_signup; pub mod customer_portal; pub mod webhook; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs index b849f44ae3..c767f24050 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs @@ -1,6 +1,7 @@ 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; @@ -13,13 +14,14 @@ 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: @@ -140,7 +142,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, @@ -164,6 +166,11 @@ 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, })) @@ -172,7 +179,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, @@ -197,11 +204,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, - maybe_stripe_customer_id: artcraft_event.webhook_event_log_summary.maybe_stripe_customer_id, + 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(), stripe_event_created_at, stripe_is_production, - maybe_user_token: artcraft_event.webhook_event_log_summary.maybe_user_token.map(|t| t.to_string()), + maybe_user_token: artcraft_event.webhook_event_log_summary.maybe_user_token.as_ref().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, }; @@ -216,3 +223,45 @@ 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 +) -> 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(()) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/invoice/invoice_paid_extractor.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/invoice/invoice_paid_extractor.rs index acf3277547..0e271e029f 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/invoice/invoice_paid_extractor.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/invoice/invoice_paid_extractor.rs @@ -4,15 +4,16 @@ 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::requests::lookup_subscription_from_subscription_id::lookup_subscription_from_subscription_id; +use crate::stripe_requests::stripe_lookup_subscription_from_subscription_id::stripe_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 crate::utils::stripe_event_descriptor::StripeEventDescriptor; +use stripe_types::Expandable; // Handle event type: 'invoice.paid' // @@ -101,7 +102,7 @@ pub async fn invoice_paid_extractor( info!("Calling Stripe to look up subscription info..."); - let subscription = lookup_subscription_from_subscription_id( + let subscription = stripe_lookup_subscription_from_subscription_id( &subscription_id, stripe_client).await?; // TODO: Multiple ways to get this; better ways to get this @@ -155,6 +156,7 @@ 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, }) diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/payment_intent/payment_intent_succeeded_extractor.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/payment_intent/payment_intent_succeeded_extractor.rs index 6356a8f5df..776ce3046f 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/payment_intent/payment_intent_succeeded_extractor.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/payment_intent/payment_intent_succeeded_extractor.rs @@ -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::requests::lookup_purchase_from_payment_intent_success::lookup_purchase_from_payment_intent_success; +use crate::stripe_requests::stripe_lookup_purchase_from_payment_intent_success::stripe_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 = lookup_purchase_from_payment_intent_success(&payment_intent_id, stripe_client) + let purchase = stripe_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); diff --git a/crates/service/plugins/billing_artcraft/src/lib.rs b/crates/service/plugins/billing_artcraft/src/lib.rs index 50949522b1..07ee2d86ae 100644 --- a/crates/service/plugins/billing_artcraft/src/lib.rs +++ b/crates/service/plugins/billing_artcraft/src/lib.rs @@ -1,5 +1,5 @@ pub mod billing_action_fulfillment; pub mod configs; pub mod endpoints; -pub mod requests; +pub mod stripe_requests; pub mod utils; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/requests/mod.rs b/crates/service/plugins/billing_artcraft/src/requests/mod.rs deleted file mode 100644 index 7870ec1916..0000000000 --- a/crates/service/plugins/billing_artcraft/src/requests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod lookup_purchase_from_payment_intent_success; -pub mod lookup_subscription_from_subscription_id; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/stripe_requests/mod.rs b/crates/service/plugins/billing_artcraft/src/stripe_requests/mod.rs new file mode 100644 index 0000000000..51b02501a6 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/stripe_requests/mod.rs @@ -0,0 +1,2 @@ +pub mod stripe_lookup_purchase_from_payment_intent_success; +pub mod stripe_lookup_subscription_from_subscription_id; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/requests/lookup_purchase_from_payment_intent_success.rs b/crates/service/plugins/billing_artcraft/src/stripe_requests/stripe_lookup_purchase_from_payment_intent_success.rs similarity index 97% rename from crates/service/plugins/billing_artcraft/src/requests/lookup_purchase_from_payment_intent_success.rs rename to crates/service/plugins/billing_artcraft/src/stripe_requests/stripe_lookup_purchase_from_payment_intent_success.rs index 90061f23c4..68c31332fc 100644 --- a/crates/service/plugins/billing_artcraft/src/requests/lookup_purchase_from_payment_intent_success.rs +++ b/crates/service/plugins/billing_artcraft/src/stripe_requests/stripe_lookup_purchase_from_payment_intent_success.rs @@ -17,7 +17,7 @@ pub struct PurchaseSummary { } // This assumes the payment came from a checkout session. -pub async fn lookup_purchase_from_payment_intent_success( +pub async fn stripe_lookup_purchase_from_payment_intent_success( payment_intent_id: &str, stripe_client: &Client ) -> anyhow::Result { diff --git a/crates/service/plugins/billing_artcraft/src/requests/lookup_subscription_from_subscription_id.rs b/crates/service/plugins/billing_artcraft/src/stripe_requests/stripe_lookup_subscription_from_subscription_id.rs similarity index 97% rename from crates/service/plugins/billing_artcraft/src/requests/lookup_subscription_from_subscription_id.rs rename to crates/service/plugins/billing_artcraft/src/stripe_requests/stripe_lookup_subscription_from_subscription_id.rs index ba31153fad..e0aa22c95a 100644 --- a/crates/service/plugins/billing_artcraft/src/requests/lookup_subscription_from_subscription_id.rs +++ b/crates/service/plugins/billing_artcraft/src/stripe_requests/stripe_lookup_subscription_from_subscription_id.rs @@ -1,5 +1,5 @@ use crate::configs::stripe_client_retry_strategy::STRIPE_CLIENT_RETRY_STRATEGY; -use crate::requests::lookup_purchase_from_payment_intent_success::PurchaseSummary; +use crate::stripe_requests::stripe_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, } -pub async fn lookup_subscription_from_subscription_id( +pub async fn stripe_lookup_subscription_from_subscription_id( subscription_id: &str, stripe_client: &Client ) -> anyhow::Result { diff --git a/crates/service/plugins/billing_artcraft/src/utils/create_checkout/create_subscription_checkout_session.rs b/crates/service/plugins/billing_artcraft/src/utils/create_checkout/create_subscription_checkout_session.rs new file mode 100644 index 0000000000..409735e1df --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/create_checkout/create_subscription_checkout_session.rs @@ -0,0 +1,123 @@ +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 { + + 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) +} \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/utils/create_checkout/mod.rs b/crates/service/plugins/billing_artcraft/src/utils/create_checkout/mod.rs new file mode 100644 index 0000000000..1ceb5d4c4f --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/create_checkout/mod.rs @@ -0,0 +1 @@ +pub mod create_subscription_checkout_session; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/utils/mod.rs b/crates/service/plugins/billing_artcraft/src/utils/mod.rs index c02140dc45..bff3fdbe66 100644 --- a/crates/service/plugins/billing_artcraft/src/utils/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/utils/mod.rs @@ -1,6 +1,7 @@ pub (crate) mod metadata; pub mod artcraft_stripe_config; pub mod common_web_error; +pub mod create_checkout; pub mod enum_conversion; pub mod expand_ids; pub mod stripe_event_descriptor; diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index e683e8b369..1a607ec792 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -32,17 +32,18 @@ path = "src/api_doc_cli.rs" workspace-build-acceleration.workspace = true # Internal +actix_artcraft.workspace = true actix_cors_configs = { path = "../../../lib/actix_cors_configs" } -actix_helpers = { path = "../../../lib/actix_helpers" } +actix_helpers.workspace = true artcraft_api_defs.workspace = true bootstrap = { path = "../../../lib/bootstrap" } bucket_paths = { path = "../../../lib/bucket_paths" } cloud_storage.workspace = true -collections = { path = "../../../lib/data/collections" } +collections.workspace = true component_traits.workspace = true composite_identifiers = { path = "../../../schema/public/composite_identifiers" } config = { path = "../../../lib/deprecated/config" } -cookies = { path = "../../../lib/cookies" } +cookies.workspace = true crockford = { path = "../../../lib/data/crockford" } datetimes = { path = "../../../lib/data/datetimes" } easyenv = { path = "../../../lib/easyenv" } @@ -78,6 +79,7 @@ tokens.workspace = true tts_common = { path = "../../../lib/tts_common" } url_config = { path = "../../../lib/url_config" } user_input_common = { path = "../../../lib/user_input_common" } +users.workspace = true videos = { path = "../../../lib/files/videos" } # Internal Components diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index e0f93f0edf..abb9fb9311 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -102,7 +102,9 @@ use crate::http_server::endpoints::user_bookmarks::list_user_bookmarks_for_user_ use crate::http_server::endpoints::user_ratings::batch_get_user_rating_handler::*; use crate::http_server::endpoints::user_ratings::get_user_rating_handler::*; use crate::http_server::endpoints::user_ratings::set_user_rating_handler::*; +use crate::http_server::endpoints::users::change_password_handler::*; use crate::http_server::endpoints::users::create_account_handler::*; +use crate::http_server::endpoints::users::edit_email_handler::*; use crate::http_server::endpoints::users::edit_username_handler::*; use crate::http_server::endpoints::users::get_profile_handler::*; use crate::http_server::endpoints::users::google_sso::google_sso_handler::*; @@ -122,6 +124,9 @@ use crate::http_server::endpoints::weights::list::list_weights_by_user_handler:: use crate::http_server::endpoints::weights::search::search_model_weights_impl::*; use crate::http_server::endpoints::weights::update::set_model_weight_cover_image_handler::*; use crate::http_server::endpoints::weights::update::update_weight_handler::*; +use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse}; +use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse}; +use artcraft_api_defs::users::edit_username::{EditUsernameRequest, EditUsernameResponse}; use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest; use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest; use artcraft_api_defs::common::responses::media_links::*; @@ -313,7 +318,9 @@ use tokens::tokens::zs_voice_datasets::*; crate::http_server::endpoints::user_ratings::batch_get_user_rating_handler::batch_get_user_rating_handler, crate::http_server::endpoints::user_ratings::get_user_rating_handler::get_user_rating_handler, crate::http_server::endpoints::user_ratings::set_user_rating_handler::set_user_rating_handler, + crate::http_server::endpoints::users::change_password_handler::change_password_handler, crate::http_server::endpoints::users::create_account_handler::create_account_handler, + crate::http_server::endpoints::users::edit_email_handler::edit_email_handler, crate::http_server::endpoints::users::edit_username_handler::edit_username_handler, crate::http_server::endpoints::users::get_profile_handler::get_profile_handler, crate::http_server::endpoints::users::google_sso::google_sso_handler::google_sso_handler, @@ -508,6 +515,12 @@ use tokens::tokens::zs_voice_datasets::*; GptImage1EditImageNumImages, GptImage1EditImageImageQuality, GptImage1EditImageResponse, + ChangePasswordError, + ChangePasswordRequest, + ChangePasswordResponse, + EditEmailError, + EditEmailRequest, + EditEmailResponse, EditUsernameError, EditUsernameRequest, EditUsernameResponse, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/change_password_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/change_password_handler.rs new file mode 100644 index 0000000000..f47b14392b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/change_password_handler.rs @@ -0,0 +1,135 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Json; +use actix_web::{web, HttpRequest, HttpResponse}; +use log::warn; +use utoipa::ToSchema; +use artcraft_api_defs::users::change_password::{ChangePasswordRequest, ChangePasswordResponse}; +use crate::http_server::validations::validate_passwords::validate_passwords; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::users::user::update::update_password::{update_password, UpdatePasswordArgs}; +use mysql_queries::utils::transactor::Transactor; +use password::bcrypt_hash_password::bcrypt_hash_password; + +#[derive(Debug, ToSchema)] +pub enum ChangePasswordError { + BadInput(String), + NotAuthorized, + ServerError, +} + +impl ResponseError for ChangePasswordError { + fn status_code(&self) -> StatusCode { + match *self { + ChangePasswordError::BadInput(_) => StatusCode::BAD_REQUEST, + ChangePasswordError::NotAuthorized => StatusCode::UNAUTHORIZED, + ChangePasswordError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + ChangePasswordError::BadInput(reason) => reason.to_string(), + ChangePasswordError::NotAuthorized => "unauthorized".to_string(), + ChangePasswordError::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for ChangePasswordError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + + +/// Change password for the current user. +#[utoipa::path( + post, + tag = "Users", + path = "/v1/user/change_password", + responses( + (status = 200, description = "Success", body = ChangePasswordResponse), + (status = 400, description = "Bad input", body = ChangePasswordError), + (status = 401, description = "Not authorized", body = ChangePasswordError), + (status = 500, description = "Server error", body = ChangePasswordError), + ), + params( + ("request" = ChangePasswordRequest, description = "Payload for Request"), + ) +)] +pub async fn change_password_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, ChangePasswordError> +{ + let password = request.password.trim(); + let password_confirmation = request.password_confirmation.trim(); + + if let Err(reason) = validate_passwords(password, password_confirmation) { + return Err(ChangePasswordError::BadInput(reason)); + } + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + ChangePasswordError::ServerError + })?; + + let user_session = require_user_session_using_connection( + &http_request, + &server_state.session_checker, + &mut mysql_connection) + .await + .map_err(|_err| { + ChangePasswordError::NotAuthorized + })?; + + if user_session.role.is_banned { + return Err(ChangePasswordError::NotAuthorized); + } + + let password_hash = match bcrypt_hash_password(password.to_string()) { + Ok(hash) => hash, + Err(err) => { + warn!("Bcrypt error: {:?}", err); + return Err(ChangePasswordError::ServerError); + } + }; + + let ip_address = get_request_ip(&http_request); + + let result = update_password(UpdatePasswordArgs { + user_token: &user_session.user_token_typed, + password_hash: &password_hash, + ip_address: &ip_address, + transactor: Transactor::for_connection(&mut mysql_connection), + }).await; + + match result { + Ok(()) => {}, + Err(err) => { + warn!("Error updating password: {:?}", err); + return Err(ChangePasswordError::ServerError); + } + } + + Ok(Json(ChangePasswordResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs index 6e1aa03339..1c387e7c02 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs @@ -7,13 +7,12 @@ use std::collections::HashMap; use std::fmt; use std::fmt::Formatter; -use crate::http_server::requests::get_request_signup_source::get_request_signup_source_enum; -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; use crate::http_server::validations::is_reserved_username::is_reserved_username; use crate::http_server::validations::validate_passwords::validate_passwords; use crate::http_server::validations::validate_username::validate_username; -use crate::util::email_to_gravatar::email_to_gravatar; use crate::util::enroll_in_studio::enroll_in_studio; +use actix_artcraft::requests::get_request_signup_source_enum::get_request_signup_source_enum; +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::error::ResponseError; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; @@ -29,6 +28,7 @@ use password::bcrypt_hash_password::bcrypt_hash_password; use sqlx::MySqlPool; use tokens::tokens::user_sessions::UserSessionToken; use user_input_common::check_for_slurs::contains_slurs; +use users::email::email_to_gravatar_hash::email_to_gravatar_hash; use utoipa::ToSchema; #[derive(ToSchema, Deserialize)] @@ -185,7 +185,7 @@ pub async fn create_account_handler( let email_address = request.email_address.trim().to_lowercase(); - let email_gravatar_hash = email_to_gravatar(&email_address); + let email_gravatar_hash = email_to_gravatar_hash(&email_address); let ip_address = get_request_ip(&http_request); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs new file mode 100644 index 0000000000..f4b153f6c3 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs @@ -0,0 +1,131 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Json; +use actix_web::{web, HttpRequest, HttpResponse}; +use log::warn; +use utoipa::ToSchema; +use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse}; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::users::user::update::update_email::{update_email, UpdateEmailArgs, UpdateEmailError}; +use mysql_queries::utils::transactor::Transactor; +use users::email::email_to_gravatar_hash::email_to_gravatar_hash; +use users::email::validate_email_address_format::validate_email_address_format; + +#[derive(Debug, ToSchema)] +pub enum EditEmailError { + BadInput(String), + NotAuthorized, + ServerError, +} + +impl ResponseError for EditEmailError { + fn status_code(&self) -> StatusCode { + match *self { + EditEmailError::BadInput(_) => StatusCode::BAD_REQUEST, + EditEmailError::NotAuthorized => StatusCode::UNAUTHORIZED, + EditEmailError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + EditEmailError::BadInput(reason) => reason.to_string(), + EditEmailError::NotAuthorized => "unauthorized".to_string(), + EditEmailError::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for EditEmailError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + + +/// Edit email address of the current user. +#[utoipa::path( + post, + tag = "Users", + path = "/v1/user/edit_email", + responses( + (status = 200, description = "Success", body = EditEmailResponse), + (status = 400, description = "Bad input", body = EditEmailError), + (status = 401, description = "Not authorized", body = EditEmailError), + (status = 500, description = "Server error", body = EditEmailError), + ), + params( + ("request" = EditEmailRequest, description = "Payload for Request"), + ) +)] +pub async fn edit_email_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, EditEmailError> +{ + let email_address = request.email_address.trim().to_lowercase(); + + if let Err(reason) = validate_email_address_format(&email_address) { + return Err(EditEmailError::BadInput(format!("bad email: {}", &reason))); + } + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + EditEmailError::ServerError + })?; + + let user_session = require_user_session_using_connection( + &http_request, + &server_state.session_checker, + &mut mysql_connection) + .await + .map_err(|_err| { + EditEmailError::NotAuthorized + })?; + + if user_session.role.is_banned { + return Err(EditEmailError::NotAuthorized); + } + + let ip_address = get_request_ip(&http_request); + let gravatar_hash = email_to_gravatar_hash(&email_address); + + let result = update_email(UpdateEmailArgs { + token: &user_session.user_token_typed, + email_address: &email_address, + email_gravatar_hash: &gravatar_hash, + ip_address: &ip_address, + transactor: Transactor::for_connection(&mut mysql_connection), + }).await; + + match result { + Ok(()) => {}, + Err(UpdateEmailError::EmailIsTaken) => { + return Err(EditEmailError::BadInput("email address is already in use".to_string())); + } + Err(UpdateEmailError::DatabaseError { source }) => { + warn!("Error updating email: {:?}", source); + return Err(EditEmailError::ServerError); + } + } + + Ok(Json(EditEmailResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs index 4c7f02426e..f67fa158e9 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_username_handler.rs @@ -12,7 +12,7 @@ use actix_web::web::Json; use actix_web::{web, HttpRequest, HttpResponse}; use log::warn; use utoipa::ToSchema; - +use artcraft_api_defs::users::edit_username::{EditUsernameRequest, EditUsernameResponse}; use crate::http_server::validations::is_reserved_username::is_reserved_username; use crate::http_server::validations::validate_username::validate_username; use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; @@ -23,15 +23,6 @@ use mysql_queries::queries::users::user::update::update_username::{update_userna use mysql_queries::utils::transactor::Transactor; use user_input_common::check_for_slurs::contains_slurs; -#[derive(Deserialize, ToSchema)] -pub struct EditUsernameRequest { - display_name: String, -} - -#[derive(Serialize, ToSchema)] -pub struct EditUsernameResponse { - pub success: bool, -} #[derive(Debug, ToSchema)] pub enum EditUsernameError { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs index 9d6aecd08c..000766aaf4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/google_sso_handler.rs @@ -9,8 +9,8 @@ use std::fmt::Formatter; use crate::http_server::endpoints::users::google_sso::check_claims::check_claims; use crate::http_server::endpoints::users::google_sso::handle_existing_sso_account::{handle_existing_sso_account, ExistingAccountArgs}; use crate::http_server::endpoints::users::google_sso::handle_new_sso_account::{handle_new_sso_account, NewSsoArgs}; -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; use crate::state::certs::google_sign_in_cert::GoogleSignInCert; +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::error::ResponseError; use actix_web::http::StatusCode; use actix_web::web::{Data, Json}; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs index 92bab8a865..68edcb7d97 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs @@ -1,9 +1,7 @@ use crate::http_server::endpoints::users::google_sso::google_sso_handler::GoogleCreateAccountErrorResponse; use crate::http_server::endpoints::users::google_sso::handle_new_sso_account::NewSsoAccountInfo; -use crate::http_server::requests::get_request_signup_source::{get_request_signup_source, get_request_signup_source_enum}; use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; -use crate::util::email_to_gravatar::email_to_gravatar; -use crate::util::generate_random_username::generate_random_username; +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 google_sign_in::claims::claims::Claims; @@ -15,6 +13,8 @@ use mysql_queries::queries::users::user::create::create_account_from_google_sso: use mysql_queries::utils::transactor::Transactor; use sqlx::pool::PoolConnection; use sqlx::{Acquire, MySql}; +use users::email::email_to_gravatar_hash::email_to_gravatar_hash; +use users::username::generate_random_username::generate_random_username; pub struct CreateArgs<'a> { pub http_request: &'a HttpRequest, @@ -40,7 +40,7 @@ pub async fn handle_new_sso_account_for_new_user( let user_feature_flags = studio_feature_flags(); let ip_address = get_request_ip(&args.http_request); - let user_email_gravatar_hash = email_to_gravatar(&args.user_email_address); + let user_email_gravatar_hash = email_to_gravatar_hash(&args.user_email_address); let mut maybe_source = get_request_signup_source_enum(&args.http_request); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs index 491f99bae9..bb203651ef 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs @@ -6,22 +6,21 @@ use std::fmt; use std::fmt::Formatter; +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::error::ResponseError; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; -use log::{info, warn}; -use sqlx::MySqlPool; -use utoipa::ToSchema; - use http_server_common::request::get_request_ip::get_request_ip; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use log::{info, warn}; use mysql_queries::queries::users::user::get::lookup_user_for_login_by_email::lookup_user_for_login_by_email; use mysql_queries::queries::users::user::get::lookup_user_for_login_by_username::lookup_user_for_login_by_username; use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; use password::bcrypt_confirm_password::bcrypt_confirm_password; +use sqlx::MySqlPool; use tokens::tokens::user_sessions::UserSessionToken; +use utoipa::ToSchema; -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::util::enroll_in_studio::enroll_in_studio; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/logout_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/logout_handler.rs index 392fa298c6..42acd7836d 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/logout_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/logout_handler.rs @@ -5,18 +5,16 @@ use std::fmt; +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::error::ResponseError; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; -use log::warn; -use sqlx::MySqlPool; -use utoipa::ToSchema; - use http_server_common::response::response_error_helpers::to_simple_json_error; +use log::warn; use mysql_queries::queries::users::user_sessions::delete_user_session::delete_user_session; +use sqlx::MySqlPool; use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge; - -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; +use utoipa::ToSchema; #[derive(Serialize, ToSchema)] pub struct LogoutSuccessResponse { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs index ce5ad1437d..f3dda5f783 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs @@ -1,4 +1,6 @@ +pub mod change_password_handler; pub mod create_account_handler; +pub mod edit_email_handler; pub mod edit_profile_handler; pub mod edit_username_handler; pub mod get_profile_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/password_reset_redeem_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/password_reset_redeem_handler.rs index 87516281b7..513aa00bfb 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/password_reset_redeem_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/password_reset_redeem_handler.rs @@ -1,22 +1,20 @@ use std::fmt::Display; +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse, ResponseError}; -use log::{error, warn}; -use serde::Deserialize; -use sqlx::MySqlPool; -use strum_macros::Display; - use http_server_common::request::get_request_ip::get_request_ip; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use log::{error, warn}; use mysql_queries::queries::users::user_password_resets::change_password_from_password_reset::{change_password_from_password_reset, ChangePasswordFromPasswordResetArgs}; use mysql_queries::queries::users::user_password_resets::lookup_password_reset_request::lookup_password_reset_request; use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; use password::bcrypt_hash_password::bcrypt_hash_password; +use serde::Deserialize; +use sqlx::MySqlPool; +use strum_macros::Display; use tokens::tokens::user_sessions::UserSessionToken; -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; - #[derive(Deserialize)] pub struct PasswordResetRedemptionRequest { reset_token: String, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs index 94d82be33e..3ed0f75e9e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/session_info_handler.rs @@ -3,7 +3,6 @@ #![forbid(unused_mut)] #![forbid(unused_variables)] -use std::collections::BTreeSet; use std::fmt; use actix_web::error::ResponseError; @@ -13,90 +12,15 @@ use log::warn; use sqlx::MySqlPool; use utoipa::ToSchema; +use artcraft_api_defs::users::session_info::{FakeYouPlan, SessionInfoSuccessResponse, SessionOnboardingState, SessionUserInfo, StorytellerStreamPlan}; use enums::by_table::users::user_feature_flag::UserFeatureFlag; use http_server_common::response::response_error_helpers::to_simple_json_error; -use tokens::tokens::users::UserToken; -use crate::http_server::common_responses::user_details_lite::UserDetailsLight; +use crate::http_server::common_responses::user_details_lite_builder::UserDetailsLightBuilder; use crate::http_server::cookies::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager; use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::http_server::session::session_checker::SessionChecker; -#[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, - - // 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, - - // 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 SessionInfoSuccessResponse { - pub success: bool, - pub logged_in: bool, - pub user: Option, -} #[derive(Debug, ToSchema)] pub enum SessionInfoError { @@ -170,12 +94,18 @@ pub async fn session_info_handler( // NB: Banned users can't be logged in logged_in = true; user_info = Some(SessionUserInfo { - core_info: UserDetailsLight::from_db_fields( + core_info: UserDetailsLightBuilder::from_db_fields( &session_data.user_token, &session_data.username, &session_data.display_name, &session_data.email_gravatar_hash, ), + onboarding: SessionOnboardingState { + email_not_set: session_data.email_is_synthetic, + email_not_confirmed: !(session_data.email_confirmed || session_data.email_confirmed_by_google), + password_not_set: session_data.is_without_password, + username_not_customized: session_data.username_is_not_customized, + }, user_token: session_data.user_token, username: session_data.username.to_string(), display_name: session_data.display_name.to_string(), diff --git a/crates/service/web/storyteller_web/src/http_server/requests/mod.rs b/crates/service/web/storyteller_web/src/http_server/requests/mod.rs index 83dac24461..68bf0c0996 100644 --- a/crates/service/web/storyteller_web/src/http_server/requests/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/requests/mod.rs @@ -1,3 +1,2 @@ pub mod get_request_domain_branding; -pub mod get_request_signup_source; pub mod request_headers; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/stripe_artcraft_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/stripe_artcraft_routes.rs index 88234d9d16..91930e542d 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/stripe_artcraft_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/stripe_artcraft_routes.rs @@ -4,6 +4,7 @@ use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{web, App, Error, HttpResponse}; use billing_artcraft_component::endpoints::checkout::stripe_artcraft_create_credits_pack_checkout_handler::stripe_artcraft_create_credits_pack_checkout_handler; use billing_artcraft_component::endpoints::checkout::stripe_artcraft_create_subscription_checkout_handler::stripe_artcraft_create_subscription_session_handler; +use billing_artcraft_component::endpoints::checkout_with_user_signup::stripe_artcraft_create_subscription_checkout_with_user_signup_handler::stripe_artcraft_create_subscription_checkout_with_user_signup_handler; use billing_artcraft_component::endpoints::customer_portal::stripe_artcraft_customer_portal_cancel_plan_handler::stripe_artcraft_customer_portal_cancel_plan_handler; use billing_artcraft_component::endpoints::customer_portal::stripe_artcraft_customer_portal_manage_plan_handler::stripe_artcraft_customer_portal_manage_plan_handler; use billing_artcraft_component::endpoints::customer_portal::stripe_artcraft_customer_portal_switch_plan_handler::stripe_artcraft_customer_portal_switch_plan_handler; @@ -50,5 +51,9 @@ where .route(web::post().to(stripe_artcraft_customer_portal_update_payment_method_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/user_signup_subscription_checkout") + .route(web::post().to(stripe_artcraft_create_subscription_checkout_with_user_signup_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) } diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs index 2fe15c208d..8986d5e969 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs @@ -8,7 +8,9 @@ use crate::http_server::deprecated_endpoints::w2l::list_user_w2l_inference_resul use crate::http_server::deprecated_endpoints::w2l::list_user_w2l_templates::list_user_w2l_templates_handler; use crate::http_server::endpoints::tts::list_user_tts_inference_results::list_user_tts_inference_results_handler; use crate::http_server::endpoints::tts::list_user_tts_models::list_user_tts_models_handler; +use crate::http_server::endpoints::users::change_password_handler::change_password_handler; use crate::http_server::endpoints::users::create_account_handler::create_account_handler; +use crate::http_server::endpoints::users::edit_email_handler::edit_email_handler; use crate::http_server::endpoints::users::edit_profile_handler::edit_profile_handler; use crate::http_server::endpoints::users::edit_username_handler::edit_username_handler; use crate::http_server::endpoints::users::get_profile_handler::get_profile_handler; @@ -99,6 +101,16 @@ pub fn add_user_routes (app: App) -> App // NB(bt): Modern user profile routes .service(web::scope("/v1/user") + .service( + web::resource("/change_password") + .route(web::post().to(change_password_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service( + web::resource("/edit_email") + .route(web::post().to(edit_email_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) .service( web::resource("/edit_username") .route(web::post().to(edit_username_handler)) diff --git a/crates/service/web/storyteller_web/src/http_server/session/mod.rs b/crates/service/web/storyteller_web/src/http_server/session/mod.rs index aa920b6746..3d3e43d88a 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/session/mod.rs @@ -1,3 +1,2 @@ -pub mod http; pub mod lookup; pub mod session_checker; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/session/session_checker.rs b/crates/service/web/storyteller_web/src/http_server/session/session_checker.rs index 88667f74e9..4f540fa52b 100644 --- a/crates/service/web/storyteller_web/src/http_server/session/session_checker.rs +++ b/crates/service/web/storyteller_web/src/http_server/session/session_checker.rs @@ -3,19 +3,18 @@ #![forbid(unused_mut)] #![forbid(unused_variables)] +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::HttpRequest; -use log::warn; -use sqlx::pool::PoolConnection; -use sqlx::{Executor, MySql, MySqlPool}; - use errors::AnyhowResult; +use log::warn; use mysql_queries::queries::users::user_sessions::get_user_session_by_token::{get_user_session_by_token, get_user_session_by_token_pooled_connection, SessionUserRecord}; use mysql_queries::queries::users::user_sessions::get_user_session_by_token_light::{get_user_session_by_token_light, SessionRecord}; use mysql_queries::queries::users::user_subscriptions::list_active_user_subscriptions::list_active_user_subscriptions; use redis_caching::redis_ttl_cache::{RedisTtlCache, RedisTtlCacheConnection}; use redis_common::redis_cache_keys::RedisCacheKeys; +use sqlx::pool::PoolConnection; +use sqlx::{Executor, MySql, MySqlPool}; -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; use crate::http_server::session::lookup::user_session_extended::{UserSessionExtended, UserSessionPreferences, UserSessionPremiumPlanInfo, UserSessionRoleAndPermissions, UserSessionSubscriptionPlan, UserSessionUserDetails}; use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; diff --git a/crates/service/web/storyteller_web/src/main.rs b/crates/service/web/storyteller_web/src/main.rs index 96aec69013..10ca1d3d7a 100644 --- a/crates/service/web/storyteller_web/src/main.rs +++ b/crates/service/web/storyteller_web/src/main.rs @@ -21,17 +21,7 @@ use std::sync::Arc; use std::time::Duration; use actix::Actor; -use actix_multipart::form::MultipartFormConfig; -use actix_web::middleware::{DefaultHeaders, Logger}; -use actix_web::{middleware, web, App, HttpServer}; -use anyhow::anyhow; -use chrono::Utc; -use elasticsearch::http::transport::Transport; -use elasticsearch::Elasticsearch; -use log::info; -use redis::Client; -use tokio::runtime::Runtime; - +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_cors_configs::cors::build_cors_config; use actix_cors_configs::shared_array_buffer_cors::shared_array_buffer_cors; use actix_helpers::middleware::banned_cidr_filter::banned_cidr_filter::BannedCidrFilter; @@ -44,24 +34,34 @@ use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoint_filte use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoints::disabled_endpoints::DisabledEndpoints; use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoints::exact_match_disabled_endpoints::ExactMatchDisabledEndpoints; use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoints::prefix_disabled_endpoints::PrefixDisabledEndpoints; +use actix_multipart::form::MultipartFormConfig; +use actix_web::middleware::{DefaultHeaders, Logger}; +use actix_web::{middleware, web, App, HttpServer}; +use anyhow::anyhow; use billing_artcraft_component::utils::artcraft_stripe_config::ArtcraftStripeConfig; use billing_component::stripe::stripe_config::{FullUrlOrPath, StripeCheckoutConfigs, StripeConfig, StripeCustomerPortalConfigs, StripeSecrets}; use billing_component::stripe::traits::internal_product_to_stripe_lookup::InternalProductToStripeLookup; use billing_component::stripe::traits::internal_subscription_product_lookup::InternalSubscriptionProductLookup; use bootstrap::bootstrap::{bootstrap, BootstrapArgs}; +use chrono::Utc; use cloud_storage::bucket_client::BucketClient; use component_traits::traits::internal_user_lookup::InternalUserLookup; use config::common_env::CommonEnv; use config::shared_constants::DEFAULT_RUST_LOG; +use elasticsearch::http::transport::Transport; +use elasticsearch::Elasticsearch; use email_sender::smtp_email_sender::SmtpEmailSender; use errors::AnyhowResult; use fal_client::creds::fal_api_key::FalApiKey; +use log::info; use memory_caching::arc_ttl_sieve::ArcTtlSieve; use memory_caching::single_item_ttl_cache::SingleItemTtlCache; use mysql_queries::mediators::badge_granter::BadgeGranter; use mysql_queries::mediators::firehose_publisher::FirehosePublisher; +use redis::Client; use redis_caching::redis_ttl_cache::RedisTtlCache; use reusable_types::server_environment::ServerEnvironment; +use tokio::runtime::Runtime; use url_config::third_party_url_redirector::ThirdPartyUrlRedirector; use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge; @@ -75,7 +75,6 @@ use crate::configs::static_api_tokens::StaticApiTokenSet; use crate::http_server::cookies::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager; use crate::http_server::middleware::pushback_filter_middleware::PushbackFilter; use crate::http_server::routes::add_routes::add_routes; -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; use crate::http_server::session::session_checker::SessionChecker; use crate::http_server::web_utils::handle_multipart_error::handle_multipart_error; use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator; diff --git a/crates/service/web/storyteller_web/src/state/server_state.rs b/crates/service/web/storyteller_web/src/state/server_state.rs index 3e2c4be366..7ad97c2d7b 100644 --- a/crates/service/web/storyteller_web/src/state/server_state.rs +++ b/crates/service/web/storyteller_web/src/state/server_state.rs @@ -1,13 +1,11 @@ -use chrono::{DateTime, Utc}; -use elasticsearch::Elasticsearch; -use redis::Client; -use sqlx::MySqlPool; - +use actix_artcraft::sessions::http_user_session_manager::HttpUserSessionManager; use actix_helpers::middleware::banned_cidr_filter::banned_cidr_set::BannedCidrSet; use actix_helpers::middleware::banned_ip_filter::ip_ban_list::ip_ban_list::IpBanList; use billing_artcraft_component::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; use billing_component::stripe::stripe_config::StripeConfig; +use chrono::{DateTime, Utc}; use cloud_storage::bucket_client::BucketClient; +use elasticsearch::Elasticsearch; use email_sender::smtp_email_sender::SmtpEmailSender; use fal_client::creds::fal_api_key::FalApiKey; use memory_caching::arc_ttl_sieve::ArcTtlSieve; @@ -19,8 +17,10 @@ use mysql_queries::queries::media_files::list::list_featured_media_files::Featur use mysql_queries::queries::model_categories::list_categories_query_builder::CategoryList; use mysql_queries::queries::tts::tts_inference_jobs::get_pending_tts_inference_job_count::TtsQueueLengthResult; use mysql_queries::queries::w2l::w2l_templates::list_w2l_templates::W2lTemplateRecordForList; +use redis::Client; use redis_caching::redis_ttl_cache::RedisTtlCache; use reusable_types::server_environment::ServerEnvironment; +use sqlx::MySqlPool; use url_config::third_party_url_redirector::ThirdPartyUrlRedirector; use crate::configs::app_startup::username_set::UsernameSet; @@ -32,7 +32,6 @@ use crate::http_server::endpoints::media_files::list::list_featured_media_files_ use crate::http_server::endpoints::stats::result_transformer::CacheableQueueStats; use crate::http_server::endpoints::tts::list_tts_models::TtsModelRecordForResponse; use crate::http_server::endpoints::voice_conversion::list_voice_conversion_models_handler::VoiceConversionModel; -use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; use crate::http_server::session::session_checker::SessionChecker; use crate::http_server::web_utils::redis_rate_limiter::RedisRateLimiter; use crate::http_server::web_utils::scoped_temp_dir_creator::ScopedTempDirCreator; diff --git a/crates/service/web/storyteller_web/src/util/email_to_gravatar.rs b/crates/service/web/storyteller_web/src/util/email_to_gravatar.rs deleted file mode 100644 index 7d227e00b2..0000000000 --- a/crates/service/web/storyteller_web/src/util/email_to_gravatar.rs +++ /dev/null @@ -1,34 +0,0 @@ -use md5::{Digest, Md5}; - -pub fn email_to_gravatar(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::util::email_to_gravatar::email_to_gravatar; - - #[test] - fn test_gravatar() { - // Email 1 - assert_eq!(email_to_gravatar("example@example.com"), "23463b99b62a72f26ed677cc556c44e8".to_string()); - assert_eq!(email_to_gravatar("EXAMPLE@EXAMPLE.COM"), "23463b99b62a72f26ed677cc556c44e8".to_string()); - assert_eq!(email_to_gravatar(" example@example.com \n "), "23463b99b62a72f26ed677cc556c44e8".to_string()); - - // Email 2 - assert_eq!(email_to_gravatar("echelon@gmail.com"), "37e7e18f19d8f654a1ace405c846a9de".to_string()); - assert_eq!(email_to_gravatar("ECHELON@GMAIL.COM"), "37e7e18f19d8f654a1ace405c846a9de".to_string()); - assert_eq!(email_to_gravatar(" ECHELON@GMAIL.COM "), "37e7e18f19d8f654a1ace405c846a9de".to_string()); - - // Misc - assert_eq!(email_to_gravatar(""), "d41d8cd98f00b204e9800998ecf8427e".to_string()); - assert_eq!(email_to_gravatar(" "), "d41d8cd98f00b204e9800998ecf8427e".to_string()); - } -} \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/util/mod.rs b/crates/service/web/storyteller_web/src/util/mod.rs index 6ca3449ae4..3dbe31fbd1 100644 --- a/crates/service/web/storyteller_web/src/util/mod.rs +++ b/crates/service/web/storyteller_web/src/util/mod.rs @@ -5,10 +5,8 @@ pub mod canonicalize_email_for_users_table; pub mod check_creator_tokens; pub mod cleaners; pub mod delete_role_disambiguation; -pub mod email_to_gravatar; pub mod encrypted_sort_id; pub mod enroll_in_studio; -pub mod generate_random_username; pub mod http_download_url_to_bytes; pub mod http_download_url_to_tempfile; pub mod lookup;