Switch to Trolley for Modrinth Payments (#727)

* most of trolley

* Switch to trolley for payments

* run prepare

* fix clippy

* fix more

* Fix most tests + bitflags

* Update src/auth/flows.rs

Co-authored-by: Jackson Kruger <jak.kruger@gmail.com>

* Finish trolley

* run prep for merge

* Update src/queue/payouts.rs

Co-authored-by: Jackson Kruger <jak.kruger@gmail.com>

---------

Co-authored-by: Jackson Kruger <jak.kruger@gmail.com>
This commit is contained in:
Geometrically 2023-10-11 15:55:01 -07:00 committed by GitHub
parent f1ff88f452
commit 07ecd13554
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1719 additions and 1461 deletions

6
.env
View File

@ -49,9 +49,9 @@ WHITELISTED_MODPACK_DOMAINS='["cdn.modrinth.com", "github.com", "raw.githubuserc
ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com", "127.0.0.1"]' ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com", "127.0.0.1"]'
PAYPAL_API_URL=https://api-m.sandbox.paypal.com/v1/ TROLLEY_ACCESS_KEY=none
PAYPAL_CLIENT_ID=none TROLLEY_SECRET_KEY=none
PAYPAL_CLIENT_SECRET=none TROLLEY_WEBHOOK_SIGNATURE=none
GITHUB_CLIENT_ID=none GITHUB_CLIENT_ID=none
GITHUB_CLIENT_SECRET=none GITHUB_CLIENT_SECRET=none

4
.idea/labrinth.iml generated
View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="CPP_MODULE" version="4"> <module type="CPP_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/ahash-7eac216debe9f38a/out" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/target/debug/build/ahash-7eac216debe9f38a/out" isTestSource="false" />
@ -144,6 +145,7 @@
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/v_htmlescape-2dcc3d8195b1decd/out" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/target/debug/build/v_htmlescape-2dcc3d8195b1decd/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/value-bag-61e3e358d251e1a5/out" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/target/debug/build/value-bag-61e3e358d251e1a5/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/whoami-7d9ebcbbb2371986/out" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/target/debug/build/whoami-7d9ebcbbb2371986/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />

1338
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -51,7 +51,7 @@ sha1 = { version = "0.6.1", features = ["std"] }
sha2 = "0.9.9" sha2 = "0.9.9"
hmac = "0.11.0" hmac = "0.11.0"
argon2 = { version = "0.5.0", features = ["std"] } argon2 = { version = "0.5.0", features = ["std"] }
bitflags = "1.3.2" bitflags = "2.4.0"
hex = "0.4.3" hex = "0.4.3"
zxcvbn = "2.2.2" zxcvbn = "2.2.2"
totp-rs = { version = "5.0.2", features = ["gen_secret"] } totp-rs = { version = "5.0.2", features = ["gen_secret"] }
@ -96,7 +96,7 @@ maxminddb = "0.23.0"
flate2 = "1.0.25" flate2 = "1.0.25"
tar = "0.4.38" tar = "0.4.38"
sentry = { version = "0.31.5", features = ["profiling"] } sentry = { version = "0.31.5" }
sentry-actix = "0.31.5" sentry-actix = "0.31.5"
image = "0.24.6" image = "0.24.6"

View File

@ -0,0 +1,16 @@
ALTER TABLE users
ADD COLUMN trolley_id text NULL,
ADD COLUMN trolley_account_status text NULL,
DROP COLUMN midas_expires,
DROP COLUMN is_overdue,
DROP COLUMN stripe_customer_id,
DROP COLUMN payout_wallet,
DROP COLUMN payout_wallet_type,
DROP COLUMN payout_address;
ALTER TABLE historical_payouts
ADD COLUMN batch_id text NULL,
ADD COLUMN payment_id text NULL;
UPDATE historical_payouts
SET status = 'processed'

View File

