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"]'
PAYPAL_API_URL=https://api-m.sandbox.paypal.com/v1/
PAYPAL_CLIENT_ID=none
PAYPAL_CLIENT_SECRET=none
TROLLEY_ACCESS_KEY=none
TROLLEY_SECRET_KEY=none
TROLLEY_WEBHOOK_SIGNATURE=none
GITHUB_CLIENT_ID=none
GITHUB_CLIENT_SECRET=none

4
.idea/labrinth.iml generated
View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="CPP_MODULE" version="4">
<component name="NewModuleRootManager">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" 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/value-bag-61e3e358d251e1a5/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" />
</content>
<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"
hmac = "0.11.0"
argon2 = { version = "0.5.0", features = ["std"] }
bitflags = "1.3.2"
bitflags = "2.4.0"
hex = "0.4.3"
zxcvbn = "2.2.2"
totp-rs = { version = "5.0.2", features = ["gen_secret"] }
@ -96,7 +96,7 @@ maxminddb = "0.23.0"
flate2 = "1.0.25"
tar = "0.4.38"
sentry = { version = "0.31.5", features = ["profiling"] }
sentry = { version = "0.31.5" }
sentry-actix = "0.31.5"
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 "
},
"03284fe5b045e2cf93f160863c4d121439382b348b728fffb5ac588dee980731": {
"04128dd06489004e0d0305bfd0f4ca5ee4b4a6b9f610de6e1b9ef9c8543cc025": {
"describe": {
"columns": [
{
"name": "exists",
"name": "id",
"ordinal": 0,
"type_info": "Bool"
"type_info": "Int8"
}
],
"nullable": [
null
false
],
"parameters": {
"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": {
"describe": {
@ -1609,6 +1609,18 @@
},
"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": {
"describe": {
"columns": [
@ -1756,6 +1768,20 @@
},
"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": {
"describe": {
"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 "
},
"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": {
"describe": {
"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 "
},
"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": {
"describe": {
"columns": [],
@ -2706,153 +2734,6 @@
},
"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": {
"describe": {
"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 "
},
"8cbd74dad7a21128d99fd32b430c2e0427480f910e1f125ff56b893c67a6e8a4": {
"8f45a48700b8836f4ba8626b25b7be7f838d35d260430a46817729d9787e2013": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Text",
"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": {
"describe": {
@ -4188,6 +4069,19 @@
},
"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": {
"describe": {
"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 "
},
"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": {
"describe": {
"columns": [],
@ -6051,6 +5933,44 @@
},
"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": {
"describe": {
"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 "
},
"f141cc6711123b4fe5a5d9a7337a0b009b80e5d8fbda664b8d62b1a3f38eb936": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Numeric",
"Int8"
]
}
},
"query": "\n UPDATE users\n SET balance = balance + $1\n WHERE id = $2\n "
},
"f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01": {
"describe": {
"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 "
},
"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": {
"describe": {
"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::random_base62_rng;
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::socket::ActiveSockets;
use crate::routes::ApiError;
@ -30,7 +31,7 @@ use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::{Mutex, RwLock};
use validator::Validate;
pub fn config(cfg: &mut ServiceConfig) {
@ -51,7 +52,8 @@ pub fn config(cfg: &mut ServiceConfig) {
.service(resend_verify_email)
.service(set_email)
.service(verify_email)
.service(subscribe_newsletter),
.service(subscribe_newsletter)
.service(link_trolley),
);
}
@ -225,9 +227,8 @@ impl TempUser {
role: Role::Developer.to_string(),
badges: Badges::default(),
balance: Decimal::ZERO,
payout_wallet: None,
payout_wallet_type: None,
payout_address: None,
trolley_id: None,
trolley_account_status: None,
}
.insert(transaction)
.await?;
@ -1013,7 +1014,7 @@ pub async fn auth_callback(
let sockets = active_sockets.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?;
@ -1175,7 +1176,7 @@ pub async fn auth_callback(
} else {
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
if let Err(ref e) = res {
@ -1385,9 +1386,8 @@ pub async fn create_account_with_password(
role: Role::Developer.to_string(),
badges: Badges::default(),
balance: Decimal::ZERO,
payout_wallet: None,
payout_wallet_type: None,
payout_address: None,
trolley_id: None,
trolley_account_status: None,
}
.insert(&mut transaction)
.await?;
@ -2011,6 +2011,7 @@ pub async fn set_email(
redis: Data<RedisPool>,
email: web::Json<SetEmail>,
session_queue: Data<AuthQueue>,
payouts_queue: Data<Mutex<PayoutsQueue>>,
) -> Result<HttpResponse, ApiError> {
email
.0
@ -2064,6 +2065,17 @@ pub async fn set_email(
"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?;
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))),
)
}
#[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,
role: Role::from_string(&db_user.role),
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),
has_password: Some(db_user.password.is_some()),
has_totp: Some(db_user.totp_secret.is_some()),
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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ use super::CollectionId;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
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 rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@ -35,10 +35,10 @@ pub struct User {
pub created: DateTime<Utc>,
pub role: String,
pub badges: Badges,
pub balance: Decimal,
pub payout_wallet: Option<RecipientWallet>,
pub payout_wallet_type: Option<RecipientType>,
pub payout_address: Option<String>,
pub trolley_id: Option<String>,
pub trolley_account_status: Option<RecipientStatus>,
}
impl User {
@ -188,9 +188,9 @@ impl User {
SELECT id, name, email,
avatar_url, username, bio,
created, role, badges,
balance, payout_wallet, payout_wallet_type, payout_address,
balance,
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
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
",
@ -220,13 +220,13 @@ impl User {
role: u.role,
badges: Badges::from_bits(u.badges as u64).unwrap_or_default(),
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,
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>>()
@ -352,7 +352,7 @@ impl User {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
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())),
(

View File

@ -590,9 +590,9 @@ impl Version {
downloads: v.downloads,
version_type: v.version_type,
featured: v.featured,
status: VersionStatus::from_str(&v.status),
status: VersionStatus::from_string(&v.status),
requested_status: v.requested_status
.map(|x| VersionStatus::from_str(&x)),
.map(|x| VersionStatus::from_string(&x)),
},
files: {
#[derive(Deserialize)]
@ -803,7 +803,7 @@ impl Version {
.unwrap_or_default().into_iter().map(|x| (x.algorithm, x.hash)).collect(),
primary: f.is_primary,
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 sha2::Digest;
#[derive(Default)]
pub struct MockHost(());
impl MockHost {

View File

@ -365,9 +365,9 @@ pub fn check_env_vars() -> bool {
failed |= true;
}
failed |= check_var::<String>("PAYPAL_API_URL");
failed |= check_var::<String>("PAYPAL_CLIENT_ID");
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET");
failed |= check_var::<String>("TROLLEY_ACCESS_KEY");
failed |= check_var::<String>("TROLLEY_SECRET_KEY");
failed |= check_var::<String>("TROLLEY_WEBHOOK_SIGNATURE");
failed |= check_var::<String>("GITHUB_CLIENT_ID");
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 {
release: sentry::release_name!(),
traces_sample_rate: 0.1,
enable_profiling: true,
profiles_sample_rate: 0.1,
..Default::default()
});
if sentry.is_enabled() {

View File

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

View File

@ -1,4 +1,5 @@
use super::ids::Base62Id;
use crate::bitflags_serde_impl;
use crate::models::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@ -10,8 +11,7 @@ use serde::{Deserialize, Serialize};
pub struct PatId(pub u64);
bitflags::bitflags! {
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
#[derive(Copy, Clone, Debug)]
pub struct Scopes: u64 {
// read a user's email
const USER_READ_EMAIL = 1 << 0;
@ -107,6 +107,8 @@ bitflags::bitflags! {
}
}
bitflags_serde_impl!(Scopes, u64);
impl Scopes {
// these scopes cannot be specified in a personal access token
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 {
"required" => SideType::Required,
"optional" => SideType::Optional,
@ -308,7 +308,7 @@ impl std::fmt::Display for ProjectStatus {
}
impl ProjectStatus {
pub fn from_str(string: &str) -> ProjectStatus {
pub fn from_string(string: &str) -> ProjectStatus {
match string {
"processing" => ProjectStatus::Processing,
"rejected" => ProjectStatus::Rejected,
@ -433,7 +433,7 @@ impl std::fmt::Display for MonetizationStatus {
}
impl MonetizationStatus {
pub fn from_str(string: &str) -> MonetizationStatus {
pub fn from_string(string: &str) -> MonetizationStatus {
match string {
"force-demonetized" => MonetizationStatus::ForceDemonetized,
"demonetized" => MonetizationStatus::Demonetized,
@ -537,7 +537,7 @@ impl From<QueryVersion> for Version {
version_id: d.version_id.map(|i| VersionId(i.0 as u64)),
project_id: d.project_id.map(|i| ProjectId(i.0 as u64)),
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(),
game_versions: data.game_versions.into_iter().map(GameVersion).collect(),
@ -570,7 +570,7 @@ impl std::fmt::Display for VersionStatus {
}
impl VersionStatus {
pub fn from_str(string: &str) -> VersionStatus {
pub fn from_string(string: &str) -> VersionStatus {
match string {
"listed" => VersionStatus::Listed,
"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 {
"required" => DependencyType::Required,
"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 {
"required-resource-pack" => FileType::RequiredResourcePack,
"optional-resource-pack" => FileType::OptionalResourcePack,

View File

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

View File

@ -1,5 +1,6 @@
use super::ids::Base62Id;
use crate::auth::flows::AuthProvider;
use crate::bitflags_serde_impl;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@ -12,8 +13,7 @@ pub struct UserId(pub u64);
pub const DELETED_USER: UserId = UserId(127155982985829);
bitflags::bitflags! {
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
#[derive(Copy, Clone, Debug)]
pub struct Badges: u64 {
// 1 << 0 unused - ignore + replace with something later
const MIDAS = 1 << 0;
@ -29,6 +29,8 @@ bitflags::bitflags! {
}
}
bitflags_serde_impl!(Badges, u64);
impl Default for Badges {
fn default() -> Badges {
Badges::NONE
@ -46,12 +48,12 @@ pub struct User {
pub role: Role,
pub badges: Badges,
pub payout_data: Option<UserPayoutData>,
pub auth_providers: Option<Vec<AuthProvider>>,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub has_password: Option<bool>,
pub has_totp: Option<bool>,
pub payout_data: Option<UserPayoutData>,
// DEPRECATED. Always returns None
pub github_id: Option<u64>,
@ -60,77 +62,8 @@ pub struct User {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserPayoutData {
pub balance: Decimal,
pub payout_wallet: Option<RecipientWallet>,
pub payout_wallet_type: Option<RecipientType>,
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",
}
}
pub trolley_id: Option<String>,
pub trolley_status: Option<RecipientStatus>,
}
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>,
}
impl Default for AnalyticsQueue {
fn default() -> Self {
Self::new()
}
}
// Batches analytics data points + transactions every few minutes
impl AnalyticsQueue {
pub fn new() -> Self {

View File

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

View File

@ -1,203 +1,332 @@
use crate::routes::ApiError;
use crate::util::env::parse_var;
use crate::{database::redis::RedisPool, models::projects::MonetizationStatus};
use base64::Engine;
use chrono::{DateTime, Datelike, Duration, Utc, Weekday};
use hex::ToHex;
use hmac::{Hmac, Mac, NewMac};
use reqwest::Method;
use rust_decimal::Decimal;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::{json, Value};
use sha2::Sha256;
use sqlx::PgPool;
use std::collections::HashMap;
pub struct PayoutsQueue {
credential: PaypalCredential,
credential_expires: DateTime<Utc>,
access_key: String,
secret_key: String,
}
#[derive(Deserialize, Default)]
struct PaypalCredential {
access_token: String,
token_type: String,
expires_in: i64,
impl Default for PayoutsQueue {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AccountUser {
Business { name: String },
Individual { first: String, last: String },
}
#[derive(Serialize)]
pub struct PayoutItem {
pub amount: PayoutAmount,
pub receiver: String,
pub note: String,
pub recipient_type: String,
pub recipient_wallet: String,
pub sender_item_id: String,
}
#[derive(Serialize, Deserialize)]
pub struct PayoutAmount {
pub currency: String,
#[serde(with = "rust_decimal::serde::str")]
pub value: Decimal,
pub struct PaymentInfo {
country: String,
payout_method: String,
route_minimum: Decimal,
estimated_fees: Decimal,
deduct_fees: Decimal,
}
// Batches payouts and handles token refresh
impl PayoutsQueue {
pub fn new() -> Self {
PayoutsQueue {
credential: Default::default(),
credential_expires: Utc::now() - Duration::days(30),
access_key: dotenvy::var("TROLLEY_ACCESS_KEY").expect("missing trolley access key"),
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 combined_key = format!(
"{}:{}",
dotenvy::var("PAYPAL_CLIENT_ID")?,
dotenvy::var("PAYPAL_CLIENT_SECRET")?
);
let formatted_key = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(combined_key)
);
let mut request = client
.request(method, format!("https://api.trolley.com{path}"))
.header(
"Authorization",
format!("prsign {}:{}", self.access_key, request_signature),
)
.header("X-PR-Timestamp", timestamp);
let mut form = HashMap::new();
form.insert("grant_type", "client_credentials");
let credential: PaypalCredential = client
.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()
.await
.map_err(|_| ApiError::Payments("Error while authenticating with PayPal".to_string()))?
.json()
.await
.map_err(|_| {
ApiError::Payments(
"Error while authenticating with PayPal (deser error)".to_string(),
)
})?;
self.credential_expires = Utc::now() + Duration::seconds(credential.expires_in);
self.credential = credential;
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())
})?;
if let Some(body) = body {
request = request.json(&body);
}
let wallet = payout.recipient_wallet.clone();
let resp = request
.send()
.await
.map_err(|_| ApiError::Payments("could not communicate with Trolley".to_string()))?;
let fee = if wallet == *"Venmo" {
Decimal::ONE / Decimal::from(4)
} else {
std::cmp::min(
std::cmp::max(
Decimal::ONE / Decimal::from(4),
(Decimal::from(2) / Decimal::ONE_HUNDRED) * payout.amount.value,
),
Decimal::from(20),
)
};
let value = resp.json::<Value>().await.map_err(|_| {
ApiError::Payments("could not retrieve Trolley response body".to_string())
})?;
payout.amount.value -= fee;
payout.amount.value = payout.amount.value.round_dp(2);
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 payout.amount.value <= Decimal::ZERO {
return Err(ApiError::InvalidInput(
"You do not have enough funds to make this payout!".to_string(),
if let Some(array) = obj.get("errors") {
let err = serde_json::from_value::<Vec<TrolleyError>>(array.clone()).map_err(
|_| {
ApiError::Payments(
"could not retrieve Trolley error json body".to_string(),
)
},
)?;
if let Some(first) = err.into_iter().next() {
return Err(ApiError::Payments(if let Some(field) = &first.field {
format!("error - field: {field} message: {}", first.message)
} 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 client = reqwest::Client::new();
let send_amount = amount - fee.deduct_fees;
let res = client.post(&format!("{}payments/payouts", dotenvy::var("PAYPAL_API_URL")?))
.header("Authorization", format!("{} {}", self.credential.token_type, self.credential.access_token))
.json(&json! ({
"sender_batch_header": {
"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()))?;
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?;
if !res.status().is_success() {
#[derive(Deserialize)]
struct PayPalError {
pub body: PayPalErrorBody,
}
self.make_trolley_request::<Value, Value>(
Method::POST,
&format!("/v1/batches/{}/start-processing", res.batch.id),
None,
)
.await?;
#[derive(Deserialize)]
struct PayPalErrorBody {
pub message: String,
}
let payment_id = res.batch.payments.payments.into_iter().next().map(|x| x.id);
let body: PayPalError = res.json().await.map_err(|_| {
ApiError::Payments("Error while registering payment in PayPal!".to_string())
})?;
Ok((res.batch.id, payment_id))
}
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
{
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);
}
}
}
}
}
}
pub async fn register_recipient(
&self,
email: &str,
user: AccountUser,
) -> Result<String, ApiError> {
#[derive(Deserialize)]
struct TrolleyRes {
recipient: Recipient,
}
Ok(Decimal::ZERO)
#[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::max(
Decimal::ONE / Decimal::from(4),
(Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
),
Decimal::from(1),
)
} else {
std::cmp::min(
(Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
Decimal::from(20),
)
};
Ok(PaymentInfo {
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);
Ok(PaymentInfo {
country: id.recipient.address.country,
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,
})
}
}
pub async fn update_recipient_email(&self, id: &str, email: &str) -> Result<(), ApiError> {
self.make_trolley_request::<_, Value>(
Method::PATCH,
&format!("/v1/recipients/{}", id),
Some(json!({
"email": email,
})),
)
.await?;
Ok(())
}
}
@ -206,7 +335,7 @@ pub async fn process_payout(
redis: &RedisPool,
client: &clickhouse::Client,
) -> Result<(), ApiError> {
let start: DateTime<Utc> = DateTime::from_utc(
let start: DateTime<Utc> = DateTime::from_naive_utc_and_offset(
(Utc::now() - Duration::days(1))
.date_naive()
.and_hms_nano_opt(0, 0, 0, 0)

View File

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

View File

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

View File

@ -6,10 +6,10 @@ use crate::queue::analytics::AnalyticsQueue;
use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::env::parse_strings_from_var;
use actix_web::{post, web};
use actix_web::{HttpRequest, HttpResponse};
use chrono::Utc;
use serde::Deserialize;
use sqlx::PgPool;
use std::collections::HashMap;
@ -108,7 +108,7 @@ pub async fn page_view_ingest(
let mut view = PageView {
id: Uuid::new_v4(),
recorded: Utc::now().timestamp_nanos() / 100_000,
recorded: get_current_tenths_of_ms(),
domain: domain.to_string(),
site_path: url.path().to_string(),
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()) {
analytics_queue.add_playtime(Playtime {
id: Default::default(),
recorded: Utc::now().timestamp_nanos() / 100_000,
recorded: get_current_tenths_of_ms(),
seconds: playtime.seconds as u64,
user_id: user.id.0,
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::database::models::User;
use crate::database::redis::RedisPool;
use crate::models::analytics::Download;
use crate::models::ids::ProjectId;
use crate::models::pats::Scopes;
use crate::models::users::{PayoutStatus, RecipientStatus};
use crate::queue::analytics::AnalyticsQueue;
use crate::queue::download::DownloadQueue;
use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::guards::admin_key_guard;
use actix_web::{patch, web, HttpRequest, HttpResponse};
use chrono::Utc;
use crate::util::routes::read_from_payload;
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
use hex::ToHex;
use hmac::{Hmac, Mac, NewMac};
use serde::Deserialize;
use sha2::Sha256;
use sqlx::PgPool;
use std::collections::HashMap;
use std::net::Ipv4Addr;
@ -19,7 +25,11 @@ use std::sync::Arc;
use uuid::Uuid;
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)]
@ -110,7 +120,7 @@ pub async fn count_download(
analytics_queue.add_download(Download {
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(),
site_path: url.path().to_string(),
user_id: user
@ -141,3 +151,171 @@ pub async fn count_download(
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(notifications::config)
.configure(organizations::config)
//.configure(pats::config)
.configure(project_creation::config)
.configure(collections::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::redis::RedisPool;
use crate::file_hosting::FileHost;
@ -6,14 +6,15 @@ use crate::models::collections::{Collection, CollectionStatus};
use crate::models::notifications::Notification;
use crate::models::pats::Scopes;
use crate::models::projects::Project;
use crate::models::users::{Badges, RecipientType, RecipientWallet, Role, UserId};
use crate::queue::payouts::{PayoutAmount, PayoutItem, PayoutsQueue};
use crate::models::users::{
Badges, Payout, PayoutStatus, RecipientStatus, Role, UserId, UserPayoutData,
};
use crate::queue::payouts::PayoutsQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::routes::read_from_payload;
use crate::util::validate::validation_errors_to_string;
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use lazy_static::lazy_static;
use regex::Regex;
use rust_decimal::Decimal;
@ -39,6 +40,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(user_notifications)
.service(user_follows)
.service(user_payouts)
.service(user_payouts_fees)
.service(user_payouts_request),
);
}
@ -218,21 +220,6 @@ pub struct EditUser {
pub bio: Option<Option<String>>,
pub role: Option<Role>,
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}")]
@ -244,7 +231,7 @@ pub async fn user_edit(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (scopes, user) = get_user_from_headers(
let (_scopes, user) = get_user_from_headers(
&req,
&**pool,
&redis,
@ -364,79 +351,6 @@ pub async fn user_edit(
.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?;
transaction.commit().await?;
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")]
pub async fn user_payouts(
req: HttpRequest,
@ -757,7 +664,7 @@ pub async fn user_payouts(
Ok(e.right().map(|row| Payout {
created: row.created,
amount: row.amount,
status: row.status,
status: PayoutStatus::from_string(&row.status),
}))
})
.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)]
pub struct PayoutData {
amount: Decimal,
@ -811,67 +773,60 @@ pub async fn user_payouts_request(
));
}
if let Some(payouts_data) = user.payout_data {
if let Some(payout_address) = payouts_data.payout_address {
if let Some(payout_wallet_type) = payouts_data.payout_wallet_type {
if let Some(payout_wallet) = payouts_data.payout_wallet {
return if data.amount < payouts_data.balance {
let mut transaction = pool.begin().await?;
if let Some(UserPayoutData {
trolley_id: Some(trolley_id),
trolley_status: Some(trolley_status),
balance,
..
}) = user.payout_data
{
if trolley_status == RecipientStatus::Active {
return if data.amount < balance {
let mut transaction = pool.begin().await?;
let leftover = payouts_queue
.send_payout(PayoutItem {
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?;
let (batch_id, payment_id) =
payouts_queue.send_payout(&trolley_id, data.amount).await?;
sqlx::query!(
sqlx::query!(
"
INSERT INTO historical_payouts (user_id, amount, status)
VALUES ($1, $2, $3)
INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)
VALUES ($1, $2, $3, $4, $5)
",
id as crate::database::models::ids::UserId,
data.amount,
"success"
"processing",
batch_id,
payment_id,
)
.execute(&mut *transaction)
.await?;
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
sqlx::query!(
"
UPDATE users
SET balance = balance - $1
WHERE id = $2
",
data.amount - leftover,
id as crate::database::models::ids::UserId
)
.execute(&mut *transaction)
.await?;
User::clear_caches(&[(id, None)], &redis).await?;
data.amount,
id as crate::database::models::ids::UserId
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
User::clear_caches(&[(id, None)], &redis).await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(
"You do not have enough funds to make this payout!".to_string(),
))
};
}
}
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(
"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,
}
impl Default for Scheduler {
fn default() -> Self {
Self::new()
}
}
impl Scheduler {
pub fn new() -> Self {
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 cors;
pub mod date;
pub mod env;
pub mod ext;
pub mod guards;

View File

@ -20,7 +20,7 @@ impl super::Validator for FabricValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions {
// 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(),
Utc,
))

View File

@ -20,7 +20,7 @@ impl super::Validator for ForgeValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions {
// 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(),
Utc,
))
@ -58,11 +58,11 @@ impl super::Validator for LegacyForgeValidator {
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
SupportedGameVersions::Range(
DateTime::from_utc(
DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1366818300, 0).unwrap(),
Utc,
),
DateTime::from_utc(
DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1505810340, 0).unwrap(),
Utc,
),

View File

@ -19,7 +19,7 @@ impl super::Validator for QuiltValidator {
}
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(),
Utc,
))

View File

@ -20,7 +20,7 @@ impl super::Validator for PackValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions {
// 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(),
Utc,
))
@ -58,11 +58,11 @@ impl super::Validator for TexturePackValidator {
fn get_supported_game_versions(&self) -> SupportedGameVersions {
// a1.2.2a to 13w23b
SupportedGameVersions::Range(
DateTime::from_utc(
DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1289339999, 0).unwrap(),
Utc,
),
DateTime::from_utc(
DateTime::from_naive_utc_and_offset(
NaiveDateTime::from_timestamp_opt(1370651522, 0).unwrap(),
Utc,
),

View File

@ -70,24 +70,6 @@ async fn user_scopes() {
.await
.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
// (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;