Compare commits

...
Author SHA1 Message Date
Tanish Manku 53bd51c91f 2D: Big changes to prevent react renders whenever possible 2026-02-20 15:04:05 -08:00
Tanish Manku 4f2b63ac31 Map history stack and corresponding edits 2026-02-16 13:02:01 -08:00
BFlat 658b15d6aa long prompt bug fix + generic billing pop up (#1040)
- fixed the bug where long prompts overflow the lighbox modal causing
buttons to be pushed off the screen and non interactable.
- added a generic billing popup if user doesn't have a subscription plan
2026-02-16 16:27:38 +07:00
Brandon Thomas 60461e9208 db migration tools 2026-02-15 11:45:31 +07:00
bflatastic a8b4541000 verify reset end flow update 2 2026-02-14 13:12:46 +07:00
Brandon Thomas 7cc1c0209f database migration tool (primarily record deletion) (#1039)
We need to remove irrelevant FakeYou records that do not impact the
ArtCraft product

- 87M irrelevant media file records (no relation to ArtCraft)
- 105M jobs records (which we purge occasionally anyway)
- lots of other files: audit logs, old/irrelevant stripe event logs,
feed data, etc. etc.
2026-02-14 08:06:54 +07:00
bflatastic 43c82e0a82 verify reset end flow update 2026-02-13 11:49:28 +07:00
Brandon Thomas ac0e95c265 fix reset password link 2026-02-13 10:15:08 +07:00
Brandon Thomas 885fc0a983 website password reset redirect 2026-02-13 10:11:16 +07:00
Brandon Thomas 97215d3a3e bump artcraft to 0.6.0 2026-02-13 09:47:57 +07:00
Brandon Thomas 68912622d9 write artcraft desktop artifacts to same package 2026-02-13 09:45:49 +07:00
Brandon Thomas 03ab6b8f11 inactive ci workflows 2026-02-13 09:41:55 +07:00
Brandon Thomas 314dd37ce8 codegen db 2026-02-13 09:25:08 +07:00
Brandon Thomas 74571c9f47 fix password reset (backend) (#1037) 2026-02-13 09:14:55 +07:00
bflatastic e70df9a72f password update flow 2026-02-13 07:53:13 +07:00
bflatastic 9982d49f99 password reset api 2026-02-13 07:01:40 +07:00
Brandon Thomas fbc2eaceb4 setup instructions 2026-02-13 06:03:01 +07:00
bflatastic 28c0703b60 update landing cta texts 2026-02-12 11:19:15 +07:00
Brandon Thomas bbe3a837de versions and windows instructions 2026-02-12 04:33:45 +07:00
Brandon Thomas dd8b6338a6 Vendor symphonia (#1036)
Two reasons for this:

- super brittle when pinned to non-main commit
e1a70095c4409478c1ded499fb8dd4afa540fb90

- I was recently stuck on a flight without good wifi and setting up a
new devenv. Cargo was totally unable to download the remote github
dependency, but was fine downloading the entire artcraft repo. Seems bad
to link to other repos that Cargo can't quickly pull on low bandwidth.

We'll need to slim this down later and eventually get back on main
branch.
2026-02-12 04:27:37 +07:00
BFlat 8e4ad8b3a1 bulk delete function (#1034)
- in the library/gallery modal, added a checkbox on top left of each
item
- when checkbox is checked it enters toiggle select mode which allows
multi selecting and bulk deleting objects via one delete button on the
bottom right of the modal.
- added trash can icon to lightbox delete button
2026-02-10 23:52:39 +07:00
Brandon Thomas 98969a660e development documentation 2026-02-09 00:28:57 -05:00
bflatastic d71ab61760 already logged in subscription update 2026-02-05 23:31:33 +07:00
Brandon Thomas 0c6e742fe1 image pricing (#1028)
unfortunately free image only users are costing us a lot of money when
totally unmetered. I want to give free quota, but we have to be
sensible.
2026-02-03 00:46:21 -05:00
bflatastic 2dd6dc9d50 website: update onboarding flow 2026-02-03 00:05:04 +07:00
BFlat 2800c84699 Feature/purchase onboarding flow (#1027) 2026-02-02 23:45:26 +07:00
Brandon Thomas 9ea44e4aa6 update signup source 2026-02-01 01:50:02 -05:00
Brandon Thomas 57e4887cbf checkout flow with account creation; endpoints to support (#1025)
New endpoints: 

- checkout flow with integrated account creation (!!!)
- change password
- change email

New updates: 

- Session info handler returns onboarding state
2026-02-01 00:10:08 -05:00
Brandon Thomas 7661bf158a machinima bit 2026-01-31 20:25:29 -05:00
Brandon Thomas 67a509dff4 better title (going to try posting to HN) 2026-01-31 19:53:37 -05:00
Brandon Thomas ca3794d646 pricing update 2026-01-31 11:47:02 -05:00
Brandon Thomas c833f119a5 pricing table is placeholder 2026-01-31 11:34:41 -05:00
Tanish Manku 5b82b722b1 Reword the history stack deletion message (#1024) 2026-01-31 01:23:00 -08:00
Tanish Manku c4298cc02b Make sure to run autocentering when the base image changes (#1023) 2026-01-31 01:22:52 -08:00
Brandon Thomas 9a9b9bd12d update copy 2026-01-30 16:28:45 -05:00
Brandon Thomas 5944aa2b4d more copy 2026-01-30 16:20:23 -05:00
Brandon Thomas c8533c8081 blog post 3 (#1022)
I have so much to say, but I hate writing it down. Augh. 

Communication is so important, though.
2026-01-30 15:51:13 -05:00
Brandon Thomas c5e65d6e0f blog post update (#1021) 2026-01-30 14:59:45 -05:00
bflatastic 72f308f8ba website: media reference image thumbnail will open its corresponding media page 2026-01-31 02:01:36 +07:00
bflatastic e4d137e56d website: media reference image href links to source media file 2026-01-31 01:26:56 +07:00
Brandon Thomas e09c2e6682 blog post draft (#1020) 2026-01-30 11:08:19 -05:00
bflatastic 009c9832ca image to 3d model selector toggle 2026-01-30 14:42:45 +07:00
Brandon Thomas 2394375e11 Remove Google Auth (for now) - this will be difficult for the app (#1019) 2026-01-28 14:50:54 -05:00
bflatastic 7885df257f website: news blog md file image and gif support with sizing, also to work in-app 2026-01-29 00:34:34 +07:00
bflatastic 94e4c3fc01 website: news blog md file video support 2026-01-29 00:20:41 +07:00
BFlat 6ee54bdcb9 Feature/google login (#1018)
google login button update
2026-01-27 23:32:04 +07:00
bflatastic a31138535e website: added discord cta on cancel and success pages, pricing footnote cleanup 2026-01-27 21:42:17 +07:00
Brandon Thomas 8899b3dcb5 routes 2026-01-27 06:16:42 -05:00
Brandon Thomas 69d9b2013a fix various pricing page language 2026-01-27 06:13:54 -05:00
Brandon Thomas ff56a96816 fix routes 2026-01-27 05:53:27 -05:00
Brandon Thomas c5e1843b91 fix routes 2026-01-27 05:48:53 -05:00
Brandon Thomas 9a433ff42a missing routes 2026-01-27 05:39:51 -05:00
BFlatandBrandon Thomas 1469b4fcff Feature/flow update (#1007)
- added login / sign up and pricing flow
- download page in nav and signup and pricing links/buttons

currently missing features:
- forget password function
- google social login

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-01-27 05:34:34 -05:00
Brandon Thomas b1965a6019 "what year is it?!" 2026-01-27 04:45:31 -05:00
Brandon Thomas 29b1468e1a website copy improvements (#1017) 2026-01-27 03:17:41 -05:00
Brandon Thomas 1008b6f69a press kit - more videos (#1016)
if you're reading this please help us market this. idk what I'm doing. I
just want to build stuff and make movies.
2026-01-27 02:20:02 -05:00
BFlat f797d6f769 Feature/website press kit page (#1012)
website press kit page for downloadable media
2026-01-27 00:06:31 -05:00
Brandon Thomas 3cb2c31aa7 rename 2d and 3d pages; change icon (#1015) 2026-01-27 00:05:00 -05:00
Brandon Thomas 6f64086834 fix how spz files are manually uploaded in tauri (#1014) 2026-01-26 19:15:01 -05:00
bflatastic 170ee9b069 generation count tooltip for non subbed users 2026-01-26 13:18:18 +07:00
Brandon Thomas 75ab5887f7 dedicated spz upload handler (#1011) 2026-01-25 20:13:37 -05:00
Brandon Thomas 8e67654b9d nano banana text-to-image pane fixes (#1010)
fix reference images, resolution controls, etc.
2026-01-25 19:05:20 -05:00
Tanish Manku 76172e3956 Merge 2D and Edit/Inpainting pages (#981)
Needs more testing, do not merge.
2026-01-24 21:14:07 -05:00
Brandon Thomas affc19abc5 Update social media links in README
Added LinkedIn badge to the README and removed old social media section.
2026-01-24 21:13:13 -05:00
Brandon Thomas 15a04d5f52 Enhance README with video and badges
Added video and social media links to README.
2026-01-24 19:52:46 -05:00
Brandon Thomas 49a5f67dc8 Change video source in README.md
Updated video source link in README.
2026-01-24 11:30:05 -05:00
Brandon Thomas 0db259b64a video tag 2026-01-24 11:18:45 -05:00
Brandon Thomas 363e3fee37 github link for video 2026-01-24 11:11:22 -05:00
Brandon Thomas 5cead36b18 add video to readme 2026-01-24 11:06:02 -05:00
Brandon Thomas f435e9fb7e cleanup dead shared_service_components crate 2026-01-24 09:31:53 -05:00
Brandon Thomas 58ebe1f836 cleanup container binary includes (#1006) 2026-01-24 09:26:47 -05:00
509 changed files with 59110 additions and 3325 deletions
+2 -2
View File
@@ -233,8 +233,8 @@ jobs:
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "Artcraft v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseName: "ArtCraft v__VERSION__"
releaseBody: "See the assets below to download this version and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
@@ -99,9 +99,9 @@ jobs:
SQLX_OFFLINE: true
with:
projectPath: ./crates/desktop/artcraft
tagName: artcraft-windows-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "Artcraft Windows v__VERSION__"
releaseBody: "Windows build - See the assets to download this version and install."
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
releaseName: "ArtCraft v__VERSION__"
releaseBody: "Production build of ArtCraft. See the assets below to download and install."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
+3
View File
@@ -32,6 +32,9 @@ script/production_database_secret_backup.sh
script/production_database_secret_connect.sh
script/socat_dev_database_connect.sh
# Python virtual envs (named by convention)
venv/
# Scratch data
scratch/
test_data/temp/
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n email_is_synthetic = false,\n is_without_password = false,\n is_temporary = false,\n password_hash = ?,\n ip_address_last_update = ?,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "9cd3a581f6857e5c1db68a103f47eba9714ee4b5eefc8ba02fc49a4b3a19310e"
}
@@ -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"
}
Generated
+927 -104
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -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",
@@ -80,7 +81,6 @@ members = [
"crates/lib/migration",
"crates/lib/password",
"crates/lib/server_environment",
"crates/lib/shared_service_components",
"crates/lib/sqlx_mysql_helpers",
"crates/lib/storyteller_root",
"crates/lib/subprocess_common",
@@ -90,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",
@@ -135,6 +136,23 @@ members = [
"crates/vendor/fal-rs/generate",
"crates/vendor/limitation",
"crates/vendor/tauri-plugin-http",
"crates/vendor/Symphonia/symphonia",
"crates/vendor/Symphonia/symphonia-bundle-flac",
"crates/vendor/Symphonia/symphonia-bundle-mp3",
"crates/vendor/Symphonia/symphonia-codec-aac",
"crates/vendor/Symphonia/symphonia-codec-alac",
"crates/vendor/Symphonia/symphonia-codec-opus",
"crates/vendor/Symphonia/symphonia-codec-pcm",
"crates/vendor/Symphonia/symphonia-codec-vorbis",
"crates/vendor/Symphonia/symphonia-codec-wavpack",
"crates/vendor/Symphonia/symphonia-core",
"crates/vendor/Symphonia/symphonia-format-isomp4",
"crates/vendor/Symphonia/symphonia-format-mkv",
"crates/vendor/Symphonia/symphonia-format-ogg",
"crates/vendor/Symphonia/symphonia-format-wav",
"crates/vendor/Symphonia/symphonia-metadata",
"crates/vendor/Symphonia/symphonia-play",
"crates/vendor/Symphonia/symphonia-utils-xiph",
# NB(bt,2025-05-25): Too many failures on Mac. Disable for now.
#"crates/vendor/candle/candle-core",
#"crates/vendor/candle/candle-datasets",
@@ -186,13 +204,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" }
@@ -219,6 +241,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" }
@@ -247,12 +270,13 @@ futures = { version = "0.3.31" }
hex-literal = "1.0.0"
indexmap = "2.9.0"
log = "0.4.27"
md-5 = "0.10.6" # insecure but used for gravatar
once_cell = "1.21.3"
r2d2 = "0.8.10"
rand = "0.9.1"
redis = { version = "1.0.2", features = ["r2d2", "tokio-rustls-comp", "tokio-comp", "cluster-async", "aio"] }
regex = "1.12.2"
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls" ] }
reqwest = { version = "0.12.23", default-features = false, features = [ "charset", "http2", "system-proxy", "json", "cookies", "rustls-tls", "gzip" ] }
reqwest-websocket = { version = "0.5.0" }
serde = { version = "1.0.219", features = ["derive"] }
serde_derive = "1.0.219"
+14 -7
View File
@@ -1,3 +1,17 @@
<p align="center">
<video src="https://github.com/user-attachments/assets/b4e24c27-d87d-4fd1-8599-dc0d0b8af48d" width="100%" autoplay="true" loop controls>
</p>
<p align="center">The IDE for artists.</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Discord" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&label=discord&color=ffffff&logo=discord&logoColor=ffffff" /></a>
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/youtube/channel/subscribers/UCdjY4VG0ntoGwFsKZO4sVWA?style=for-the-badge&logo=YouTube" /></a>
<a href="https://x.com/intent/follow?screen_name=get_artcraft"><img alt="X" src="https://img.shields.io/twitter/follow/get_artcraft?style=for-the-badge&label=follow&logo=x&logoColor=ffffff&color=ffffff" /></a>
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin--0A66C2?style=for-the-badge"></a>
</p>
---
ArtCraft
========
ArtCraft is the IDE for interactive AI image and video creation.
@@ -66,10 +80,3 @@ those providers, for example: OpenArt, FreePik, etc.
- [license](./LICENSE.md)
- [roadmap](./ROADMAP.md)
## Social
[![discord](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/discord.svg)](https://discord.gg/artcraft)
[![youtube](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/youtube.svg)](https://www.youtube.com/@OfficialArtCraftStudios)
[![x](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/linkedin.svg)](https://www.linkedin.com/company/artcraft-ai)
[![x](https://raw.githubusercontent.com/CLorant/readme-social-icons/main/large/colored/twitter-x.svg)](https://x.com/get_artcraft)
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN
was_eagerly_provisioned;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN
was_eagerly_provisioned BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN
is_temporary;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN
is_temporary BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN
email_is_synthetic;
@@ -0,0 +1,7 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN
email_is_synthetic BOOLEAN NOT NULL DEFAULT false;
@@ -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.
+45
View File
@@ -0,0 +1,45 @@
Dev Setup
=========
# ArtCraft
ArtCraft is a Rust / Tauri app.
To set up the ArtCraft development environment, install the following:
1. [Install Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html).
2. [Install npm](https://nodejs.org/en/download) or [nvm](https://github.com/nvm-sh/nvm). (Node version `v24.13.0` works at time of writing.)
3. [Install nx](https://nx.dev/docs/getting-started/installation). (Nx version `v22.4.5` works at time of writing.)
4. [Install Tauri CLI](https://v2.tauri.app/reference/cli/). (Version `tauri-cli 2.10.0` works at time of writing.)
An easy way to get started with running the app in development is to run the two commands (in separate terminals):
**Mac and Linux Development**
```bash
# Run the frontend dev server
./script/artcraft/unix_frontend_dev.sh
# Run the Tauri Rust application
./script/artcraft/unix_rust_dev.sh
```
**Windows Development**
```powershell
# Run the frontend dev server
.\script\artcraft\windows_frontend_dev.ps1
# Run the Tauri Rust application
.\script\artcraft\windows_rust_dev.ps1
```
# ArtCraft Server
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
See [dev_setup_server.md](./dev_setup_server.md) for instructions.
+105
View File
@@ -0,0 +1,105 @@
# ArtCraft Server
ArtCraft's server is a Rust / Actix app called `storyteller-web`.
You don't need to run this to develop the ArtCraft application, but it can be useful to spin up
a development instance for adding new server functions or as your own private local copy.
`storyteller-web` can function entirely within the development environment, and ArtCraft can be
pointed to a local instance of the server.
**TODO**: Concisely describe setting up server components. Note: we have
previous docs in `old/` that may still be relevant, though they're perhaps
slightly out of date.
## Server Setup (Mac)
### Install and Migrate MySQL Database
```bash
# For now, the tooling doesn't support MySQL 9.6 as it was just released in January 2026.
# This will probably change in the future to no longer need to specify the 8.* series.
brew install mysql@8.4
# Try to connect
mysql -uroot
# If connection fails, start service, then try again.
brew services start mysql
mysql -uroot
# If that fails, reboot and try connecting again.
```
Create the `storyteller` database table. Connect to the database:
```bash
sudo mysql -u root
# If it needs a password, use argument -p and when prompted use "root" as the typical default password
```
Then run this:
```mysql
use mysql;
CREATE DATABASE storyteller;
CREATE USER 'storyteller'@'localhost' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON storyteller.* TO 'storyteller'@'localhost';
```
Then verify access with `./script/dev_mysql_connect.sh`
```bash
cargo install diesel_cli \
--no-default-features \
--features mysql,sqlite
```
If this fails,
```bash
brew install zstd
brew install openssl
```
Possibly,
```bash
export RUSTFLAGS="-L$(brew --prefix zstd)/lib -L$(brew --prefix openssl)/lib"
```
In older Macs, there [have been some issues with Diesel CLI](https://github.com/diesel-rs/diesel/issues/2605)
that require a few extra dependencies to be installed:
```bash
# If you're on Mac and the above command didn't work, run the following and then retry:
brew install libpq
```
You're ready to run the migrations:
```bash
diesel migration run
```
You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this
error if you see it in isolation. It doesn't impact the migrations whatsoever.
Finally, you'll need the sqlx CLI tool to run codegen.
You likely won't need this now, but if you change any queries, this will be necessary:
```bash
cargo install sqlx-cli --features rustls,mysql,sqlite
```
### Install Redis (Mac)
```bash
# Install Redis
brew install redis
# Start the service:
brew services start redis
```
@@ -7,4 +7,5 @@ pub mod media_file;
pub mod prompts;
pub mod stripe_artcraft;
pub mod subscriptions;
pub mod users;
pub mod utils;
@@ -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<ArtcraftSubscriptionSlug>,
pub cadence: Option<PlanBillingCadence>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)]
pub enum PlanBillingCadence {
#[serde(rename = "monthly")]
Monthly,
#[serde(rename = "yearly")]
Yearly,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupResponse {
pub success: bool,
/// The checkout session URL.
pub stripe_checkout_redirect_url: String,
/// If a user account was created, these are the details.
pub generated_user: Option<UserDetails>,
/// If a session was created, these are the details.
pub session: Option<SessionDetails>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UserDetails {
/// The generated username.
pub username: String,
/// The generated display name.
pub display_name: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SessionDetails {
/// A signed session that can be sent as a header, bypassing cookies.
/// This is useful for API clients that don't support cookies or Google
/// browsers killing cross-domain cookies.
pub signed_session: String,
}
@@ -1,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;
@@ -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,
}
@@ -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,
}
@@ -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,
}
@@ -0,0 +1,4 @@
pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod session_info;
@@ -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<UserFeatureFlag>,
// Premium plans:
#[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")]
pub fakeyou_plan: FakeYouPlan,
#[deprecated(note = "DO NOT USE. This was never used and is 100% meaningless.")]
pub storyteller_stream_plan: StorytellerStreamPlan,
// Usage permissions:
pub can_use_tts: bool,
pub can_use_w2l: bool,
pub can_delete_own_tts_results: bool,
pub can_delete_own_w2l_results: bool,
pub can_delete_own_account: bool,
// Contribution permissions:
pub can_upload_tts_models: bool,
pub can_upload_w2l_templates: bool,
pub can_delete_own_tts_models: bool,
pub can_delete_own_w2l_templates: bool,
// Moderation permissions:
pub can_approve_w2l_templates: bool,
pub can_edit_other_users_profiles: bool,
pub can_edit_other_users_tts_models: bool,
pub can_edit_other_users_w2l_templates: bool,
pub can_delete_other_users_tts_models: bool,
pub can_delete_other_users_tts_results: bool,
pub can_delete_other_users_w2l_templates: bool,
pub can_delete_other_users_w2l_results: bool,
pub can_ban_users: bool,
pub can_delete_users: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SessionOnboardingState {
/// If true, the user hasn't set their email.
pub email_not_set: bool,
/// If true, the user hasn't confirmed their email.
pub email_not_confirmed: bool,
/// If true, the user hasn't set their password.
pub password_not_set: bool,
/// If true, the user hasn't set their username (the username they have is random).
pub username_not_customized: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SessionInfoSuccessResponse {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUserInfo>,
}
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4_edit_image::{bytedance_seedream_v4_edit_image, BytedanceSeedreamV4EditImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -53,6 +54,23 @@ pub enum EnqueueBytedanceSeedreamV4EditImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.03 per image.
let unit_cost = 3;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4EditImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4_edit_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4EditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4p5_edit_image::{bytedance_seedream_v4p5_edit_image, BytedanceSeedreamV4p5EditImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -52,6 +53,23 @@ pub enum EnqueueBytedanceSeedreamV4p5EditImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4p5EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.04 per image.
let unit_cost = 4;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4p5EditImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4p5_edit_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4p5EditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gemini_25_flash_image::edit::gemini_25_flash_image_edit;
use fal::prelude::fal_ai::gemini_25_flash_image::edit::Gemini25FlashImageEditInput;
use fal::webhook::WebhookResponse;
@@ -49,6 +50,20 @@ pub enum Gemini25FlashEditAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for Gemini25FlashEditArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.039 per image. For $1.00, you can run this model 25 times.
match self.num_images {
Gemini25FlashEditNumImages::One => 4,
Gemini25FlashEditNumImages::Two => 8,
Gemini25FlashEditNumImages::Three => 12,
Gemini25FlashEditNumImages::Four => 16,
}
}
}
pub async fn enqueue_gemini_25_flash_edit_webhook<R: IntoUrl>(
args: Gemini25FlashEditArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -2,6 +2,7 @@ use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gpt_image_1::edit_image::byok::{gpt_edit_image, GptEditImageRequest};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -44,6 +45,31 @@ pub enum GptEditImageNumImages{
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptEditImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptEditImageQuality::Auto => 17,
GptEditImageQuality::Low => 1,
GptEditImageQuality::Medium => 4,
GptEditImageQuality::High => 17,
};
let cost = match self.num_images {
GptEditImageNumImages::One => base_cost,
GptEditImageNumImages::Two => base_cost * 2,
GptEditImageNumImages::Three => base_cost * 3,
GptEditImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_edit_image_webhook<V: IntoUrl>(
args: GptEditImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gpt_image::gpt_image_1p5_image_edit::{gpt_image_1p5_image_edit, GptImage1p5ImageEditInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -69,6 +70,34 @@ pub enum EnqueueGptImage1p5EditImageOutputFormat {
Webp,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1p5EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost different amounts based on the number of images, quality, and size.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
let use_quality = self.quality.unwrap_or(EnqueueGptImage1p5EditImageQuality::Medium);
let use_size = self.image_size.unwrap_or(EnqueueGptImage1p5EditImageSize::Square);
let base_cost = match (use_quality, use_size) {
(EnqueueGptImage1p5EditImageQuality::Low, EnqueueGptImage1p5EditImageSize::Square) => 1,
(EnqueueGptImage1p5EditImageQuality::Low, _) => 1,
(EnqueueGptImage1p5EditImageQuality::Medium, EnqueueGptImage1p5EditImageSize::Square) => 3,
(EnqueueGptImage1p5EditImageQuality::Medium, _) => 5,
(EnqueueGptImage1p5EditImageQuality::High, EnqueueGptImage1p5EditImageSize::Square) => 13,
(EnqueueGptImage1p5EditImageQuality::High, _) => 20,
};
let cost = match self.num_images {
EnqueueGptImage1p5EditImageNumImages::One => base_cost,
EnqueueGptImage1p5EditImageNumImages::Two => base_cost * 2,
EnqueueGptImage1p5EditImageNumImages::Three => base_cost * 3,
EnqueueGptImage1p5EditImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1p5_image_edit_webhook<R: IntoUrl>(
args: EnqueueGptImage1p5EditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::nano_banana_pro::nano_banana_pro_image_edit::{nano_banana_pro_image_edit, NanoBananaProImageEditInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -56,6 +57,30 @@ pub enum EnqueueNanoBananaProEditImageAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueNanoBananaProEditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.15 per image.
// For $1.00, you can run this model 7 times.
// 4K outputs will be charged at double the standard rate.
// If web search is used, an additional $0.015 will be charged.
// Note: Pricing may change in the future.
let cost = match self.resolution {
None => 15,
Some(EnqueueNanoBananaProEditImageResolution::OneK) => 15,
Some(EnqueueNanoBananaProEditImageResolution::TwoK) => 15,
Some(EnqueueNanoBananaProEditImageResolution::FourK) => 30,
};
let cost = match self.num_images {
EnqueueNanoBananaProEditImageNumImages::One => cost,
EnqueueNanoBananaProEditImageNumImages::Two => cost * 2,
EnqueueNanoBananaProEditImageNumImages::Three => cost * 3,
EnqueueNanoBananaProEditImageNumImages::Four => cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_nano_banana_pro_image_edit_webhook<R: IntoUrl>(
args: EnqueueNanoBananaProEditImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::qwen_image_edit::{qwen_image_edit, QwenImageEditInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -45,6 +46,23 @@ pub enum QwenImageEditSize {
//Custom { width: u32, height: u32 }, // TODO
}
impl <U: IntoUrl, R: IntoUrl> FalRequestCostCalculator for QwenImageEditArgs<'_, U, R> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.03 per megapixel. -- Ugh, not calculating that
let base_cost = 3;
let cost = match self.num_images {
None => base_cost,
Some(QwenImageEditNumImages::One) => base_cost,
Some(QwenImageEditNumImages::Two) => base_cost * 2,
Some(QwenImageEditNumImages::Three) => base_cost * 3,
Some(QwenImageEditNumImages::Four) => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_qwen_image_edit_webhook<U: IntoUrl, R: IntoUrl>(
args: QwenImageEditArgs<'_, U, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::flux_pro::v1_1::{v1_1, FluxProPlusTextToImageInput, ImageSizeProperty};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -33,6 +34,22 @@ pub enum FluxPro11NumImages {
Four,
}
impl <U: IntoUrl> FalRequestCostCalculator for FluxPro11Args<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.04 per megapixel. -- NB: What? Not going to calculate that.
let base_cost = 6;
let cost = match self.num_images {
FluxPro11NumImages::One => base_cost,
FluxPro11NumImages::Two => base_cost * 2,
FluxPro11NumImages::Three => base_cost * 3,
FluxPro11NumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_flux_pro_11_text_to_image_webhook<U: IntoUrl>(
args: FluxPro11Args<'_, U>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::flux_pro::v1_1_ultra::{v1_1_ultra, AspectRatioProperty, FluxProUltraTextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -35,6 +36,22 @@ pub enum FluxPro11UltraNumImages {
Four,
}
impl <U: IntoUrl> FalRequestCostCalculator for FluxPro11UltraArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.06 per image.
let base_cost = 6;
let cost = match self.num_images {
FluxPro11UltraNumImages::One => base_cost,
FluxPro11UltraNumImages::Two => base_cost * 2,
FluxPro11UltraNumImages::Three => base_cost * 3,
FluxPro11UltraNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_flux_pro_11_ultra_text_to_image_webhook<U: IntoUrl>(
args: FluxPro11UltraArgs<'_, U>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -2,6 +2,7 @@ use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::prelude::fal_ai::gpt_image_1::edit_image::byok::{gpt_text_to_image, GptTextToImageRequest};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -43,6 +44,31 @@ pub enum GptTextToImageNumImages{
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptTextToImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptTextToImageQuality::Auto => 17,
GptTextToImageQuality::Low => 1,
GptTextToImageQuality::Medium => 4,
GptTextToImageQuality::High => 17,
};
let cost = match self.num_images {
GptTextToImageNumImages::One => base_cost,
GptTextToImageNumImages::Two => base_cost * 2,
GptTextToImageNumImages::Three => base_cost * 3,
GptTextToImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4_text_to_image::BytedanceSeedreamV4TextToImageInput;
use fal::prelude::fal_ai::bytedance::seedream::bytedance_seedream_v4_text_to_image::bytedance_seedream_v4_text_to_image;
use fal::webhook::WebhookResponse;
@@ -53,6 +54,24 @@ pub enum EnqueueBytedanceSeedreamV4TextToImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Copied from edit image version
// Your request will cost $0.03 per image.
let unit_cost = 3;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4TextToImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4_text_to_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4TextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -4,6 +4,8 @@ use crate::error::fal_error_plus::FalErrorPlus;
use fal::endpoints::fal_ai::bytedance::seedream::bytedance_seedream_v4p5_text_to_image::{bytedance_seedream_v4p5_text_to_image, BytedanceSeedreamV4p5TextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::webhook::image::edit::enqueue_bytedance_seedream_v4p5_edit_image_webhook::{EnqueueBytedanceSeedreamV4p5EditImageArgs, EnqueueBytedanceSeedreamV4p5EditImageNumImages};
pub struct EnqueueBytedanceSeedreamV4p5TextToImageArgs<'a, R: IntoUrl> {
// Request required
@@ -51,6 +53,24 @@ pub enum EnqueueBytedanceSeedreamV4p5TextToImageSize {
Auto4k,
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueBytedanceSeedreamV4p5TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// NB: Copied from edit image costs
// Your request will cost $0.04 per image.
let unit_cost = 4;
let cost = match self.num_images {
None => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::One) => unit_cost,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Two) => unit_cost * 2,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Three) => unit_cost * 3,
Some(EnqueueBytedanceSeedreamV4p5TextToImageNumImages::Four) => unit_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_bytedance_seedream_v4p5_text_to_image_webhook<R: IntoUrl>(
args: EnqueueBytedanceSeedreamV4p5TextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -5,6 +5,8 @@ use fal::prelude::fal_ai::gemini_25_flash_image::text_to_image::gemini_25_flash_
use fal::prelude::fal_ai::gemini_25_flash_image::text_to_image::Gemini25FlashTextToImageInput;
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{Gemini25FlashEditArgs, Gemini25FlashEditNumImages};
pub struct Gemini25FlashTextToImageArgs<'a, R: IntoUrl> {
// Request required
@@ -46,6 +48,21 @@ pub enum Gemini25FlashTextToImageAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for Gemini25FlashTextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// NB: This was copied from the image-to-image case.
// Your request will cost $0.039 per image. For $1.00, you can run this model 25 times.
match self.num_images {
Gemini25FlashTextToImageNumImages::One => 4,
Gemini25FlashTextToImageNumImages::Two => 8,
Gemini25FlashTextToImageNumImages::Three => 12,
Gemini25FlashTextToImageNumImages::Four => 16,
}
}
}
pub async fn enqueue_gemini_25_flash_text_to_image_webhook<R: IntoUrl>(
args: Gemini25FlashTextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -1,6 +1,7 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use fal::endpoints::fal_ai::gpt_image::gpt_image_1p5_text_to_image::{gpt_image_1p5_text_to_image, GptImage1p5TextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
@@ -61,6 +62,34 @@ pub enum EnqueueGptImage1p5TextToImageOutputFormat {
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1p5TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// NB: copied from edit image case - should be correct, but not verified.
// Your request will cost different amounts based on the number of images, quality, and size.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
let use_quality = self.quality.unwrap_or(EnqueueGptImage1p5TextToImageQuality::Medium);
let use_size = self.image_size.unwrap_or(EnqueueGptImage1p5TextToImageSize::Square);
let base_cost = match (use_quality, use_size) {
(EnqueueGptImage1p5TextToImageQuality::Low, EnqueueGptImage1p5TextToImageSize::Square) => 1,
(EnqueueGptImage1p5TextToImageQuality::Low, _) => 1,
(EnqueueGptImage1p5TextToImageQuality::Medium, EnqueueGptImage1p5TextToImageSize::Square) => 3,
(EnqueueGptImage1p5TextToImageQuality::Medium, _) => 5,
(EnqueueGptImage1p5TextToImageQuality::High, EnqueueGptImage1p5TextToImageSize::Square) => 13,
(EnqueueGptImage1p5TextToImageQuality::High, _) => 20,
};
let cost = match self.num_images {
EnqueueGptImage1p5TextToImageNumImages::One => base_cost,
EnqueueGptImage1p5TextToImageNumImages::Two => base_cost * 2,
EnqueueGptImage1p5TextToImageNumImages::Three => base_cost * 3,
EnqueueGptImage1p5TextToImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1p5_text_to_image_webhook<R: IntoUrl>(
args: EnqueueGptImage1p5TextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
@@ -4,6 +4,8 @@ use crate::error::fal_error_plus::FalErrorPlus;
use fal::endpoints::fal_ai::nano_banana_pro::nano_banana_pro_text_to_image::{nano_banana_pro_text_to_image, NanoBananaProTextToImageInput};
use fal::webhook::WebhookResponse;
use reqwest::IntoUrl;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::webhook::image::edit::enqueue_nano_banana_pro_edit_image_webhook::{EnqueueNanoBananaProEditImageArgs, EnqueueNanoBananaProEditImageNumImages, EnqueueNanoBananaProEditImageResolution};
pub struct EnqueueNanoBananaProTextToImageArgs<'a, R: IntoUrl> {
// Request required
@@ -53,6 +55,31 @@ pub enum EnqueueNanoBananaProTextToImageAspectRatio {
NineBySixteen, // NB: No NineByTwentyOne ?
}
impl <U: IntoUrl> FalRequestCostCalculator for EnqueueNanoBananaProTextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Your request will cost $0.15 per image.
// For $1.00, you can run this model 7 times.
// 4K outputs will be charged at double the standard rate.
// If web search is used, an additional $0.015 will be charged.
// Note: Pricing may change in the future.
let cost = match self.resolution {
None => 15,
Some(EnqueueNanoBananaProTextToImageResolution::OneK) => 15,
Some(EnqueueNanoBananaProTextToImageResolution::TwoK) => 15,
Some(EnqueueNanoBananaProTextToImageResolution::FourK) => 30,
};
let cost = match self.num_images {
EnqueueNanoBananaProTextToImageNumImages::One => cost,
EnqueueNanoBananaProTextToImageNumImages::Two => cost * 2,
EnqueueNanoBananaProTextToImageNumImages::Three => cost * 3,
EnqueueNanoBananaProTextToImageNumImages::Four => cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_nano_banana_pro_text_to_image_webhook<R: IntoUrl>(
args: EnqueueNanoBananaProTextToImageArgs<'_, R>
) -> Result<WebhookResponse, FalErrorPlus> {
+2 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "artcraft"
version = "0.5.0"
version = "0.6.0"
description = "Artcraft"
authors = ["you"]
license = ""
@@ -54,6 +54,7 @@ bytes = "1.10.1"
chrono.workspace = true
directories = "6.0.0"
dirs = "6.0.0"
discord-rich-presence = "1.1.0"
futures = "0.3"
image = "0.25.5"
indexmap.workspace = true
@@ -77,10 +78,6 @@ url.workspace = true
# NB: We need to vendor this in so that we can access the cookies inside via the private API
tauri-plugin-http = { path = "../../vendor/tauri-plugin-http" }
# NB: The version of the `discord-rich-presence` crate (0.2.5) last published has non-Send/Sync error
# types and can't do threading. We need to wait for a new version to unpin from Git.
discord-rich-presence = { git = "https://github.com/vionya/discord-rich-presence" }
tauri-plugin-opener = "2"
# # Transparent title bar on MacOS
@@ -1,8 +1,8 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common aspect ratios.
/// This is a comprehensive list of common aspect ratios you can specify when enqueuing a generation.
/// Not every model will support every aspect ratio.
/// In the case a model doesn't support the aspect ratio, pick the nearest option.
/// In the case a model doesn't support the aspect ratio, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonAspectRatio {
@@ -1 +1,2 @@
pub mod aspect_ratio;
pub mod aspect_ratio;
pub mod resolution;
@@ -0,0 +1,17 @@
use serde_derive::{Deserialize, Serialize};
/// This is a comprehensive list of common resolutions you can specify when enqueuing a generation.
/// Not every model will support every resolution
/// In the case a model doesn't support the resolution, gracefully pick the nearest option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonResolution {
/// Models: Nano Banana Pro
OneK,
/// Models: Nano Banana Pro
TwoK,
/// Models: (None)
ThreeK,
/// Models: Nano Banana Pro
FourK,
}
@@ -0,0 +1 @@
pub mod resolution_to_artcraft_nano_banana_pro;
@@ -0,0 +1,12 @@
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenImageResolution;
pub fn resolution_to_artcraft_nano_banana_pro(resolution: CommonResolution) -> NanoBananaProMultiFunctionImageGenImageResolution {
match resolution {
CommonResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
CommonResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
// NB: ThreeK doesn't exist in Nano Banana Pro, use FourK as nearest higher resolution
CommonResolution::ThreeK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
CommonResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
}
}
@@ -0,0 +1,2 @@
pub mod common_resolution;
pub mod convert;
@@ -62,8 +62,7 @@ pub async fn handle_artcraft_gpt_image_1p5_text_to_image(
prompt: request.prompt.clone(),
image_size: aspect_ratio,
num_images,
// Not provided for text-to-image
image_media_tokens: None,
image_media_tokens: request.image_media_tokens.clone(),
mask_image_token: None,
input_fidelity: None,
// Not provided
@@ -1,5 +1,7 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana_pro::aspect_ratio_to_artcraft_nano_banana_pro;
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use crate::core::api_adapters::resolution::convert::resolution_to_artcraft_nano_banana_pro::resolution_to_artcraft_nano_banana_pro;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageResolution, EditImageSize};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
@@ -61,18 +63,12 @@ pub async fn handle_artcraft_nano_banana_pro_text_to_image(
};
let aspect_ratio = get_aspect_ratio(request);
let resolution = request.image_resolution
.map(|image_resolution| match image_resolution {
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
});
let resolution = get_resolution(request);
let request = NanoBananaProMultiFunctionImageGenRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_media_tokens: None,
image_media_tokens: request.image_media_tokens.clone(),
num_images,
resolution,
aspect_ratio,
@@ -126,3 +122,20 @@ fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option<NanoBananaPro
None
}
fn get_resolution(request: &EnqueueTextToImageRequest) -> Option<NanoBananaProMultiFunctionImageGenImageResolution> {
if let Some(common_resolution) = request.common_resolution {
return Some(resolution_to_artcraft_nano_banana_pro(common_resolution));
}
if let Some(resolution) = request.image_resolution {
// Deprecated resolution
return Some(match resolution {
TextToImageResolution::OneK => NanoBananaProMultiFunctionImageGenImageResolution::OneK,
TextToImageResolution::TwoK => NanoBananaProMultiFunctionImageGenImageResolution::TwoK,
TextToImageResolution::FourK => NanoBananaProMultiFunctionImageGenImageResolution::FourK,
})
}
None
}
@@ -1,3 +1,5 @@
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::api_adapters::resolution::common_resolution::CommonResolution;
use crate::core::commands::enqueue::common::notify_frontend_of_errors::notify_frontend_of_errors;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError, MissingCredentialsReason};
use crate::core::commands::enqueue::image_edit::image_edit_models::image_edit_model_to_model_type;
@@ -16,6 +18,8 @@ use crate::core::events::generation_events::common::{GenerationAction, Generatio
use crate::core::events::generation_events::generation_enqueue_success_event::GenerationEnqueueSuccessEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::{Provider, ProviderPriorityStore};
use crate::core::state::task_database::TaskDatabase;
@@ -34,9 +38,6 @@ use serde::{Deserialize, Serialize};
use sqlite_tasks::queries::create_task::{create_task, CreateTaskArgs};
use tauri::{AppHandle, State};
use tokens::tokens::media_files::MediaFileToken;
use crate::core::api_adapters::aspect_ratio::common_aspect_ratio::CommonAspectRatio;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
/// This is used in the Tauri command bridge.
/// Don't change the serializations without coordinating with the frontend.
@@ -88,15 +89,20 @@ pub struct EnqueueTextToImageRequest {
/// Text prompt for the image generation. Required.
pub prompt: Option<String>,
/// Aspect ratio.
#[deprecated(note="use common_aspect_ratio")]
pub aspect_ratio: Option<TextToImageSize>,
/// New field for aspect ratio.
/// Not all models support each of these aspect ratios, but we can choose or interpolate sensibly.
pub common_aspect_ratio: Option<CommonAspectRatio>,
/// New field for resolution.
/// Not all models support each of these resolutions, but we can choose or interpolate sensibly.
pub common_resolution: Option<CommonResolution>,
/// Aspect ratio.
#[deprecated(note="use common_aspect_ratio")]
pub aspect_ratio: Option<TextToImageSize>,
/// Aspect ratio.
#[deprecated(note="use common_resolution")]
pub image_resolution: Option<TextToImageResolution>,
/// The number of images to generate.
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.5.0";
pub const ARTCRAFT_VERSION: &str = "0.6.0";
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.5.0",
"version": "0.6.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.5.0",
"version": "0.6.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
+40
View File
@@ -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 <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "actix_artcraft"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
actix_helpers.workspace = true
cookies.workspace = true
enums.workspace = true
errors.workspace = true
http_headers.workspace = true
tokens.workspace = true
# External
actix-http.workspace = true
actix-service.workspace = true
actix-web.workspace = true
log.workspace = true
anyhow.workspace = true
[dev-dependencies]
# (None yet)
+21
View File
@@ -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;
@@ -3,14 +3,7 @@ use actix_web::HttpRequest;
use enums::by_table::users::user_signup_source::UserSignupSource;
use log::warn;
const ARTCRAFT : &str = "artcraft";
const FAKEYOU : &str = "fakeyou";
const STORYTELLER : &str = "storyteller";
/// This is for the users table `maybe_source` field which is populated during account creation.
/// While we could back this with an enum, there may be a motivation to use this VARCHAR(255) field
/// for more robust payloads and user tracking in the future.
pub fn get_request_signup_source(http_request: &HttpRequest) -> Option<&'static str> {
pub fn get_request_signup_source_enum(http_request: &HttpRequest) -> Option<UserSignupSource> {
// NB: "Origin" vs "Referrer"
//
// Basically:
@@ -27,12 +20,14 @@ pub fn get_request_signup_source(http_request: &HttpRequest) -> Option<&'static
match maybe_origin {
Ok(Some(uri)) => {
if let Some(host) = uri.host() {
if host.contains(ARTCRAFT) {
return Some(ARTCRAFT);
} else if host.contains(STORYTELLER) {
return Some(STORYTELLER);
} else if host.contains(FAKEYOU) {
return Some(FAKEYOU);
if host.contains("getartcraft.com") {
return Some(UserSignupSource::ArtCraftGetWeb);
} else if host.contains("artcraft.ai") {
return Some(UserSignupSource::ArtCraftAiWeb);
} else if host.contains("storyteller") {
return Some(UserSignupSource::Storyteller);
} else if host.contains("fakeyou") {
return Some(UserSignupSource::FakeYou);
}
}
}
@@ -50,79 +45,13 @@ pub fn get_request_signup_source(http_request: &HttpRequest) -> Option<&'static
None
}
pub fn get_request_signup_source_enum(http_request: &HttpRequest) -> Option<UserSignupSource> {
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_str {
use super::*;
use crate::http_server::requests::get_request_signup_source::get_request_signup_source;
#[test]
fn artcraft_dot_ai() {
let request = TestRequest::get()
.insert_header(("origin", "https://artcraft.ai"))
.to_http_request();
assert_eq!(get_request_signup_source(&request), Some("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(&request), Some("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(&request), Some("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(&request), Some("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(&request), Some("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(&request), Some("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(&request), Some("storyteller"));
}
}
mod origin_header_enum {
use super::*;
use crate::http_server::requests::get_request_signup_source::get_request_signup_source_enum;
use crate::requests::get_request_signup_source_enum::get_request_signup_source_enum;
use enums::by_table::users::user_signup_source::UserSignupSource;
#[test]
@@ -130,7 +59,7 @@ mod tests {
let request = TestRequest::get()
.insert_header(("origin", "https://artcraft.ai"))
.to_http_request();
assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft));
assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraftAiWeb));
}
#[test]
@@ -138,7 +67,7 @@ mod tests {
let request = TestRequest::get()
.insert_header(("origin", "https://getartcraft.com"))
.to_http_request();
assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraft));
assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraftGetWeb));
}
#[test]
@@ -146,7 +75,7 @@ mod tests {
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));
assert_eq!(get_request_signup_source_enum(&request), Some(UserSignupSource::ArtCraftGetWeb));
}
#[test]
@@ -0,0 +1 @@
pub mod get_request_signup_source_enum;
@@ -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() {
@@ -1,3 +1,3 @@
pub mod http_user_session_manager;
pub mod http_user_session_payload;
pub mod payload_signer;
pub mod payload_signer;
@@ -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();
+2 -1
View File
@@ -29,7 +29,8 @@ log = { workspace = true }
#symphonia = { version = "0.5.2", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "vorbis", "wav"] }
# Opus support still hasn't landed in Symphonia, so we have to use an older version (~0.5) off-master.
# Opus is the browser's default (only) recording codec: https://blog.addpipe.com/audio-constraints-getusermedia/
symphonia = { git = "https://github.com/pdeljanov/Symphonia.git", rev = "e1a70095c4409478c1ded499fb8dd4afa540fb90", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
# symphonia = { git = "https://github.com/pdeljanov/Symphonia.git", rev = "e1a70095c4409478c1ded499fb8dd4afa540fb90", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
symphonia = { path = "../../../vendor/Symphonia/symphonia", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] }
[dev-dependencies]
# None yet
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_children_terms.txt");
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_children_terms.txt");
pub (crate) fn contains_children_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS: &str = include_str!("../../../../../../includes/binary_includes/dictionary_potential_minor_terms.txt");
const TERMS: &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_potential_minor_terms.txt");
pub (crate) fn contains_potential_minor_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY: Lazy<HashSet<String>> = Lazy::new(|| {
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_racist_terms.txt");
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_racist_terms.txt");
pub (crate) fn contains_racist_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use once_cell::sync::Lazy;
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/dictionary_sex_terms.txt");
const TERMS : &str = include_str!("../../../../../../includes/binary_includes/safety/dictionary_sex_terms.txt");
pub (crate) fn contains_sex_keyword(prompt_tokens: &[String]) -> bool {
static TERM_DICTIONARY : Lazy<HashSet<String>> = Lazy::new(|| {
TERMS.lines()
@@ -1,33 +0,0 @@
[package]
name = "shared_service_components"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[lib]
name = "shared_service_components"
path = "src/lib.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration = { workspace = true }
# Internal
openai_sora_client = { workspace = true }
errors = { workspace = true }
# External
anyhow = { workspace = true }
log = { workspace = true }
r2d2_redis = "0.14.0"
reqwest = { workspace = true, features = ["multipart", "gzip"] }
[dev-dependencies]
# None yet
@@ -1,4 +0,0 @@
//! This library contains code shared between `storyteller-web` and jobs.
pub mod sora_redis_credentials;
pub mod sora_redis_sentinel_refresh;
@@ -1,33 +0,0 @@
use crate::sora_redis_credentials::keys::{BEARER_SUBKEY, COOKIE_SUBKEY, SENTINEL_SUBKEY, SORA_SECRET_REDIS_KEY};
use anyhow::anyhow;
use errors::AnyhowResult;
use openai_sora_client::creds::sora_credential_builder::SoraCredentialBuilder;
use openai_sora_client::creds::sora_credential_set::SoraCredentialSet;
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use std::collections::HashMap;
pub fn get_sora_credentials_from_redis(
redis: &mut PooledConnection<RedisConnectionManager>
) -> AnyhowResult<SoraCredentialSet> {
let values : HashMap<String, String> = redis.hgetall(SORA_SECRET_REDIS_KEY)
.map_err(|e| anyhow!("Failed to get Sora credentials from Redis: {}", e))?;
let bearer = values.get(BEARER_SUBKEY);
let cookie = values.get(COOKIE_SUBKEY);
let sentinel = values.get(SENTINEL_SUBKEY);
match (bearer, cookie, sentinel) {
(Some(b), Some(c), Some(s)) => {
let creds = SoraCredentialBuilder::new()
.with_jwt_bearer_token(&b)
.with_cookies(&c)
.with_sora_sentinel(&s)
.build()?;
Ok(creds)
}
_ => Err(anyhow!("redis sora credential values not present")),
}
}
@@ -1,29 +0,0 @@
/// Redis key
pub (crate) const SORA_SECRET_REDIS_KEY : &str = "sora_secret";
/// HKEY field for the bearer token
pub (crate) const BEARER_SUBKEY : &str = "bearer";
/// HKEY field for the cookie
pub (crate) const COOKIE_SUBKEY : &str = "cookie";
/// HKEY field for the sentinel
pub (crate) const SENTINEL_SUBKEY : &str = "sentinel";
/// Which subkey to write
#[derive(Clone,Copy,Debug)]
pub enum RedisSoraCredentialSubkey {
Bearer,
Cookie,
Sentinel,
}
impl RedisSoraCredentialSubkey {
pub (crate) fn to_str(&self) -> &'static str {
match self {
RedisSoraCredentialSubkey::Bearer => BEARER_SUBKEY,
RedisSoraCredentialSubkey::Cookie => COOKIE_SUBKEY,
RedisSoraCredentialSubkey::Sentinel => SENTINEL_SUBKEY,
}
}
}
@@ -1,3 +0,0 @@
pub mod keys;
pub mod set_sora_credential_field_in_redis;
pub mod get_sora_credentials_from_redis;
@@ -1,18 +0,0 @@
use anyhow::anyhow;
use r2d2_redis::r2d2::PooledConnection;
use r2d2_redis::redis::Commands;
use r2d2_redis::RedisConnectionManager;
use errors::AnyhowResult;
use crate::sora_redis_credentials::keys::{RedisSoraCredentialSubkey, SORA_SECRET_REDIS_KEY};
pub fn set_sora_credential_field_in_redis(
redis: &mut PooledConnection<RedisConnectionManager>,
subkey: RedisSoraCredentialSubkey,
value: &str,
) -> AnyhowResult<()> {
redis.hset(SORA_SECRET_REDIS_KEY, subkey.to_str(), value)
.map_err(|e| anyhow!("Failed to set Sora credentials in Redis: {}", e))?;
Ok(())
}
@@ -1 +0,0 @@
pub mod refresh;
@@ -1,19 +0,0 @@
/// Send a POST request to env variable SORA_SENTINEL_REFRESH_URL
use errors::AnyhowResult;
use reqwest::Client;
use std::env;
const SORA_SENTINEL_REFRESH_URL: &str = "http://oai-token-server/refresh_token";
/// Refresh the Sora sentinel
/// The response body does _not_ contain the new sentinel
/// We should read it again from redis or generate a new one here
/// For now we refresh in the server handler
pub async fn refresh_sentinel() -> AnyhowResult<String> {
let client = Client::new();
let url = env::var("SORA_SENTINEL_REFRESH_URL").unwrap_or(SORA_SENTINEL_REFRESH_URL.to_string());
let response = client.post(url).send().await?;
let body = response.text().await?;
Ok(body)
}
+1 -1
View File
@@ -8,7 +8,7 @@
#![forbid(unused_variables)]
#![forbid(warnings)]
pub (crate) const BANNED_SLURS : &str = include_str!("../../../../includes/binary_includes/banned_slurs.txt");
pub (crate) const BANNED_SLURS : &str = include_str!("../../../../includes/binary_includes/safety/banned_slurs.txt");
pub mod check_for_slurs;
pub mod latin_alphabet;
+29
View File
@@ -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)
@@ -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());
}
}
@@ -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("."));
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod email_to_gravatar_hash;
pub mod generate_random_synthetic_email;
pub mod validate_email_address_format;
@@ -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"));
}
}
+20
View File
@@ -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;
@@ -0,0 +1,3 @@
/// Username may be up to this many characters, but not more.
pub const USERNAME_MAX_LENGTH: usize = 16;
@@ -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<Vec<&'static str>> = Lazy::new(|| {
iterate_trimmed_lines_without_comments(NOUNS.lines())
@@ -120,7 +120,7 @@ fn maybe_random_safe_digit() -> Option<u32> {
#[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() {
+2
View File
@@ -0,0 +1,2 @@
pub mod generate_random_username;
pub mod constants;
@@ -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?;
@@ -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?;
@@ -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<UserSignupSource>,
/// Comma separated string of feature flags.
pub maybe_feature_flags: Option<&'a str>,
}
pub async fn create_account_from_stripe_checkout(
args: CreateAccountFromStripeCheckoutArgs<'_>,
transactor: Transactor<'_, '_>,
) -> Result<UserToken, CreateAccountError>
{
let result= create_account_generic(
GenericCreateAccountArgs {
maybe_signup_method: Some(UserSignupMethod::StripeCheckout),
ip_address: args.ip_address,
maybe_feature_flags: args.maybe_feature_flags,
maybe_source: args.maybe_source,
// These are synthetic user accounts that are being eagerly created
// If the user abandons the onboarding flow, they can be deleted later.
is_temporary: true,
was_eagerly_provisioned: true,
// Stripe checkout users have randomly generated usernames
username: args.username,
display_name: args.display_name,
username_is_generated: true,
username_is_not_customized: true,
// Stripe checkout users have NO REAL EMAIL ADDRESS yet.
// Stripe will tell us what their email address is once
// they complete the checkout (or the user can update it,
// and their write wins.)
email_address: args.email_address,
email_gravatar_hash: args.email_gravatar_hash,
email_confirmed_by_google: false,
email_is_synthetic: true,
// SSO accounts start without a password
password_hash: SSO_PASSWORD,
is_without_password: true,
// NB: This is just for testing.
maybe_user_token: None,
},
transactor,
).await?;
Ok(result.user_token)
}
@@ -23,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<UserSignupSource>,
@@ -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,
@@ -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;
@@ -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<MySql>,
}
pub async fn maybe_update_email_from_synthetic_value(
args: MaybeUpdateEmailFromSyntheticValueArgs<'_>,
) -> AnyhowResult<()> {
let result = sqlx::query_as!(
UserEmailCheckRecordRaw,
r#"
SELECT
email_address,
email_is_synthetic
FROM users
WHERE token = ?
LIMIT 1
"#,
args.user_token.as_str(),
)
.fetch_one(&mut **args.mysql_connection)
.await;
let maybe_record = transform_optional_result(result)?;
let record = match maybe_record {
Some(record) => record,
None => return Err(anyhow!("User not found for token: {}", args.user_token)),
};
let record : UserEmailCheckRecord = record.into();
if !record.email_is_synthetic {
return Ok(());
}
let record_email = record.email_address.to_lowercase();
// NB: So we don't get the format de-synced, let's just trust the boolean flag.
//if !record_email.contains("synthetic") {
// return Ok(());
//}
// Normally we don't have much business logic in this crate, but let's be extra careful since we don't have a change log.
if record_email.contains("gmail")
|| record_email.contains("aol.com")
|| record_email.contains("apple")
|| record_email.contains("att.net")
|| record_email.contains("comcast")
|| record_email.contains("earthlink")
|| record_email.contains("hotmail")
|| record_email.contains("icloud")
|| record_email.contains("live.com")
|| record_email.contains("mac.com")
|| record_email.contains("mail.ru")
|| record_email.contains("me.com")
|| record_email.contains("outlook")
|| record_email.contains("proton")
|| record_email.contains("rocketmail")
|| record_email.contains("sbcglobal")
|| record_email.contains("yahoo")
|| record_email.contains("ymail")
|| record_email.contains("zoho")
{
// NB: Just to super extra careful, in case we change the synthetic email format.
// This obviously won't catch everything.
return Ok(());
}
let update_email = args.email_address.to_lowercase();
let query = sqlx::query!(
r#"
UPDATE users
SET
email_address = ?,
email_gravatar_hash = ?,
email_confirmed = false,
email_confirmed_by_google = false,
email_is_synthetic = false,
version = version + 1
WHERE
token = ?
LIMIT 1
"#,
update_email,
args.email_gravatar_hash,
args.user_token.as_str(),
);
args.mysql_connection.execute(query).await?;
Ok(())
}
struct UserEmailCheckRecordRaw {
email_address: String,
email_is_synthetic: i8,
}
struct UserEmailCheckRecord {
email_address: String,
email_is_synthetic: bool,
}
impl From<UserEmailCheckRecordRaw> for UserEmailCheckRecord {
fn from(raw: UserEmailCheckRecordRaw) -> Self {
Self {
email_address: raw.email_address,
email_is_synthetic: i8_to_bool(raw.email_is_synthetic),
}
}
}

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