@ -144,25 +144,25 @@
}, },
"query": "\n UPDATE versions\n SET status = $1, date_published = $2\n WHERE (id = $3)\n " "query": "\n UPDATE versions\n SET status = $1, date_published = $2\n WHERE (id = $3)\n "
}, },
"03284fe5b045e2cf93f160863c4d121439382b348b728fffb5ac588dee980731": { "04128dd06489004e0d0305bfd0f4ca5ee4b4a6b9f610de6e1b9ef9c8543cc025": {
"describe": { "describe": {
"columns": [ "columns": [
{ {
"name": "exists", "name": "id",
"ordinal": 0, "ordinal": 0,
"type_info": "Bool" "type_info": "Int8"
} }
], ],
"nullable": [ "nullable": [
null false
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Int8" "Text"
] ]
} }
}, },
"query": "\n SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email IS NULL)\n " "query": "SELECT id FROM users WHERE trolley_id = $1"
}, },
"04345d9c23430267f755b1420520df91bd403524fd60ba1a94e3a239ea70cae7": { "04345d9c23430267f755b1420520df91bd403524fd60ba1a94e3a239ea70cae7": {
"describe": { "describe": {
@ -1609,6 +1609,18 @@
}, },
"query": "\n UPDATE mods\n SET follows = follows - 1\n WHERE id = $1\n " "query": "\n UPDATE mods\n SET follows = follows - 1\n WHERE id = $1\n "
}, },
"382753714620109f2ad1a4cacbb6f699732db321a2dcb1f9d83e57332e32357d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "\n UPDATE users\n SET trolley_account_status = NULL, trolley_id = NULL\n WHERE id = $1\n "
},
"38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca": { "38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca": {
"describe": { "describe": {
"columns": [ "columns": [
@ -1756,6 +1768,20 @@
}, },
"query": "\n UPDATE mods\n SET title = $1\n WHERE (id = $2)\n " "query": "\n UPDATE mods\n SET title = $1\n WHERE (id = $2)\n "
}, },
"3f525e05e94ccaea4abc059d54f48011517bd8997df0c7d42cc4caae62194ae6": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
}
},
"query": "\n UPDATE users\n SET trolley_id = $1, trolley_account_status = $2\n WHERE id = $3\n "
},
"40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08": { "40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -1986,20 +2012,6 @@
}, },
"query": "\n INSERT INTO mods_donations (\n joining_mod_id, joining_platform_id, url\n )\n SELECT * FROM UNNEST($1::bigint[], $2::int[], $3::varchar[])\n " "query": "\n INSERT INTO mods_donations (\n joining_mod_id, joining_platform_id, url\n )\n SELECT * FROM UNNEST($1::bigint[], $2::int[], $3::varchar[])\n "
}, },
"4778d2f5994fda2f978fa53e0840c1a9a2582ef0434a5ff7f21706f1dc4edcf4": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Numeric",
"Varchar"
]
}
},
"query": "\n INSERT INTO historical_payouts (user_id, amount, status)\n VALUES ($1, $2, $3)\n "
},
"4838777a8ef4371f4f5bb4f4f038bb6d041455f0849a3972a5418d75165ae9c7": { "4838777a8ef4371f4f5bb4f4f038bb6d041455f0849a3972a5418d75165ae9c7": {
"describe": { "describe": {
"columns": [ "columns": [
@ -2032,6 +2044,22 @@
}, },
"query": "\n SELECT d.dependency_id, COALESCE(vd.mod_id, 0) mod_id, d.mod_dependency_id\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n LEFT JOIN versions vd ON d.dependency_id = vd.id\n WHERE v.mod_id = $1\n " "query": "\n SELECT d.dependency_id, COALESCE(vd.mod_id, 0) mod_id, d.mod_dependency_id\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n LEFT JOIN versions vd ON d.dependency_id = vd.id\n WHERE v.mod_id = $1\n "
}, },
"48dc011567c5d50ee734fd0bdd1f5d07d9ef066c485a9b34495120c9947489f8": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8",
"Numeric",
"Varchar",
"Text",
"Text"
]
}
},
"query": "\n INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)\n VALUES ($1, $2, $3, $4, $5)\n "
},
"49813a96f007216072d69468aae705d73d5b85dcdd64a22060009b12d947ed5a": { "49813a96f007216072d69468aae705d73d5b85dcdd64a22060009b12d947ed5a": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -2706,153 +2734,6 @@
}, },
"query": "\n UPDATE mods_gallery\n SET ordering = $2\n WHERE id = $1\n " "query": "\n UPDATE mods_gallery\n SET ordering = $2\n WHERE id = $1\n "
}, },
"60a251aea1efbc7d9357255e520f0ac13f3697fecb84b1e9edd5d9ea61fe0cb0": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "email",
"ordinal": 2,
"type_info": "Varchar"
},
{
"name": "avatar_url",
"ordinal": 3,
"type_info": "Varchar"
},
{
"name": "username",
"ordinal": 4,
"type_info": "Varchar"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Varchar"
},
{
"name": "created",
"ordinal": 6,
"type_info": "Timestamptz"
},
{
"name": "role",
"ordinal": 7,
"type_info": "Varchar"
},
{
"name": "badges",
"ordinal": 8,
"type_info": "Int8"
},
{
"name": "balance",
"ordinal": 9,
"type_info": "Numeric"
},
{
"name": "payout_wallet",
"ordinal": 10,
"type_info": "Varchar"
},
{
"name": "payout_wallet_type",
"ordinal": 11,
"type_info": "Varchar"
},
{
"name": "payout_address",
"ordinal": 12,
"type_info": "Varchar"
},
{
"name": "github_id",
"ordinal": 13,
"type_info": "Int8"
},
{
"name": "discord_id",
"ordinal": 14,
"type_info": "Int8"
},
{
"name": "gitlab_id",
"ordinal": 15,
"type_info": "Int8"
},
{
"name": "google_id",
"ordinal": 16,
"type_info": "Varchar"
},
{
"name": "steam_id",
"ordinal": 17,
"type_info": "Int8"
},
{
"name": "microsoft_id",
"ordinal": 18,
"type_info": "Varchar"
},
{
"name": "email_verified",
"ordinal": 19,
"type_info": "Bool"
},
{
"name": "password",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "totp_secret",
"ordinal": 21,
"type_info": "Varchar"
}
],
"nullable": [
false,
true,
true,
true,
false,
true,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true
],
"parameters": {
"Left": [
"Int8Array",
"TextArray"
]
}
},
"query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance, payout_wallet, payout_wallet_type, payout_address,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n "
},
"61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27": { "61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -3953,20 +3834,20 @@
}, },
"query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering, v.mod_id \n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE\n WHERE v.id = $1\n " "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering, v.mod_id \n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE\n WHERE v.id = $1\n "
}, },
"8cbd74dad7a21128d99fd32b430c2e0427480f910e1f125ff56b893c67a6e8a4": { "8f45a48700b8836f4ba8626b25b7be7f838d35d260430a46817729d9787e2013": {
"describe": { "describe": {
"columns": [], "columns": [],
"nullable": [], "nullable": [],
"parameters": { "parameters": {
"Left": [ "Left": [
"Varchar", "Varchar",
"Varchar", "Bool",
"Varchar", "Text",
"Int8" "Int8"
] ]
} }
}, },
"query": "\n UPDATE users\n SET payout_wallet = $1, payout_wallet_type = $2, payout_address = $3\n WHERE (id = $4)\n " "query": "\n UPDATE users\n SET email = $1, email_verified = $2, trolley_account_status = $3\n WHERE id = $4\n "
}, },
"8f5e2a570cf35b2d158182bac37fd40bcec277bbdeddaece5efaa88600048a70": { "8f5e2a570cf35b2d158182bac37fd40bcec277bbdeddaece5efaa88600048a70": {
"describe": { "describe": {
@ -4188,6 +4069,19 @@
}, },
"query": "SELECT EXISTS(SELECT 1 FROM reports WHERE id=$1)" "query": "SELECT EXISTS(SELECT 1 FROM reports WHERE id=$1)"
}, },
"9774f59e5d5ce6ba00ca7e3a4a81f80f78b908bdf664a4cdfad592a1b14c0d44": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
}
},
"query": "\n UPDATE historical_payouts\n SET status = $1\n WHERE payment_id = $2\n "
},
"99a1eac69d7f5a5139703df431e6a5c3012a90143a8c635f93632f04d0bc41d4": { "99a1eac69d7f5a5139703df431e6a5c3012a90143a8c635f93632f04d0bc41d4": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -5104,18 +4998,6 @@
}, },
"query": "\n SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'title', na.title, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.user_id = $1\n GROUP BY n.id, n.user_id;\n " "query": "\n SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'title', na.title, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.user_id = $1\n GROUP BY n.id, n.user_id;\n "
}, },
"c4b167ec7452cc92be0e33f7e4f3908f0c4109291511c94909e9105fc62a432f": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Int8"
]
}
},
"query": "\n UPDATE users\n SET payout_wallet = NULL, payout_wallet_type = NULL, payout_address = NULL\n WHERE (id = $1)\n "
},
"c55d2132e3e6e92dd50457affab758623dca175dc27a2d3cd4aace9cfdecf789": { "c55d2132e3e6e92dd50457affab758623dca175dc27a2d3cd4aace9cfdecf789": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -6051,6 +5933,44 @@
}, },
"query": "\n UPDATE versions\n SET featured = $1\n WHERE (id = $2)\n " "query": "\n UPDATE versions\n SET featured = $1\n WHERE (id = $2)\n "
}, },
"e5adaf219c52ec828b72bd89c6b86a475f73181abf180a024dfe05f918e58edb": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
},
{
"name": "amount",
"ordinal": 1,
"type_info": "Numeric"
},
{
"name": "user_id",
"ordinal": 2,
"type_info": "Int8"
},
{
"name": "status",
"ordinal": 3,
"type_info": "Varchar"
}
],
"nullable": [
false,
false,
false,
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1"
},
"e60ea75112db37d3e73812e21b1907716e4762e06aa883af878e3be82e3f87d3": { "e60ea75112db37d3e73812e21b1907716e4762e06aa883af878e3be82e3f87d3": {
"describe": { "describe": {
"columns": [ "columns": [
@ -6417,6 +6337,19 @@
}, },
"query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[])\n ON CONFLICT DO NOTHING\n " "query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[])\n ON CONFLICT DO NOTHING\n "
}, },
"f141cc6711123b4fe5a5d9a7337a0b009b80e5d8fbda664b8d62b1a3f38eb936": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Numeric",
"Int8"
]
}
},
"query": "\n UPDATE users\n SET balance = balance + $1\n WHERE id = $2\n "
},
"f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01": { "f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -6648,6 +6581,147 @@
}, },
"query": "\n INSERT INTO game_versions_versions (game_version_id, joining_version_id)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[])\n " "query": "\n INSERT INTO game_versions_versions (game_version_id, joining_version_id)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[])\n "
}, },
"faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int8"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Varchar"
},
{
"name": "email",
"ordinal": 2,
"type_info": "Varchar"
},
{
"name": "avatar_url",
"ordinal": 3,
"type_info": "Varchar"
},
{
"name": "username",
"ordinal": 4,
"type_info": "Varchar"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Varchar"
},
{
"name": "created",
"ordinal": 6,
"type_info": "Timestamptz"
},
{
"name": "role",
"ordinal": 7,
"type_info": "Varchar"
},
{
"name": "badges",
"ordinal": 8,
"type_info": "Int8"
},
{
"name": "balance",
"ordinal": 9,
"type_info": "Numeric"
},
{
"name": "github_id",
"ordinal": 10,
"type_info": "Int8"
},
{
"name": "discord_id",
"ordinal": 11,
"type_info": "Int8"
},
{
"name": "gitlab_id",
"ordinal": 12,
"type_info": "Int8"
},
{
"name": "google_id",
"ordinal": 13,
"type_info": "Varchar"
},
{
"name": "steam_id",
"ordinal": 14,
"type_info": "Int8"
},
{
"name": "microsoft_id",
"ordinal": 15,
"type_info": "Varchar"
},
{
"name": "email_verified",
"ordinal": 16,
"type_info": "Bool"
},
{
"name": "password",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "totp_secret",
"ordinal": 18,
"type_info": "Varchar"
},
{
"name": "trolley_id",
"ordinal": 19,
"type_info": "Text"
},
{
"name": "trolley_account_status",
"ordinal": 20,
"type_info": "Text"
}
],
"nullable": [
false,
true,
true,
true,
false,
true,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true
],
"parameters": {
"Left": [
"Int8Array",
"TextArray"
]
}
},
"query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, trolley_id, trolley_account_status\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n "
},
"fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7": { "fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7": {
"describe": { "describe": {
"columns": [ "columns": [

View File

@ -8,7 +8,8 @@ use crate::file_hosting::FileHost;
use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::ids::base62_impl::{parse_base62, to_base62};
use crate::models::ids::random_base62_rng; use crate::models::ids::random_base62_rng;
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
use crate::models::users::{Badges, Role}; use crate::models::users::{Badges, RecipientStatus, Role, UserPayoutData};
use crate::queue::payouts::{AccountUser, PayoutsQueue};
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::queue::socket::ActiveSockets; use crate::queue::socket::ActiveSockets;
use crate::routes::ApiError; use crate::routes::ApiError;
@ -30,7 +31,7 @@ use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::{Mutex, RwLock};
use validator::Validate; use validator::Validate;
pub fn config(cfg: &mut ServiceConfig) { pub fn config(cfg: &mut ServiceConfig) {
@ -51,7 +52,8 @@ pub fn config(cfg: &mut ServiceConfig) {
.service(resend_verify_email) .service(resend_verify_email)
.service(set_email) .service(set_email)
.service(verify_email) .service(verify_email)
.service(subscribe_newsletter), .service(subscribe_newsletter)
.service(link_trolley),
); );
} }
@ -225,9 +227,8 @@ impl TempUser {
role: Role::Developer.to_string(), role: Role::Developer.to_string(),
badges: Badges::default(), badges: Badges::default(),
balance: Decimal::ZERO, balance: Decimal::ZERO,
payout_wallet: None, trolley_id: None,
payout_wallet_type: None, trolley_account_status: None,
payout_address: None,
} }
.insert(transaction) .insert(transaction)
.await?; .await?;
@ -1013,7 +1014,7 @@ pub async fn auth_callback(
let sockets = active_sockets.clone(); let sockets = active_sockets.clone();
let state = state_string.clone(); let state = state_string.clone();
let res: Result<HttpResponse, AuthenticationError> = (|| async move { let res: Result<HttpResponse, AuthenticationError> = async move {
let flow = Flow::get(&state, &redis).await?; let flow = Flow::get(&state, &redis).await?;
@ -1175,7 +1176,7 @@ pub async fn auth_callback(
} else { } else {
Err::<HttpResponse, AuthenticationError>(AuthenticationError::InvalidCredentials) Err::<HttpResponse, AuthenticationError>(AuthenticationError::InvalidCredentials)
} }
})().await; }.await;
// Because this is callback route, if we have an error, we need to ensure we close the original socket if it exists // Because this is callback route, if we have an error, we need to ensure we close the original socket if it exists
if let Err(ref e) = res { if let Err(ref e) = res {
@ -1385,9 +1386,8 @@ pub async fn create_account_with_password(
role: Role::Developer.to_string(), role: Role::Developer.to_string(),
badges: Badges::default(), badges: Badges::default(),
balance: Decimal::ZERO, balance: Decimal::ZERO,
payout_wallet: None, trolley_id: None,
payout_wallet_type: None, trolley_account_status: None,
payout_address: None,
} }
.insert(&mut transaction) .insert(&mut transaction)
.await?; .await?;
@ -2011,6 +2011,7 @@ pub async fn set_email(
redis: Data<RedisPool>, redis: Data<RedisPool>,
email: web::Json<SetEmail>, email: web::Json<SetEmail>,
session_queue: Data<AuthQueue>, session_queue: Data<AuthQueue>,
payouts_queue: Data<Mutex<PayoutsQueue>>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
email email
.0 .0
@ -2064,6 +2065,17 @@ pub async fn set_email(
"We need to verify your email address.", "We need to verify your email address.",
)?; )?;
if let Some(UserPayoutData {
trolley_id: Some(trolley_id),
..
}) = user.payout_data
{
let queue = payouts_queue.lock().await;
queue
.update_recipient_email(&trolley_id, &email.email)
.await?;
}
crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?; crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?;
transaction.commit().await?; transaction.commit().await?;
@ -2206,3 +2218,59 @@ fn send_email_verify(
Some(("Verify email", &format!("{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_VERIFY_EMAIL_PATH")?, flow))), Some(("Verify email", &format!("{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_VERIFY_EMAIL_PATH")?, flow))),
) )
} }
#[post("trolley/link")]
pub async fn link_trolley(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
payouts_queue: Data<Mutex<PayoutsQueue>>,
body: web::Json<AccountUser>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_WRITE]),
)
.await?
.1;
if let Some(payout_data) = user.payout_data {
if payout_data.trolley_id.is_some() {
return Err(ApiError::InvalidInput(
"User already has a trolley account.".to_string(),
));
}
}
if let Some(email) = user.email {
let id = payouts_queue.lock().await.register_recipient(&email, body.0).await?;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE users
SET trolley_id = $1, trolley_account_status = $2
WHERE id = $3
",
id,
RecipientStatus::Incomplete.as_str(),
user.id.0 as i64,
)
.execute(&mut transaction)
.await?;
transaction.commit().await?;
crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?;
Ok(HttpResponse::NoContent().finish())
} else {
Err(ApiError::InvalidInput(
"User needs to have an email set on account.".to_string(),
))
}
}

View File

@ -56,16 +56,15 @@ where
created: db_user.created, created: db_user.created,
role: Role::from_string(&db_user.role), role: Role::from_string(&db_user.role),
badges: db_user.badges, badges: db_user.badges,
payout_data: Some(UserPayoutData {
balance: db_user.balance,
payout_wallet: db_user.payout_wallet,
payout_wallet_type: db_user.payout_wallet_type,
payout_address: db_user.payout_address,
}),
auth_providers: Some(auth_providers), auth_providers: Some(auth_providers),
has_password: Some(db_user.password.is_some()), has_password: Some(db_user.password.is_some()),
has_totp: Some(db_user.totp_secret.is_some()), has_totp: Some(db_user.totp_secret.is_some()),
github_id: None, github_id: None,
payout_data: Some(UserPayoutData {
balance: db_user.balance,
trolley_id: db_user.trolley_id,
trolley_status: db_user.trolley_account_status,
}),
}; };
if let Some(required_scopes) = required_scopes { if let Some(required_scopes) = required_scopes {

View File

@ -76,10 +76,10 @@ pub async fn fetch_playtimes(
.bind(start_date) .bind(start_date)
.bind(end_date); .bind(end_date);
if projects.is_some() { if let Some(projects) = projects {
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
} else if versions.is_some() { } else if let Some(versions) = versions {
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
} }
Ok(query.fetch_all().await?) Ok(query.fetch_all().await?)
@ -123,10 +123,10 @@ pub async fn fetch_views(
.bind(start_date) .bind(start_date)
.bind(end_date); .bind(end_date);
if projects.is_some() { if let Some(projects) = projects {
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
} else if versions.is_some() { } else if let Some(versions) = versions {
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
} }
Ok(query.fetch_all().await?) Ok(query.fetch_all().await?)
@ -170,10 +170,10 @@ pub async fn fetch_downloads(
.bind(start_date) .bind(start_date)
.bind(end_date); .bind(end_date);
if projects.is_some() { if let Some(projects) = projects {
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
} else if versions.is_some() { } else if let Some(versions) = versions {
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
} }
Ok(query.fetch_all().await?) Ok(query.fetch_all().await?)
@ -233,10 +233,10 @@ pub async fn fetch_countries(
" "
)).bind(start_date).bind(end_date).bind(start_date).bind(end_date); )).bind(start_date).bind(end_date).bind(start_date).bind(end_date);
if projects.is_some() { if let Some(projects) = projects {
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
} else if versions.is_some() { } else if let Some(versions) = versions {
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>()); query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
} }
Ok(query.fetch_all().await?) Ok(query.fetch_all().await?)

View File

@ -210,7 +210,7 @@ impl Collection {
color: m.color.map(|x| x as u32), color: m.color.map(|x| x as u32),
created: m.created, created: m.created,
updated: m.updated, updated: m.updated,
status: CollectionStatus::from_str(&m.status), status: CollectionStatus::from_string(&m.status),
projects: m projects: m
.mods .mods
.unwrap_or_default() .unwrap_or_default()

View File

@ -630,10 +630,10 @@ impl Project {
license_url: m.license_url.clone(), license_url: m.license_url.clone(),
discord_url: m.discord_url.clone(), discord_url: m.discord_url.clone(),
client_side: SideTypeId(m.client_side), client_side: SideTypeId(m.client_side),
status: ProjectStatus::from_str( status: ProjectStatus::from_string(
&m.status, &m.status,
), ),
requested_status: m.requested_status.map(|x| ProjectStatus::from_str( requested_status: m.requested_status.map(|x| ProjectStatus::from_string(
&x, &x,
)), )),
server_side: SideTypeId(m.server_side), server_side: SideTypeId(m.server_side),
@ -647,7 +647,7 @@ impl Project {
webhook_sent: m.webhook_sent, webhook_sent: m.webhook_sent,
color: m.color.map(|x| x as u32), color: m.color.map(|x| x as u32),
queued: m.queued, queued: m.queued,
monetization_status: MonetizationStatus::from_str( monetization_status: MonetizationStatus::from_string(
&m.monetization_status, &m.monetization_status,
), ),
loaders: m.loaders, loaders: m.loaders,
@ -685,8 +685,8 @@ impl Project {
donation_urls: serde_json::from_value( donation_urls: serde_json::from_value(
m.donations.unwrap_or_default(), m.donations.unwrap_or_default(),
).ok().unwrap_or_default(), ).ok().unwrap_or_default(),
client_side: crate::models::projects::SideType::from_str(&m.client_side_type), client_side: crate::models::projects::SideType::from_string(&m.client_side_type),
server_side: crate::models::projects::SideType::from_str(&m.server_side_type), server_side: crate::models::projects::SideType::from_string(&m.server_side_type),
thread_id: ThreadId(m.thread_id), thread_id: ThreadId(m.thread_id),
}})) }}))
}) })

View File

@ -148,7 +148,7 @@ impl Thread {
id: ThreadId(x.id), id: ThreadId(x.id),
project_id: x.mod_id.map(ProjectId), project_id: x.mod_id.map(ProjectId),
report_id: x.report_id.map(ReportId), report_id: x.report_id.map(ReportId),
type_: ThreadType::from_str(&x.thread_type), type_: ThreadType::from_string(&x.thread_type),
messages: { messages: {
let mut messages: Vec<ThreadMessage> = serde_json::from_value( let mut messages: Vec<ThreadMessage> = serde_json::from_value(
x.messages.unwrap_or_default(), x.messages.unwrap_or_default(),

View File

@ -3,7 +3,7 @@ use super::CollectionId;
use crate::database::models::DatabaseError; use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::ids::base62_impl::{parse_base62, to_base62};
use crate::models::users::{Badges, RecipientType, RecipientWallet}; use crate::models::users::{Badges, RecipientStatus};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -35,10 +35,10 @@ pub struct User {
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
pub role: String, pub role: String,
pub badges: Badges, pub badges: Badges,
pub balance: Decimal, pub balance: Decimal,
pub payout_wallet: Option<RecipientWallet>, pub trolley_id: Option<String>,
pub payout_wallet_type: Option<RecipientType>, pub trolley_account_status: Option<RecipientStatus>,
pub payout_address: Option<String>,
} }
impl User { impl User {
@ -188,9 +188,9 @@ impl User {
SELECT id, name, email, SELECT id, name, email,
avatar_url, username, bio, avatar_url, username, bio,
created, role, badges, created, role, badges,
balance, payout_wallet, payout_wallet_type, payout_address, balance,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, totp_secret email_verified, password, totp_secret, trolley_id, trolley_account_status
FROM users FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2) WHERE id = ANY($1) OR LOWER(username) = ANY($2)
", ",
@ -220,13 +220,13 @@ impl User {
role: u.role, role: u.role,
badges: Badges::from_bits(u.badges as u64).unwrap_or_default(), badges: Badges::from_bits(u.badges as u64).unwrap_or_default(),
balance: u.balance, balance: u.balance,
payout_wallet: u.payout_wallet.map(|x| RecipientWallet::from_string(&x)),
payout_wallet_type: u
.payout_wallet_type
.map(|x| RecipientType::from_string(&x)),
payout_address: u.payout_address,
password: u.password, password: u.password,
totp_secret: u.totp_secret, totp_secret: u.totp_secret,
trolley_id: u.trolley_id,
trolley_account_status: u
.trolley_account_status
.as_ref()
.map(|x| RecipientStatus::from_string(x)),
})) }))
}) })
.try_collect::<Vec<User>>() .try_collect::<Vec<User>>()
@ -352,7 +352,7 @@ impl User {
redis: &RedisPool, redis: &RedisPool,
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
redis redis
.delete_many(user_ids.into_iter().flat_map(|(id, username)| { .delete_many(user_ids.iter().flat_map(|(id, username)| {
[ [
(USERS_NAMESPACE, Some(id.0.to_string())), (USERS_NAMESPACE, Some(id.0.to_string())),
( (

View File

@ -590,9 +590,9 @@ impl Version {
downloads: v.downloads, downloads: v.downloads,
version_type: v.version_type, version_type: v.version_type,
featured: v.featured, featured: v.featured,
status: VersionStatus::from_str(&v.status), status: VersionStatus::from_string(&v.status),
requested_status: v.requested_status requested_status: v.requested_status
.map(|x| VersionStatus::from_str(&x)), .map(|x| VersionStatus::from_string(&x)),
}, },
files: { files: {
#[derive(Deserialize)] #[derive(Deserialize)]
@ -803,7 +803,7 @@ impl Version {
.unwrap_or_default().into_iter().map(|x| (x.algorithm, x.hash)).collect(), .unwrap_or_default().into_iter().map(|x| (x.algorithm, x.hash)).collect(),
primary: f.is_primary, primary: f.is_primary,
size: f.size as u32, size: f.size as u32,
file_type: f.file_type.map(|x| FileType::from_str(&x)), file_type: f.file_type.map(|x| FileType::from_string(&x)),
} }
} }
)) ))

View File

@ -4,6 +4,7 @@ use bytes::Bytes;
use chrono::Utc; use chrono::Utc;
use sha2::Digest; use sha2::Digest;
#[derive(Default)]
pub struct MockHost(()); pub struct MockHost(());
impl MockHost { impl MockHost {

View File

@ -365,9 +365,9 @@ pub fn check_env_vars() -> bool {
failed |= true; failed |= true;
} }
failed |= check_var::<String>("PAYPAL_API_URL"); failed |= check_var::<String>("TROLLEY_ACCESS_KEY");
failed |= check_var::<String>("PAYPAL_CLIENT_ID"); failed |= check_var::<String>("TROLLEY_SECRET_KEY");
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET"); failed |= check_var::<String>("TROLLEY_WEBHOOK_SIGNATURE");
failed |= check_var::<String>("GITHUB_CLIENT_ID"); failed |= check_var::<String>("GITHUB_CLIENT_ID");
failed |= check_var::<String>("GITHUB_CLIENT_SECRET"); failed |= check_var::<String>("GITHUB_CLIENT_SECRET");

View File

@ -31,8 +31,6 @@ async fn main() -> std::io::Result<()> {
let sentry = sentry::init(sentry::ClientOptions { let sentry = sentry::init(sentry::ClientOptions {
release: sentry::release_name!(), release: sentry::release_name!(),
traces_sample_rate: 0.1, traces_sample_rate: 0.1,
enable_profiling: true,
profiles_sample_rate: 0.1,
..Default::default() ..Default::default()
}); });
if sentry.is_enabled() { if sentry.is_enabled() {

View File

@ -80,7 +80,7 @@ impl std::fmt::Display for CollectionStatus {
} }
impl CollectionStatus { impl CollectionStatus {
pub fn from_str(string: &str) -> CollectionStatus { pub fn from_string(string: &str) -> CollectionStatus {
match string { match string {
"listed" => CollectionStatus::Listed, "listed" => CollectionStatus::Listed,
"unlisted" => CollectionStatus::Unlisted, "unlisted" => CollectionStatus::Unlisted,

View File

@ -1,4 +1,5 @@
use super::ids::Base62Id; use super::ids::Base62Id;
use crate::bitflags_serde_impl;
use crate::models::ids::UserId; use crate::models::ids::UserId;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -10,8 +11,7 @@ use serde::{Deserialize, Serialize};
pub struct PatId(pub u64); pub struct PatId(pub u64);
bitflags::bitflags! { bitflags::bitflags! {
#[derive(Serialize, Deserialize)] #[derive(Copy, Clone, Debug)]
#[serde(transparent)]
pub struct Scopes: u64 { pub struct Scopes: u64 {
// read a user's email // read a user's email
const USER_READ_EMAIL = 1 << 0; const USER_READ_EMAIL = 1 << 0;
@ -107,6 +107,8 @@ bitflags::bitflags! {
} }
} }
bitflags_serde_impl!(Scopes, u64);
impl Scopes { impl Scopes {
// these scopes cannot be specified in a personal access token // these scopes cannot be specified in a personal access token
pub fn restricted() -> Scopes { pub fn restricted() -> Scopes {

View File

@ -247,7 +247,7 @@ impl SideType {
} }
} }
pub fn from_str(string: &str) -> SideType { pub fn from_string(string: &str) -> SideType {
match string { match string {
"required" => SideType::Required, "required" => SideType::Required,
"optional" => SideType::Optional, "optional" => SideType::Optional,
@ -308,7 +308,7 @@ impl std::fmt::Display for ProjectStatus {
} }
impl ProjectStatus { impl ProjectStatus {
pub fn from_str(string: &str) -> ProjectStatus { pub fn from_string(string: &str) -> ProjectStatus {
match string { match string {
"processing" => ProjectStatus::Processing, "processing" => ProjectStatus::Processing,
"rejected" => ProjectStatus::Rejected, "rejected" => ProjectStatus::Rejected,
@ -433,7 +433,7 @@ impl std::fmt::Display for MonetizationStatus {
} }
impl MonetizationStatus { impl MonetizationStatus {
pub fn from_str(string: &str) -> MonetizationStatus { pub fn from_string(string: &str) -> MonetizationStatus {
match string { match string {
"force-demonetized" => MonetizationStatus::ForceDemonetized, "force-demonetized" => MonetizationStatus::ForceDemonetized,
"demonetized" => MonetizationStatus::Demonetized, "demonetized" => MonetizationStatus::Demonetized,
@ -537,7 +537,7 @@ impl From<QueryVersion> for Version {
version_id: d.version_id.map(|i| VersionId(i.0 as u64)), version_id: d.version_id.map(|i| VersionId(i.0 as u64)),
project_id: d.project_id.map(|i| ProjectId(i.0 as u64)), project_id: d.project_id.map(|i| ProjectId(i.0 as u64)),
file_name: d.file_name, file_name: d.file_name,
dependency_type: DependencyType::from_str(d.dependency_type.as_str()), dependency_type: DependencyType::from_string(d.dependency_type.as_str()),
}) })
.collect(), .collect(),
game_versions: data.game_versions.into_iter().map(GameVersion).collect(), game_versions: data.game_versions.into_iter().map(GameVersion).collect(),
@ -570,7 +570,7 @@ impl std::fmt::Display for VersionStatus {
} }
impl VersionStatus { impl VersionStatus {
pub fn from_str(string: &str) -> VersionStatus { pub fn from_string(string: &str) -> VersionStatus {
match string { match string {
"listed" => VersionStatus::Listed, "listed" => VersionStatus::Listed,
"draft" => VersionStatus::Draft, "draft" => VersionStatus::Draft,
@ -718,7 +718,7 @@ impl DependencyType {
} }
} }
pub fn from_str(string: &str) -> DependencyType { pub fn from_string(string: &str) -> DependencyType {
match string { match string {
"required" => DependencyType::Required, "required" => DependencyType::Required,
"optional" => DependencyType::Optional, "optional" => DependencyType::Optional,
@ -753,7 +753,7 @@ impl FileType {
} }
} }
pub fn from_str(string: &str) -> FileType { pub fn from_string(string: &str) -> FileType {
match string { match string {
"required-resource-pack" => FileType::RequiredResourcePack, "required-resource-pack" => FileType::RequiredResourcePack,
"optional-resource-pack" => FileType::OptionalResourcePack, "optional-resource-pack" => FileType::OptionalResourcePack,

View File

@ -1,4 +1,5 @@
use super::ids::Base62Id; use super::ids::Base62Id;
use crate::bitflags_serde_impl;
use crate::models::users::User; use crate::models::users::User;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -22,8 +23,7 @@ pub struct Team {
} }
bitflags::bitflags! { bitflags::bitflags! {
#[derive(Serialize, Deserialize)] #[derive(Copy, Clone, Debug)]
#[serde(transparent)]
pub struct ProjectPermissions: u64 { pub struct ProjectPermissions: u64 {
const UPLOAD_VERSION = 1 << 0; const UPLOAD_VERSION = 1 << 0;
const DELETE_VERSION = 1 << 1; const DELETE_VERSION = 1 << 1;
@ -40,6 +40,8 @@ bitflags::bitflags! {
} }
} }
bitflags_serde_impl!(ProjectPermissions, u64);
impl Default for ProjectPermissions { impl Default for ProjectPermissions {
fn default() -> ProjectPermissions { fn default() -> ProjectPermissions {
ProjectPermissions::UPLOAD_VERSION | ProjectPermissions::DELETE_VERSION ProjectPermissions::UPLOAD_VERSION | ProjectPermissions::DELETE_VERSION
@ -77,8 +79,7 @@ impl ProjectPermissions {
} }
bitflags::bitflags! { bitflags::bitflags! {
#[derive(Serialize, Deserialize)] #[derive(Copy, Clone, Debug)]
#[serde(transparent)]
pub struct OrganizationPermissions: u64 { pub struct OrganizationPermissions: u64 {
const EDIT_DETAILS = 1 << 0; const EDIT_DETAILS = 1 << 0;
const EDIT_BODY = 1 << 1; const EDIT_BODY = 1 << 1;
@ -94,6 +95,8 @@ bitflags::bitflags! {
} }
} }
bitflags_serde_impl!(OrganizationPermissions, u64);
impl Default for OrganizationPermissions { impl Default for OrganizationPermissions {
fn default() -> OrganizationPermissions { fn default() -> OrganizationPermissions {
OrganizationPermissions::NONE OrganizationPermissions::NONE

View File

@ -78,7 +78,7 @@ impl ThreadType {
} }
} }
pub fn from_str(string: &str) -> ThreadType { pub fn from_string(string: &str) -> ThreadType {
match string { match string {
"report" => ThreadType::Report, "report" => ThreadType::Report,
"project" => ThreadType::Project, "project" => ThreadType::Project,

View File

@ -1,5 +1,6 @@
use super::ids::Base62Id; use super::ids::Base62Id;
use crate::auth::flows::AuthProvider; use crate::auth::flows::AuthProvider;
use crate::bitflags_serde_impl;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -12,8 +13,7 @@ pub struct UserId(pub u64);
pub const DELETED_USER: UserId = UserId(127155982985829); pub const DELETED_USER: UserId = UserId(127155982985829);
bitflags::bitflags! { bitflags::bitflags! {
#[derive(Serialize, Deserialize)] #[derive(Copy, Clone, Debug)]
#[serde(transparent)]
pub struct Badges: u64 { pub struct Badges: u64 {
// 1 << 0 unused - ignore + replace with something later // 1 << 0 unused - ignore + replace with something later
const MIDAS = 1 << 0; const MIDAS = 1 << 0;
@ -29,6 +29,8 @@ bitflags::bitflags! {
} }
} }
bitflags_serde_impl!(Badges, u64);
impl Default for Badges { impl Default for Badges {
fn default() -> Badges { fn default() -> Badges {
Badges::NONE Badges::NONE
@ -46,12 +48,12 @@ pub struct User {
pub role: Role, pub role: Role,
pub badges: Badges, pub badges: Badges,
pub payout_data: Option<UserPayoutData>,
pub auth_providers: Option<Vec<AuthProvider>>, pub auth_providers: Option<Vec<AuthProvider>>,
pub email: Option<String>, pub email: Option<String>,
pub email_verified: Option<bool>, pub email_verified: Option<bool>,
pub has_password: Option<bool>, pub has_password: Option<bool>,
pub has_totp: Option<bool>, pub has_totp: Option<bool>,
pub payout_data: Option<UserPayoutData>,
// DEPRECATED. Always returns None // DEPRECATED. Always returns None
pub github_id: Option<u64>, pub github_id: Option<u64>,
@ -60,77 +62,8 @@ pub struct User {
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserPayoutData { pub struct UserPayoutData {
pub balance: Decimal, pub balance: Decimal,
pub payout_wallet: Option<RecipientWallet>, pub trolley_id: Option<String>,
pub payout_wallet_type: Option<RecipientType>, pub trolley_status: Option<RecipientStatus>,
pub payout_address: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)]
#[serde(rename_all = "snake_case")]
pub enum RecipientType {
Email,
Phone,
UserHandle,
}
impl std::fmt::Display for RecipientType {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
fmt.write_str(self.as_str())
}
}
impl RecipientType {
pub fn from_string(string: &str) -> RecipientType {
match string {
"user_handle" => RecipientType::UserHandle,
"phone" => RecipientType::Phone,
_ => RecipientType::Email,
}
}
pub fn as_str(&self) -> &'static str {
match self {
RecipientType::Email => "email",
RecipientType::Phone => "phone",
RecipientType::UserHandle => "user_handle",
}
}
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)]
#[serde(rename_all = "snake_case")]
pub enum RecipientWallet {
Venmo,
Paypal,
}
impl std::fmt::Display for RecipientWallet {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
fmt.write_str(self.as_str())
}
}
impl RecipientWallet {
pub fn from_string(string: &str) -> RecipientWallet {
match string {
"venmo" => RecipientWallet::Venmo,
_ => RecipientWallet::Paypal,
}
}
pub fn as_str(&self) -> &'static str {
match self {
RecipientWallet::Paypal => "paypal",
RecipientWallet::Venmo => "venmo",
}
}
pub fn as_str_api(&self) -> &'static str {
match self {
RecipientWallet::Paypal => "PayPal",
RecipientWallet::Venmo => "Venmo",
}
}
} }
use crate::database::models::user_item::User as DBUser; use crate::database::models::user_item::User as DBUser;
@ -201,3 +134,89 @@ impl Role {
} }
} }
} }
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum RecipientStatus {
Active,
Incomplete,
Disabled,
Archived,
Suspended,
Blocked,
}
impl RecipientStatus {
pub fn from_string(string: &str) -> RecipientStatus {
match string {
"active" => RecipientStatus::Active,
"incomplete" => RecipientStatus::Incomplete,
"disabled" => RecipientStatus::Disabled,
"archived" => RecipientStatus::Archived,
"suspended" => RecipientStatus::Suspended,
"blocked" => RecipientStatus::Blocked,
_ => RecipientStatus::Disabled,
}
}
pub fn as_str(&self) -> &'static str {
match self {
RecipientStatus::Active => "active",
RecipientStatus::Incomplete => "incomplete",
RecipientStatus::Disabled => "disabled",
RecipientStatus::Archived => "archived",
RecipientStatus::Suspended => "suspended",
RecipientStatus::Blocked => "blocked",
}
}
}
#[derive(Serialize)]
pub struct Payout {
pub created: DateTime<Utc>,
pub amount: Decimal,
pub status: PayoutStatus,
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)]
#[serde(rename_all = "lowercase")]
pub enum PayoutStatus {
Pending,
Failed,
Processed,
Returned,
Processing,
}
impl PayoutStatus {
pub fn from_string(string: &str) -> PayoutStatus {
match string {
"pending" => PayoutStatus::Pending,
"failed" => PayoutStatus::Failed,
"processed" => PayoutStatus::Processed,
"returned" => PayoutStatus::Returned,
"processing" => PayoutStatus::Processing,
_ => PayoutStatus::Processing,
}
}
pub fn as_str(&self) -> &'static str {
match self {
PayoutStatus::Pending => "pending",
PayoutStatus::Failed => "failed",
PayoutStatus::Processed => "processed",
PayoutStatus::Returned => "returned",
PayoutStatus::Processing => "processing",
}
}
pub fn is_failed(&self) -> bool {
match self {
PayoutStatus::Pending => false,
PayoutStatus::Failed => true,
PayoutStatus::Processed => false,
PayoutStatus::Returned => true,
PayoutStatus::Processing => false,
}
}
}

View File

@ -14,6 +14,12 @@ pub struct AnalyticsQueue {
playtime_queue: DashSet<Playtime>, playtime_queue: DashSet<Playtime>,
} }
impl Default for AnalyticsQueue {
fn default() -> Self {
Self::new()
}
}
// Batches analytics data points + transactions every few minutes // Batches analytics data points + transactions every few minutes
impl AnalyticsQueue { impl AnalyticsQueue {
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -6,6 +6,12 @@ pub struct DownloadQueue {
queue: Mutex<Vec<(ProjectId, VersionId)>>, queue: Mutex<Vec<(ProjectId, VersionId)>>,
} }
impl Default for DownloadQueue {
fn default() -> Self {
Self::new()
}
}
// Batches download transactions every thirty seconds // Batches download transactions every thirty seconds
impl DownloadQueue { impl DownloadQueue {
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -1,203 +1,332 @@
use crate::routes::ApiError; use crate::routes::ApiError;
use crate::util::env::parse_var; use crate::util::env::parse_var;
use crate::{database::redis::RedisPool, models::projects::MonetizationStatus}; use crate::{database::redis::RedisPool, models::projects::MonetizationStatus};
use base64::Engine;
use chrono::{DateTime, Datelike, Duration, Utc, Weekday}; use chrono::{DateTime, Datelike, Duration, Utc, Weekday};
use hex::ToHex;
use hmac::{Hmac, Mac, NewMac};
use reqwest::Method;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::{json, Value};
use sha2::Sha256;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
pub struct PayoutsQueue { pub struct PayoutsQueue {
credential: PaypalCredential, access_key: String,
credential_expires: DateTime<Utc>, secret_key: String,
} }
#[derive(Deserialize, Default)] impl Default for PayoutsQueue {
struct PaypalCredential { fn default() -> Self {
access_token: String, Self::new()
token_type: String, }
expires_in: i64, }
#[derive(Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AccountUser {
Business { name: String },
Individual { first: String, last: String },
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct PayoutItem { pub struct PaymentInfo {
pub amount: PayoutAmount, country: String,
pub receiver: String, payout_method: String,
pub note: String, route_minimum: Decimal,
pub recipient_type: String, estimated_fees: Decimal,
pub recipient_wallet: String, deduct_fees: Decimal,
pub sender_item_id: String,
}
#[derive(Serialize, Deserialize)]
pub struct PayoutAmount {
pub currency: String,
#[serde(with = "rust_decimal::serde::str")]
pub value: Decimal,
} }
// Batches payouts and handles token refresh // Batches payouts and handles token refresh
impl PayoutsQueue { impl PayoutsQueue {
pub fn new() -> Self { pub fn new() -> Self {
PayoutsQueue { PayoutsQueue {
credential: Default::default(), access_key: dotenvy::var("TROLLEY_ACCESS_KEY").expect("missing trolley access key"),
credential_expires: Utc::now() - Duration::days(30), secret_key: dotenvy::var("TROLLEY_SECRET_KEY").expect("missing trolley secret key"),
} }
} }
pub async fn refresh_token(&mut self) -> Result<(), ApiError> { pub async fn make_trolley_request<T: Serialize, X: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<T>,
) -> Result<X, ApiError> {
let timestamp = Utc::now().timestamp();
let mut mac: Hmac<Sha256> = Hmac::new_from_slice(self.secret_key.as_bytes())
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
mac.update(
if let Some(body) = &body {
format!(
"{}\n{}\n{}\n{}\n",
timestamp,
method.as_str(),
path,
serde_json::to_string(&body)?
)
} else {
format!("{}\n{}\n{}\n\n", timestamp, method.as_str(), path)
}
.as_bytes(),
);
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let combined_key = format!( let mut request = client
"{}:{}", .request(method, format!("https://api.trolley.com{path}"))
dotenvy::var("PAYPAL_CLIENT_ID")?, .header(
dotenvy::var("PAYPAL_CLIENT_SECRET")? "Authorization",
); format!("prsign {}:{}", self.access_key, request_signature),
let formatted_key = format!( )
"Basic {}", .header("X-PR-Timestamp", timestamp);
base64::engine::general_purpose::STANDARD.encode(combined_key)
);
let mut form = HashMap::new(); if let Some(body) = body {
form.insert("grant_type", "client_credentials"); request = request.json(&body);
}
let credential: PaypalCredential = client let resp = request
.post(&format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?))
.header("Accept", "application/json")
.header("Accept-Language", "en_US")
.header("Authorization", formatted_key)
.form(&form)
.send() .send()
.await .await
.map_err(|_| ApiError::Payments("Error while authenticating with PayPal".to_string()))? .map_err(|_| ApiError::Payments("could not communicate with Trolley".to_string()))?;
.json()
.await let value = resp.json::<Value>().await.map_err(|_| {
.map_err(|_| { ApiError::Payments("could not retrieve Trolley response body".to_string())
})?;
if let Some(obj) = value.as_object() {
if !obj.get("ok").and_then(|x| x.as_bool()).unwrap_or(true) {
#[derive(Deserialize)]
struct TrolleyError {
field: Option<String>,
message: String,
}
if let Some(array) = obj.get("errors") {
let err = serde_json::from_value::<Vec<TrolleyError>>(array.clone()).map_err(
|_| {
ApiError::Payments( ApiError::Payments(
"Error while authenticating with PayPal (deser error)".to_string(), "could not retrieve Trolley error json body".to_string(),
) )
})?; },
)?;
self.credential_expires = Utc::now() + Duration::seconds(credential.expires_in); if let Some(first) = err.into_iter().next() {
self.credential = credential; return Err(ApiError::Payments(if let Some(field) = &first.field {
format!("error - field: {field} message: {}", first.message)
Ok(())
}
pub async fn send_payout(&mut self, mut payout: PayoutItem) -> Result<Decimal, ApiError> {
if self.credential_expires < Utc::now() {
self.refresh_token().await.map_err(|_| {
ApiError::Payments("Error while authenticating with PayPal".to_string())
})?;
}
let wallet = payout.recipient_wallet.clone();
let fee = if wallet == *"Venmo" {
Decimal::ONE / Decimal::from(4)
} else { } else {
first.message
}));
}
}
return Err(ApiError::Payments(
"could not retrieve Trolley error body".to_string(),
));
}
}
Ok(serde_json::from_value(value)?)
}
pub async fn send_payout(
&mut self,
recipient: &str,
amount: Decimal,
) -> Result<(String, Option<String>), ApiError> {
#[derive(Deserialize)]
struct TrolleyRes {
batch: Batch,
}
#[derive(Deserialize)]
struct Batch {
id: String,
payments: BatchPayments,
}
#[derive(Deserialize)]
struct Payment {
id: String,
}
#[derive(Deserialize)]
struct BatchPayments {
payments: Vec<Payment>,
}
let fee = self.get_estimated_fees(recipient, amount).await?;
if fee.estimated_fees > amount || fee.route_minimum > amount {
return Err(ApiError::Payments(
"Account balance is too low to withdraw funds".to_string(),
));
}
let send_amount = amount - fee.deduct_fees;
let res = self
.make_trolley_request::<_, TrolleyRes>(
Method::POST,
"/v1/batches/",
Some(json!({
"currency": "USD",
"description": "labrinth payout",
"payments": [{
"recipient": {
"id": recipient
},
"amount": send_amount.to_string(),
"currency": "USD",
"memo": "Modrinth ad revenue payout"
}],
})),
)
.await?;
self.make_trolley_request::<Value, Value>(
Method::POST,
&format!("/v1/batches/{}/start-processing", res.batch.id),
None,
)
.await?;
let payment_id = res.batch.payments.payments.into_iter().next().map(|x| x.id);
Ok((res.batch.id, payment_id))
}
pub async fn register_recipient(
&self,
email: &str,
user: AccountUser,
) -> Result<String, ApiError> {
#[derive(Deserialize)]
struct TrolleyRes {
recipient: Recipient,
}
#[derive(Deserialize)]
struct Recipient {
id: String,
}
let id = self
.make_trolley_request::<_, TrolleyRes>(
Method::POST,
"/v1/recipients/",
Some(match user {
AccountUser::Business { name } => json!({
"type": "business",
"email": email,
"name": name,
}),
AccountUser::Individual { first, last } => json!({
"type": "individual",
"firstName": first,
"lastName": last,
"email": email,
}),
}),
)
.await?;
Ok(id.recipient.id)
}
// lhs minimum, rhs estimate
pub async fn get_estimated_fees(
&self,
id: &str,
amount: Decimal,
) -> Result<PaymentInfo, ApiError> {
#[derive(Deserialize)]
struct TrolleyRes {
recipient: Recipient,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Recipient {
route_minimum: Option<Decimal>,
estimated_fees: Option<Decimal>,
address: RecipientAddress,
payout_method: String,
}
#[derive(Deserialize)]
struct RecipientAddress {
country: String,
}
let id = self
.make_trolley_request::<Value, TrolleyRes>(
Method::GET,
&format!("/v1/recipients/{id}"),
None,
)
.await?;
if &id.recipient.payout_method == "paypal" {
// based on https://www.paypal.com/us/webapps/mpp/merchant-fees. see paypal payouts section
let fee = if &id.recipient.address.country == "US" {
std::cmp::min( std::cmp::min(
std::cmp::max( std::cmp::max(
Decimal::ONE / Decimal::from(4), Decimal::ONE / Decimal::from(4),
(Decimal::from(2) / Decimal::ONE_HUNDRED) * payout.amount.value, (Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
), ),
Decimal::from(1),
)
} else {
std::cmp::min(
(Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
Decimal::from(20), Decimal::from(20),
) )
}; };
payout.amount.value -= fee; Ok(PaymentInfo {
payout.amount.value = payout.amount.value.round_dp(2); country: id.recipient.address.country,
payout_method: id.recipient.payout_method,
route_minimum: fee,
estimated_fees: fee,
deduct_fees: fee,
})
} else if &id.recipient.payout_method == "venmo" {
let venmo_fee = Decimal::ONE / Decimal::from(4);
if payout.amount.value <= Decimal::ZERO { Ok(PaymentInfo {
return Err(ApiError::InvalidInput( country: id.recipient.address.country,
"You do not have enough funds to make this payout!".to_string(), payout_method: id.recipient.payout_method,
)); route_minimum: id.recipient.route_minimum.unwrap_or(Decimal::ZERO) + venmo_fee,
estimated_fees: id.recipient.estimated_fees.unwrap_or(Decimal::ZERO) + venmo_fee,
deduct_fees: venmo_fee,
})
} else {
Ok(PaymentInfo {
country: id.recipient.address.country,
payout_method: id.recipient.payout_method,
route_minimum: id.recipient.route_minimum.unwrap_or(Decimal::ZERO),
estimated_fees: id.recipient.estimated_fees.unwrap_or(Decimal::ZERO),
deduct_fees: Decimal::ZERO,
})
}
} }
let client = reqwest::Client::new(); pub async fn update_recipient_email(&self, id: &str, email: &str) -> Result<(), ApiError> {
self.make_trolley_request::<_, Value>(
let res = client.post(&format!("{}payments/payouts", dotenvy::var("PAYPAL_API_URL")?)) Method::PATCH,
.header("Authorization", format!("{} {}", self.credential.token_type, self.credential.access_token)) &format!("/v1/recipients/{}", id),
.json(&json! ({ Some(json!({
"sender_batch_header": { "email": email,
"sender_batch_id": format!("{}-payouts", Utc::now().to_rfc3339()), })),
"email_subject": "You have received a payment from Modrinth!",
"email_message": "Thank you for creating projects on Modrinth. Please claim this payment within 30 days.",
},
"items": vec![payout]
}))
.send().await.map_err(|_| ApiError::Payments("Error while sending payout to PayPal".to_string()))?;
if !res.status().is_success() {
#[derive(Deserialize)]
struct PayPalError {
pub body: PayPalErrorBody,
}
#[derive(Deserialize)]
struct PayPalErrorBody {
pub message: String,
}
let body: PayPalError = res.json().await.map_err(|_| {
ApiError::Payments("Error while registering payment in PayPal!".to_string())
})?;
return Err(ApiError::Payments(format!(
"Error while registering payment in PayPal: {}",
body.body.message
)));
} else if wallet != *"Venmo" {
#[derive(Deserialize)]
struct PayPalLink {
href: String,
}
#[derive(Deserialize)]
struct PayoutsResponse {
pub links: Vec<PayPalLink>,
}
#[derive(Deserialize)]
struct PayoutDataItem {
payout_item_fee: PayoutAmount,
}
#[derive(Deserialize)]
struct PayoutData {
pub items: Vec<PayoutDataItem>,
}
// Calculate actual fee + refund if we took too big of a fee.
if let Ok(res) = res.json::<PayoutsResponse>().await {
if let Some(link) = res.links.first() {
if let Ok(res) = client
.get(&link.href)
.header(
"Authorization",
format!(
"{} {}",
self.credential.token_type, self.credential.access_token
),
) )
.send() .await?;
.await
{
if let Ok(res) = res.json::<PayoutData>().await {
if let Some(data) = res.items.first() {
if (fee - data.payout_item_fee.value) > Decimal::ZERO {
return Ok(fee - data.payout_item_fee.value);
}
}
}
}
}
}
}
Ok(Decimal::ZERO) Ok(())
} }
} }
@ -206,7 +335,7 @@ pub async fn process_payout(
redis: &RedisPool, redis: &RedisPool,
client: &clickhouse::Client, client: &clickhouse::Client,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let start: DateTime<Utc> = DateTime::from_utc( let start: DateTime<Utc> = DateTime::from_naive_utc_and_offset(
(Utc::now() - Duration::days(1)) (Utc::now() - Duration::days(1))
.date_naive() .date_naive()
.and_hms_nano_opt(0, 0, 0, 0) .and_hms_nano_opt(0, 0, 0, 0)

View File

@ -13,6 +13,12 @@ pub struct AuthQueue {
pat_queue: Mutex<HashSet<PatId>>, pat_queue: Mutex<HashSet<PatId>>,
} }
impl Default for AuthQueue {
fn default() -> Self {
Self::new()
}
}
// Batches session accessing transactions every 30 seconds // Batches session accessing transactions every 30 seconds
impl AuthQueue { impl AuthQueue {
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -15,6 +15,12 @@ pub struct MemoryStore {
inner: Arc<DashMap<String, (usize, Duration)>>, inner: Arc<DashMap<String, (usize, Duration)>>,
} }
impl Default for MemoryStore {
fn default() -> Self {
Self::new()
}
}
impl MemoryStore { impl MemoryStore {
/// Create a new hashmap /// Create a new hashmap
/// ///

View File

@ -6,10 +6,10 @@ use crate::queue::analytics::AnalyticsQueue;
use crate::queue::maxmind::MaxMindIndexer; use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::routes::ApiError; use crate::routes::ApiError;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::env::parse_strings_from_var; use crate::util::env::parse_strings_from_var;
use actix_web::{post, web}; use actix_web::{post, web};
use actix_web::{HttpRequest, HttpResponse}; use actix_web::{HttpRequest, HttpResponse};
use chrono::Utc;
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
@ -108,7 +108,7 @@ pub async fn page_view_ingest(
let mut view = PageView { let mut view = PageView {
id: Uuid::new_v4(), id: Uuid::new_v4(),
recorded: Utc::now().timestamp_nanos() / 100_000, recorded: get_current_tenths_of_ms(),
domain: domain.to_string(), domain: domain.to_string(),
site_path: url.path().to_string(), site_path: url.path().to_string(),
user_id: 0, user_id: 0,
@ -204,7 +204,7 @@ pub async fn playtime_ingest(
if let Some(version) = versions.iter().find(|x| id == x.inner.id.into()) { if let Some(version) = versions.iter().find(|x| id == x.inner.id.into()) {
analytics_queue.add_playtime(Playtime { analytics_queue.add_playtime(Playtime {
id: Default::default(), id: Default::default(),
recorded: Utc::now().timestamp_nanos() / 100_000, recorded: get_current_tenths_of_ms(),
seconds: playtime.seconds as u64, seconds: playtime.seconds as u64,
user_id: user.id.0, user_id: user.id.0,
project_id: version.inner.project_id.0 as u64, project_id: version.inner.project_id.0 as u64,

View File

@ -1,17 +1,23 @@
use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::models::User;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::analytics::Download; use crate::models::analytics::Download;
use crate::models::ids::ProjectId; use crate::models::ids::ProjectId;
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
use crate::models::users::{PayoutStatus, RecipientStatus};
use crate::queue::analytics::AnalyticsQueue; use crate::queue::analytics::AnalyticsQueue;
use crate::queue::download::DownloadQueue; use crate::queue::download::DownloadQueue;
use crate::queue::maxmind::MaxMindIndexer; use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::routes::ApiError; use crate::routes::ApiError;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::guards::admin_key_guard; use crate::util::guards::admin_key_guard;
use actix_web::{patch, web, HttpRequest, HttpResponse}; use crate::util::routes::read_from_payload;
use chrono::Utc; use actix_web::{patch, post, web, HttpRequest, HttpResponse};
use hex::ToHex;
use hmac::{Hmac, Mac, NewMac};
use serde::Deserialize; use serde::Deserialize;
use sha2::Sha256;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
@ -19,7 +25,11 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("admin").service(count_download)); cfg.service(
web::scope("admin")
.service(count_download)
.service(trolley_webhook),
);
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -110,7 +120,7 @@ pub async fn count_download(
analytics_queue.add_download(Download { analytics_queue.add_download(Download {
id: Uuid::new_v4(), id: Uuid::new_v4(),
recorded: Utc::now().timestamp_nanos() / 100_000, recorded: get_current_tenths_of_ms(),
domain: url.host_str().unwrap_or_default().to_string(), domain: url.host_str().unwrap_or_default().to_string(),
site_path: url.path().to_string(), site_path: url.path().to_string(),
user_id: user user_id: user
@ -141,3 +151,171 @@ pub async fn count_download(
Ok(HttpResponse::NoContent().body("")) Ok(HttpResponse::NoContent().body(""))
} }
#[derive(Deserialize)]
pub struct TrolleyWebhook {
model: String,
action: String,
body: HashMap<String, serde_json::Value>,
}
#[post("/_trolley")]
#[allow(clippy::too_many_arguments)]
pub async fn trolley_webhook(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
mut payload: web::Payload,
) -> Result<HttpResponse, ApiError> {
if let Some(signature) = req.headers().get("X-PaymentRails-Signature") {
let payload = read_from_payload(
&mut payload,
1 << 20,
"Webhook payload exceeds the maximum of 1MiB.",
)
.await?;
let mut signature = signature.to_str().ok().unwrap_or_default().split(',');
let timestamp = signature
.next()
.and_then(|x| x.split('=').nth(1))
.unwrap_or_default();
let v1 = signature
.next()
.and_then(|x| x.split('=').nth(1))
.unwrap_or_default();
let mut mac: Hmac<Sha256> =
Hmac::new_from_slice(dotenvy::var("TROLLEY_WEBHOOK_SIGNATURE")?.as_bytes())
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
mac.update(timestamp.as_bytes());
mac.update(&payload);
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
if &*request_signature == v1 {
let webhook = serde_json::from_slice::<TrolleyWebhook>(&payload)?;
if webhook.model == "recipient" {
#[derive(Deserialize)]
struct Recipient {
pub id: String,
pub email: Option<String>,
pub status: Option<RecipientStatus>,
}
if let Some(body) = webhook.body.get("recipient") {
if let Ok(recipient) = serde_json::from_value::<Recipient>(body.clone()) {
let value = sqlx::query!(
"SELECT id FROM users WHERE trolley_id = $1",
recipient.id
)
.fetch_optional(&**pool)
.await?;
if let Some(user) = value {
let user = User::get_id(
crate::database::models::UserId(user.id),
&**pool,
&redis,
)
.await?;
if let Some(user) = user {
let mut transaction = pool.begin().await?;
if webhook.action == "deleted" {
sqlx::query!(
"
UPDATE users
SET trolley_account_status = NULL, trolley_id = NULL
WHERE id = $1
",
user.id.0
)
.execute(&mut transaction)
.await?;
} else {
sqlx::query!(
"
UPDATE users
SET email = $1, email_verified = $2, trolley_account_status = $3
WHERE id = $4
",
recipient.email.clone(),
user.email_verified && recipient.email == user.email,
recipient.status.map(|x| x.as_str()),
user.id.0
)
.execute(&mut transaction).await?;
}
transaction.commit().await?;
User::clear_caches(&[(user.id, None)], &redis).await?;
}
}
}
}
}
if webhook.model == "payment" {
#[derive(Deserialize)]
struct Payment {
pub id: String,
pub status: PayoutStatus,
}
if let Some(body) = webhook.body.get("payment") {
if let Ok(payment) = serde_json::from_value::<Payment>(body.clone()) {
let value = sqlx::query!(
"SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1",
payment.id
)
.fetch_optional(&**pool)
.await?;
if let Some(payout) = value {
let mut transaction = pool.begin().await?;
if payment.status.is_failed()
&& !PayoutStatus::from_string(&payout.status).is_failed()
{
sqlx::query!(
"
UPDATE users
SET balance = balance + $1
WHERE id = $2
",
payout.amount,
payout.user_id,
)
.execute(&mut transaction)
.await?;
}
sqlx::query!(
"
UPDATE historical_payouts
SET status = $1
WHERE payment_id = $2
",
payment.status.as_str(),
payment.id,
)
.execute(&mut transaction)
.await?;
transaction.commit().await?;
User::clear_caches(
&[(crate::database::models::UserId(payout.user_id), None)],
&redis,
)
.await?;
}
}
}
}
}
}
Ok(HttpResponse::NoContent().finish())
}

View File

@ -32,7 +32,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(moderation::config) .configure(moderation::config)
.configure(notifications::config) .configure(notifications::config)
.configure(organizations::config) .configure(organizations::config)
//.configure(pats::config)
.configure(project_creation::config) .configure(project_creation::config)
.configure(collections::config) .configure(collections::config)
.configure(images::config) .configure(images::config)

View File

@ -1,4 +1,4 @@
use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::auth::get_user_from_headers;
use crate::database::models::User; use crate::database::models::User;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost; use crate::file_hosting::FileHost;
@ -6,14 +6,15 @@ use crate::models::collections::{Collection, CollectionStatus};
use crate::models::notifications::Notification; use crate::models::notifications::Notification;
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
use crate::models::projects::Project; use crate::models::projects::Project;
use crate::models::users::{Badges, RecipientType, RecipientWallet, Role, UserId}; use crate::models::users::{
use crate::queue::payouts::{PayoutAmount, PayoutItem, PayoutsQueue}; Badges, Payout, PayoutStatus, RecipientStatus, Role, UserId, UserPayoutData,
};
use crate::queue::payouts::PayoutsQueue;
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::routes::ApiError; use crate::routes::ApiError;
use crate::util::routes::read_from_payload; use crate::util::routes::read_from_payload;
use crate::util::validate::validation_errors_to_string; use crate::util::validate::validation_errors_to_string;
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex; use regex::Regex;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@ -39,6 +40,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(user_notifications) .service(user_notifications)
.service(user_follows) .service(user_follows)
.service(user_payouts) .service(user_payouts)
.service(user_payouts_fees)
.service(user_payouts_request), .service(user_payouts_request),
); );
} }
@ -218,21 +220,6 @@ pub struct EditUser {
pub bio: Option<Option<String>>, pub bio: Option<Option<String>>,
pub role: Option<Role>, pub role: Option<Role>,
pub badges: Option<Badges>, pub badges: Option<Badges>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate]
pub payout_data: Option<Option<EditPayoutData>>,
}
#[derive(Serialize, Deserialize, Validate)]
pub struct EditPayoutData {
pub payout_wallet: RecipientWallet,
pub payout_wallet_type: RecipientType,
#[validate(length(max = 128))]
pub payout_address: String,
} }
#[patch("{id}")] #[patch("{id}")]
@ -244,7 +231,7 @@ pub async fn user_edit(
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let (scopes, user) = get_user_from_headers( let (_scopes, user) = get_user_from_headers(
&req, &req,
&**pool, &**pool,
&redis, &redis,
@ -364,79 +351,6 @@ pub async fn user_edit(
.await?; .await?;
} }
if let Some(payout_data) = &new_user.payout_data {
if let Some(payout_data) = payout_data {
if payout_data.payout_wallet_type == RecipientType::UserHandle
&& payout_data.payout_wallet == RecipientWallet::Paypal
{
return Err(ApiError::InvalidInput(
"You cannot use a paypal wallet with a user handle!".to_string(),
));
}
if !scopes.contains(Scopes::PAYOUTS_WRITE) {
return Err(ApiError::Authentication(
AuthenticationError::InvalidCredentials,
));
}
if !match payout_data.payout_wallet_type {
RecipientType::Email => {
validator::validate_email(&payout_data.payout_address)
}
RecipientType::Phone => {
validator::validate_phone(&payout_data.payout_address)
}
RecipientType::UserHandle => true,
} {
return Err(ApiError::InvalidInput(
"Invalid wallet specified!".to_string(),
));
}
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email IS NULL)
",
id as crate::database::models::ids::UserId,
)
.fetch_one(&mut *transaction)
.await?;
if results.exists.unwrap_or(false) {
return Err(ApiError::InvalidInput(
"You must have an email set on your Modrinth account to enroll in the monetization program!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE users
SET payout_wallet = $1, payout_wallet_type = $2, payout_address = $3
WHERE (id = $4)
",
payout_data.payout_wallet.as_str(),
payout_data.payout_wallet_type.as_str(),
payout_data.payout_address,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
} else {
sqlx::query!(
"
UPDATE users
SET payout_wallet = NULL, payout_wallet_type = NULL, payout_address = NULL
WHERE (id = $1)
",
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
}
User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?; User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?;
transaction.commit().await?; transaction.commit().await?;
Ok(HttpResponse::NoContent().body("")) Ok(HttpResponse::NoContent().body(""))
@ -691,13 +605,6 @@ pub async fn user_notifications(
} }
} }
#[derive(Serialize)]
pub struct Payout {
pub created: DateTime<Utc>,
pub amount: Decimal,
pub status: String,
}
#[get("{id}/payouts")] #[get("{id}/payouts")]
pub async fn user_payouts( pub async fn user_payouts(
req: HttpRequest, req: HttpRequest,
@ -757,7 +664,7 @@ pub async fn user_payouts(
Ok(e.right().map(|row| Payout { Ok(e.right().map(|row| Payout {
created: row.created, created: row.created,
amount: row.amount, amount: row.amount,
status: row.status, status: PayoutStatus::from_string(&row.status),
})) }))
}) })
.try_collect::<Vec<Payout>>(), .try_collect::<Vec<Payout>>(),
@ -776,6 +683,61 @@ pub async fn user_payouts(
} }
} }
#[derive(Deserialize)]
pub struct FeeEstimateAmount {
amount: Decimal,
}
#[get("{id}/payouts_fees")]
pub async fn user_payouts_fees(
req: HttpRequest,
info: web::Path<(String,)>,
web::Query(amount): web::Query<FeeEstimateAmount>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
)
.await?
.1;
let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = actual_user {
if !user.role.is_admin() && user.id != actual_user.id.into() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to request payouts of this user!".to_string(),
));
}
if let Some(UserPayoutData {
trolley_id: Some(trolley_id),
..
}) = user.payout_data
{
let payouts = payouts_queue
.lock()
.await
.get_estimated_fees(&trolley_id, amount.amount)
.await?;
Ok(HttpResponse::Ok().json(payouts))
} else {
Err(ApiError::InvalidInput(
"You must set up your trolley account first!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PayoutData { pub struct PayoutData {
amount: Decimal, amount: Decimal,
@ -811,40 +773,30 @@ pub async fn user_payouts_request(
)); ));
} }
if let Some(payouts_data) = user.payout_data { if let Some(UserPayoutData {
if let Some(payout_address) = payouts_data.payout_address { trolley_id: Some(trolley_id),
if let Some(payout_wallet_type) = payouts_data.payout_wallet_type { trolley_status: Some(trolley_status),
if let Some(payout_wallet) = payouts_data.payout_wallet { balance,
return if data.amount < payouts_data.balance { ..
}) = user.payout_data
{
if trolley_status == RecipientStatus::Active {
return if data.amount < balance {
let mut transaction = pool.begin().await?; let mut transaction = pool.begin().await?;
let leftover = payouts_queue let (batch_id, payment_id) =
.send_payout(PayoutItem { payouts_queue.send_payout(&trolley_id, data.amount).await?;
amount: PayoutAmount {
currency: "USD".to_string(),
value: data.amount,
},
receiver: payout_address,
note: "Payment from Modrinth creator monetization program"
.to_string(),
recipient_type: payout_wallet_type.to_string().to_uppercase(),
recipient_wallet: payout_wallet.as_str_api().to_string(),
sender_item_id: format!(
"{}-{}",
UserId::from(id),
Utc::now().timestamp()
),
})
.await?;
sqlx::query!( sqlx::query!(
" "
INSERT INTO historical_payouts (user_id, amount, status) INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4, $5)
", ",
id as crate::database::models::ids::UserId, id as crate::database::models::ids::UserId,
data.amount, data.amount,
"success" "processing",
batch_id,
payment_id,
) )
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
@ -855,11 +807,12 @@ pub async fn user_payouts_request(
SET balance = balance - $1 SET balance = balance - $1
WHERE id = $2 WHERE id = $2
", ",
data.amount - leftover, data.amount,
id as crate::database::models::ids::UserId id as crate::database::models::ids::UserId
) )
.execute(&mut *transaction) .execute(&mut *transaction)
.await?; .await?;
User::clear_caches(&[(id, None)], &redis).await?; User::clear_caches(&[(id, None)], &redis).await?;
transaction.commit().await?; transaction.commit().await?;
@ -870,8 +823,10 @@ pub async fn user_payouts_request(
"You do not have enough funds to make this payout!".to_string(), "You do not have enough funds to make this payout!".to_string(),
)) ))
}; };
} } else {
} return Err(ApiError::InvalidInput(
"Please complete payout information via the trolley dashboard!".to_string(),
));
} }
} }

View File

@ -5,6 +5,12 @@ pub struct Scheduler {
arbiter: Arbiter, arbiter: Arbiter,
} }
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}
impl Scheduler { impl Scheduler {
pub fn new() -> Self { pub fn new() -> Self {
Scheduler { Scheduler {

18
src/util/bitflag.rs Normal file
View File

@ -0,0 +1,18 @@
#[macro_export]
macro_rules! bitflags_serde_impl {
($type:ident, $int_type:ident) => {
impl serde::Serialize for $type {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_i64(self.bits() as i64)
}
}
impl<'de> serde::Deserialize<'de> for $type {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let v: i64 = Deserialize::deserialize(deserializer)?;
Ok($type::from_bits_truncate(v as $int_type))
}
}
};
}

9
src/util/date.rs Normal file
View File

@ -0,0 +1,9 @@
use chrono::Utc;
// this converts timestamps to the timestamp format clickhouse requires/uses
pub fn get_current_tenths_of_ms() -> i64 {
Utc::now()
.timestamp_nanos_opt()
.expect("value can not be represented in a timestamp with nanosecond precision.")
/ 100_000
}

View File

@ -1,5 +1,7 @@
pub mod bitflag;
pub mod captcha; pub mod captcha;
pub mod cors; pub mod cors;
pub mod date;
pub mod env; pub mod env;
pub mod ext; pub mod ext;
pub mod guards; pub mod guards;

View File

@ -20,7 +20,7 @@ impl super::Validator for FabricValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions { fn get_supported_game_versions(&self) -> SupportedGameVersions {
// Time since release of 18w49a, the first fabric version // Time since release of 18w49a, the first fabric version
SupportedGameVersions::PastDate(DateTime::from_utc( SupportedGameVersions::PastDate(DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1543969469, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1543969469, 0).unwrap(),
Utc, Utc,
)) ))

View File

@ -20,7 +20,7 @@ impl super::Validator for ForgeValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions { fn get_supported_game_versions(&self) -> SupportedGameVersions {
// Time since release of 1.13, the first forge version which uses the new TOML system // Time since release of 1.13, the first forge version which uses the new TOML system
SupportedGameVersions::PastDate(DateTime::<Utc>::from_utc( SupportedGameVersions::PastDate(DateTime::<Utc>::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1540122067, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1540122067, 0).unwrap(),
Utc, Utc,
)) ))
@ -58,11 +58,11 @@ impl super::Validator for LegacyForgeValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions { fn get_supported_game_versions(&self) -> SupportedGameVersions {
// Times between versions 1.5.2 to 1.12.2, which all use the legacy way of defining mods // Times between versions 1.5.2 to 1.12.2, which all use the legacy way of defining mods
SupportedGameVersions::Range( SupportedGameVersions::Range(
DateTime::from_utc( DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1366818300, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1366818300, 0).unwrap(),
Utc, Utc,
), ),
DateTime::from_utc( DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1505810340, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1505810340, 0).unwrap(),
Utc, Utc,
), ),

View File

@ -19,7 +19,7 @@ impl super::Validator for QuiltValidator {
} }
fn get_supported_game_versions(&self) -> SupportedGameVersions { fn get_supported_game_versions(&self) -> SupportedGameVersions {
SupportedGameVersions::PastDate(DateTime::from_utc( SupportedGameVersions::PastDate(DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1646070100, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1646070100, 0).unwrap(),
Utc, Utc,
)) ))

View File

@ -20,7 +20,7 @@ impl super::Validator for PackValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions { fn get_supported_game_versions(&self) -> SupportedGameVersions {
// Time since release of 13w24a which replaced texture packs with resource packs // Time since release of 13w24a which replaced texture packs with resource packs
SupportedGameVersions::PastDate(DateTime::from_utc( SupportedGameVersions::PastDate(DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1371137542, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1371137542, 0).unwrap(),
Utc, Utc,
)) ))
@ -58,11 +58,11 @@ impl super::Validator for TexturePackValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions { fn get_supported_game_versions(&self) -> SupportedGameVersions {
// a1.2.2a to 13w23b // a1.2.2a to 13w23b
SupportedGameVersions::Range( SupportedGameVersions::Range(
DateTime::from_utc( DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1289339999, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1289339999, 0).unwrap(),
Utc, Utc,
), ),
DateTime::from_utc( DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1370651522, 0).unwrap(), NaiveDateTime::from_timestamp_opt(1370651522, 0).unwrap(),
Utc, Utc,
), ),

View File

@ -70,24 +70,6 @@ async fn user_scopes() {
.await .await
.unwrap(); .unwrap();
// User payout info writing
let failure_write_user_payout = Scopes::all() ^ Scopes::PAYOUTS_WRITE; // Failure case should include USER_WRITE
let write_user_payout = Scopes::USER_WRITE | Scopes::PAYOUTS_WRITE;
let req_gen = || {
TestRequest::patch().uri("/v2/user/user").set_json(json!( {
"payout_data": {
"payout_wallet": "paypal",
"payout_wallet_type": "email",
"payout_address": "test@modrinth.com"
}
}))
};
ScopeTest::new(&test_env)
.with_failure_scopes(failure_write_user_payout)
.test(req_gen, write_user_payout)
.await
.unwrap();
// User deletion // User deletion
// (The failure is first, and this is the last test for this test function, we can delete it and use the same PAT for both tests) // (The failure is first, and this is the last test for this test function, we can delete it and use the same PAT for both tests)
let delete_user = Scopes::USER_DELETE; let delete_user = Scopes::USER_DELETE;