diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 23f8398ce..23cab0e42 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -102,6 +102,9 @@ TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/ TREMENDOUS_API_KEY=none TREMENDOUS_PRIVATE_KEY=none TREMENDOUS_CAMPAIGN_ID=none +TILTIFY_CLIENT_ID= +TILTIFY_CLIENT_SECRET= +TILTIFY_PRIDE_26_CAMPAIGN_ID= HCAPTCHA_SECRET=none diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 8f6c29d90..4a692b8f1 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -123,6 +123,9 @@ TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/ TREMENDOUS_API_KEY=none TREMENDOUS_PRIVATE_KEY=none TREMENDOUS_CAMPAIGN_ID=none +TILTIFY_CLIENT_ID= +TILTIFY_CLIENT_SECRET= +TILTIFY_PRIDE_26_CAMPAIGN_ID= HCAPTCHA_SECRET=none diff --git a/apps/labrinth/.sqlx/query-2248f0698a4195e8f1309d89d9ea78aeb06db699c469cdef0719486d9b938647.json b/apps/labrinth/.sqlx/query-2248f0698a4195e8f1309d89d9ea78aeb06db699c469cdef0719486d9b938647.json new file mode 100644 index 000000000..15b10697d --- /dev/null +++ b/apps/labrinth/.sqlx/query-2248f0698a4195e8f1309d89d9ea78aeb06db699c469cdef0719486d9b938647.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM campaign_donations WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2248f0698a4195e8f1309d89d9ea78aeb06db699c469cdef0719486d9b938647" +} diff --git a/apps/labrinth/.sqlx/query-2f883a2641f3276be6e57f7fcce0f1c7cc2ba03e09094750dc501df45d8421d5.json b/apps/labrinth/.sqlx/query-2f883a2641f3276be6e57f7fcce0f1c7cc2ba03e09094750dc501df45d8421d5.json new file mode 100644 index 000000000..d87fa6612 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2f883a2641f3276be6e57f7fcce0f1c7cc2ba03e09094750dc501df45d8421d5.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into campaign_donations (id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id)\n values ($1, $2::text::uuid, $3, $4, $5, $6)\n on conflict (tiltify_event_id) do nothing\n returning id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Jsonb", + "Timestamptz", + "Numeric", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2f883a2641f3276be6e57f7fcce0f1c7cc2ba03e09094750dc501df45d8421d5" +} diff --git a/apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json b/apps/labrinth/.sqlx/query-9139ada08ea017859b2928d2776f878ce9531daa7556fb0b4d2a467cafeaa130.json similarity index 76% rename from apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json rename to apps/labrinth/.sqlx/query-9139ada08ea017859b2928d2776f878ce9531daa7556fb0b4d2a467cafeaa130.json index 0c33202b9..5b308549b 100644 --- a/apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json +++ b/apps/labrinth/.sqlx/query-9139ada08ea017859b2928d2776f878ce9531daa7556fb0b4d2a467cafeaa130.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", + "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n AND campaign_donations.amount_usd > 5\n ) AS campaign_pride_26,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", "describe": { "columns": [ { @@ -50,81 +50,86 @@ }, { "ordinal": 9, + "name": "campaign_pride_26", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, "name": "github_id", "type_info": "Int8" }, { - "ordinal": 10, + "ordinal": 11, "name": "discord_id", "type_info": "Int8" }, { - "ordinal": 11, + "ordinal": 12, "name": "gitlab_id", "type_info": "Int8" }, { - "ordinal": 12, + "ordinal": 13, "name": "google_id", "type_info": "Varchar" }, { - "ordinal": 13, + "ordinal": 14, "name": "steam_id", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 15, "name": "microsoft_id", "type_info": "Varchar" }, { - "ordinal": 15, + "ordinal": 16, "name": "email_verified", "type_info": "Bool" }, { - "ordinal": 16, + "ordinal": 17, "name": "password", "type_info": "Text" }, { - "ordinal": 17, + "ordinal": 18, "name": "totp_secret", "type_info": "Varchar" }, { - "ordinal": 18, + "ordinal": 19, "name": "paypal_id", "type_info": "Text" }, { - "ordinal": 19, + "ordinal": 20, "name": "paypal_country", "type_info": "Text" }, { - "ordinal": 20, + "ordinal": 21, "name": "paypal_email", "type_info": "Text" }, { - "ordinal": 21, + "ordinal": 22, "name": "venmo_handle", "type_info": "Text" }, { - "ordinal": 22, + "ordinal": 23, "name": "stripe_customer_id", "type_info": "Text" }, { - "ordinal": 23, + "ordinal": 24, "name": "allow_friend_requests", "type_info": "Bool" }, { - "ordinal": 24, + "ordinal": 25, "name": "is_subscribed_to_newsletter", "type_info": "Bool" } @@ -145,6 +150,7 @@ false, false, false, + null, true, true, true, @@ -163,5 +169,5 @@ false ] }, - "hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4" + "hash": "9139ada08ea017859b2928d2776f878ce9531daa7556fb0b4d2a467cafeaa130" } diff --git a/apps/labrinth/migrations/20260527133330_pride_26_campaign.sql b/apps/labrinth/migrations/20260527133330_pride_26_campaign.sql new file mode 100644 index 000000000..4bf0cb3de --- /dev/null +++ b/apps/labrinth/migrations/20260527133330_pride_26_campaign.sql @@ -0,0 +1,10 @@ +create table campaign_donations ( + id bigint primary key, + tiltify_event_id uuid not null unique, + raw_data jsonb not null, + donated_at timestamptz not null, + amount_usd numeric(96, 48), + user_id bigint references users(id) +); +create index campaign_donations_user_amount_donated_at_idx +on campaign_donations (user_id, amount_usd, donated_at); diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index d88c60180..1ebb09b27 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -1,12 +1,12 @@ use super::DatabaseError; use crate::database::PgTransaction; use crate::models::ids::{ - AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId, - NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId, - OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId, - ProductId, ProductPriceId, ProjectId, ReportId, SessionId, - SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, - ThreadMessageId, UserSubscriptionId, VersionId, + AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId, + CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId, + OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, + OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId, + ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId, + TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId, }; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range}; @@ -164,6 +164,10 @@ db_id_interface!( ChargeId, generator: generate_charge_id @ "charges", ); +db_id_interface!( + CampaignDonationId, + generator: generate_campaign_donation_id @ "campaign_donations", +); db_id_interface!( CollectionId, generator: generate_collection_id @ "collections", diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index aa40574d6..234347aea 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -48,6 +48,8 @@ pub struct DBUser { pub created: DateTime, pub role: String, pub badges: Badges, + #[serde(default)] + pub campaign_pride_26: Option>, pub allow_friend_requests: bool, @@ -180,6 +182,12 @@ impl DBUser { SELECT id, email, avatar_url, raw_avatar_url, username, bio, created, role, badges, + ( + SELECT MAX(campaign_donations.donated_at) + FROM campaign_donations + WHERE campaign_donations.user_id = users.id + AND campaign_donations.amount_usd > 5 + ) AS campaign_pride_26, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email, venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter @@ -208,6 +216,7 @@ impl DBUser { created: u.created, role: u.role, badges: Badges::from_bits(u.badges as u64).unwrap_or_default(), + campaign_pride_26: u.campaign_pride_26, password: u.password, paypal_id: u.paypal_id, paypal_country: u.paypal_country, diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 526a9466c..581ed7113 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -295,6 +295,9 @@ vars! { DELPHI_SLACK_WEBHOOK: String = ""; TREMENDOUS_CAMPAIGN_ID: String = "none"; + TILTIFY_CLIENT_ID: String = ""; + TILTIFY_CLIENT_SECRET: String = ""; + TILTIFY_PRIDE_26_CAMPAIGN_ID: String = ""; // server pinging SERVER_PING_MAX_CONCURRENT: usize = 16usize; diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 15363ed09..a97ec86bd 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -26,6 +26,7 @@ use crate::util::anrok; use crate::util::archon::ArchonClient; use crate::util::http::HttpClient; use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters}; +use crate::util::tiltify::TiltifyClient; use sync::friends::handle_pubsub; pub mod auth; @@ -73,6 +74,7 @@ pub struct LabrinthConfig { pub archon_client: web::Data, pub gotenberg_client: GotenbergClient, pub http_client: web::Data, + pub tiltify_client: web::Data, } #[allow(clippy::too_many_arguments)] @@ -108,6 +110,8 @@ pub fn app_setup( let scheduler = scheduler::Scheduler::new(); let http_client = web::Data::new(HttpClient::new()); + let tiltify_client = + web::Data::new(TiltifyClient::new(http_client.get_ref().clone())); { let pool_ref = pool.clone(); let http_ref = http_client.clone(); @@ -312,6 +316,7 @@ pub fn app_setup( anrok_client, gotenberg_client, http_client, + tiltify_client, archon_client: web::Data::new( ArchonClient::from_env() .expect("ARCHON_URL and PYRO_API_KEY must be set"), @@ -343,6 +348,7 @@ pub fn app_config( .app_data(labrinth_config.search_backend.clone()) .app_data(web::Data::new(labrinth_config.gotenberg_client.clone())) .app_data(labrinth_config.http_client.clone()) + .app_data(labrinth_config.tiltify_client.clone()) .app_data(labrinth_config.session_queue.clone()) .app_data(labrinth_config.payouts_queue.clone()) .app_data(labrinth_config.email_queue.clone()) diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 519c0d476..d7919fe68 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -1,6 +1,7 @@ use ariadne::ids::base62_id; base62_id!(ChargeId); +base62_id!(CampaignDonationId); base62_id!(CollectionId); base62_id!(FileId); base62_id!(ImageId); diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index e779c8281..b9947ea41 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -56,6 +56,7 @@ pub struct User { pub created: DateTime, pub role: Role, pub badges: Badges, + pub campaigns: UserCampaigns, pub auth_providers: Option>, pub email: Option, @@ -72,6 +73,11 @@ pub struct User { pub github_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UserCampaigns { + pub pride_26: Option>, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserPayoutData { pub paypal_address: Option, @@ -94,6 +100,9 @@ impl From for User { created: data.created, role: Role::from_string(&data.role), badges: data.badges, + campaigns: UserCampaigns { + pride_26: data.campaign_pride_26, + }, payout_data: None, auth_providers: None, has_password: None, @@ -142,6 +151,9 @@ impl User { created: db_user.created, role: Role::from_string(&db_user.role), badges: db_user.badges, + campaigns: UserCampaigns { + pride_26: db_user.campaign_pride_26, + }, auth_providers: Some(auth_providers), has_password: Some(db_user.password.is_some()), has_totp: Some(db_user.totp_secret.is_some()), diff --git a/apps/labrinth/src/routes/internal/campaign.rs b/apps/labrinth/src/routes/internal/campaign.rs new file mode 100644 index 000000000..4fbef365e --- /dev/null +++ b/apps/labrinth/src/routes/internal/campaign.rs @@ -0,0 +1,392 @@ +use actix_web::{get, post, web}; +use chrono::{DateTime, Utc}; +use eyre::eyre; +use reqwest::Method; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::{ + database::{ + PgPool, PgTransaction, + models::{ + DBCampaignDonationId, DBUser, DBUserId, + generate_campaign_donation_id, + }, + redis::RedisPool, + }, + env::ENV, + models::payouts::TremendousForexResponse, + queue::payouts::PayoutsQueue, + routes::ApiError, + util::{error::Context, http::HttpClient, tiltify::TiltifyClient}, +}; + +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(tiltify_webhook).service(pride_26); +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +struct TiltifyWebhook { + data: TiltifyData, + meta: TiltifyMeta, +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +struct TiltifyData { + amount_raised: AmountRaised, + user: TiltifyUser, +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +struct AmountRaised { + currency: String, + value: Decimal, +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +struct TiltifyUser { + id: Uuid, + username: String, +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +struct TiltifyMeta { + attempted_at: DateTime, + event_type: String, + generated_at: DateTime, + id: Uuid, + subscription_source_id: Uuid, + subscription_source_type: String, +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CampaignInfo { + total_donations_usd: Decimal, + target_usd: Decimal, + num_donators: usize, +} + +const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info"; +const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 15 * 60; + +#[derive(Debug, Deserialize)] +struct TiltifyCampaignResponse { + data: TiltifyCampaign, +} + +#[derive(Debug, Deserialize)] +struct TiltifyCampaign { + goal: AmountRaised, + total_amount_raised: AmountRaised, +} + +#[derive(Debug, Deserialize)] +struct TiltifyDonationResponse { + data: Vec, + metadata: TiltifyPaginationMetadata, +} + +#[derive(Debug, Deserialize)] +struct TiltifyDonation { + donor_name: String, +} + +#[derive(Debug, Deserialize)] +struct TiltifyPaginationMetadata { + after: Option, +} + +struct CampaignDonation { + id: DBCampaignDonationId, + tiltify_event_id: Uuid, + raw_data: serde_json::Value, + donated_at: DateTime, + amount_usd: Option, + user_id: Option, +} + +impl CampaignDonation { + async fn insert( + &self, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let user_id = self.user_id.map(|id| id.0); + let inserted = sqlx::query!( + " + insert into campaign_donations (id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id) + values ($1, $2::text::uuid, $3, $4, $5, $6) + on conflict (tiltify_event_id) do nothing + returning id + ", + self.id.0, + self.tiltify_event_id.to_string(), + self.raw_data, + self.donated_at, + self.amount_usd, + user_id, + ) + .fetch_optional(transaction) + .await + .wrap_internal_err("inserting campaign donation")?; + + Ok(inserted.is_some()) + } +} + +#[utoipa::path] +#[post("/webhook")] +pub async fn tiltify_webhook( + pool: web::Data, + redis: web::Data, + payouts_queue: web::Data, + web::Json(raw_payload): web::Json, +) -> Result<(), ApiError> { + // deserialize the JSON in the request handler, not in the params, + // since if the JSON fails to deserialize then it's *our* fault, + // not the caller's. + let payload = TiltifyWebhook::deserialize(&raw_payload) + .wrap_internal_err_with(|| { + eyre!( + "invalid Tiltify webhook payload schema\n{}", + serde_json::to_string_pretty(&raw_payload) + .expect("serializing should not fail") + ) + })?; + + // no matter what, we need to insert this donation record into the db + // so we'll make one upfront + let mut transaction = pool + .begin() + .await + .wrap_internal_err("beginning transaction")?; + let id = generate_campaign_donation_id(&mut transaction).await?; + + let mut donation = CampaignDonation { + id, + tiltify_event_id: payload.meta.id, + raw_data: raw_payload, + donated_at: payload.meta.generated_at, + amount_usd: None, + user_id: None, + }; + + let username = async { + // then we can attempt user lookups + let username = payload.data.user.username; + let user = DBUser::get(&username, &**pool, &redis) + .await + .wrap_err("fetching user from database")? + .wrap_err_with(|| { + eyre!("got donation for user '{username}' which does not exist") + })?; + + donation.user_id = Some(user.id); + eyre::Ok(username) + } + .await + .inspect_err(|err| { + warn!("Failed to resolve donation to Modrinth user: {err:?}") + }) + .ok(); + + let amount_usd = async { + // and insert value amount + let amount_usd = + amount_raised_usd(&payload.data.amount_raised, &payouts_queue) + .await + .wrap_err("failed to get donation amount")?; + + donation.amount_usd = Some(amount_usd); + eyre::Ok(amount_usd) + } + .await + .inspect_err(|err| warn!("Failed to resolve donation amount: {err:?}")) + .ok(); + + info!( + "Resolved donation from {} for US${}", + username.as_deref().unwrap_or(""), + amount_usd + .map(|a| a.to_string()) + .unwrap_or_else(|| "".to_string()) + ); + + let inserted = donation + .insert(&mut transaction) + .await + .wrap_internal_err("inserting donation")?; + + if !inserted { + transaction + .commit() + .await + .wrap_internal_err("committing duplicate donation transaction")?; + info!("Ignoring duplicate Tiltify webhook {}", payload.meta.id); + return Ok(()); + } + + transaction + .commit() + .await + .wrap_internal_err("committing transaction")?; + + if let Some(user_id) = donation.user_id { + DBUser::clear_caches(&[(user_id, username)], &redis) + .await + .wrap_internal_err("clearing user caches")?; + } + + Ok(()) +} + +#[utoipa::path] +#[get("/pride-26")] +pub async fn pride_26( + http: web::Data, + redis: web::Data, + tiltify: web::Data, +) -> Result, ApiError> { + let campaign_id = &ENV.TILTIFY_PRIDE_26_CAMPAIGN_ID; + let mut redis_connection = redis + .connect() + .await + .wrap_internal_err("connecting to redis")?; + + if let Some(cached) = redis_connection + .get(CAMPAIGN_INFO_CACHE_NAMESPACE, campaign_id) + .await + .wrap_internal_err("getting cached campaign info")? + { + let campaign_info = serde_json::from_str::(&cached) + .wrap_internal_err("parsing cached campaign info")?; + return Ok(web::Json(campaign_info)); + } + + let access_token = tiltify + .access_token() + .await + .wrap_internal_err("fetching Tiltify access token")?; + let url = format!( + "https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}", + ); + let response = http + .get(url) + .bearer_auth(&access_token) + .send() + .await + .wrap_internal_err("fetching campaign from Tiltify")? + .error_for_status() + .wrap_internal_err("fetching campaign from Tiltify")? + .json::() + .await + .wrap_internal_err("parsing Tiltify response")?; + + let raised_currency = &response.data.total_amount_raised.currency; + if raised_currency != "USD" { + return Err(ApiError::Internal(eyre!( + "total amount raised is in {raised_currency}, must be USD" + ))); + } + + let goal_currency = &response.data.goal.currency; + if goal_currency != "USD" { + return Err(ApiError::Internal(eyre!( + "goal amount is in {goal_currency}, must be USD" + ))); + } + + let campaign_info = CampaignInfo { + total_donations_usd: response.data.total_amount_raised.value, + target_usd: response.data.goal.value, + num_donators: num_donators(&http, &access_token, campaign_id).await?, + }; + + redis_connection + .set_serialized_to_json( + CAMPAIGN_INFO_CACHE_NAMESPACE, + campaign_id, + &campaign_info, + Some(CAMPAIGN_INFO_CACHE_TTL_SECONDS), + ) + .await + .wrap_internal_err("caching campaign info")?; + + Ok(web::Json(campaign_info)) +} + +async fn num_donators( + http: &HttpClient, + access_token: &str, + campaign_id: &str, +) -> Result { + let mut after = None; + let mut donors = HashSet::new(); + + loop { + let url = format!( + "https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}/donations" + ); + let mut request = http + .get(url) + .bearer_auth(access_token) + .query(&[("limit", "100")]); + + if let Some(after) = &after { + request = request.query(&[("after", after)]); + } + + let response = request + .send() + .await + .wrap_internal_err("fetching donations from Tiltify")? + .error_for_status() + .wrap_internal_err("fetching donations from Tiltify")? + .json::() + .await + .wrap_internal_err("parsing Tiltify donations response")?; + + donors.extend( + response + .data + .into_iter() + .map(|donation| donation.donor_name) + .filter(|donor_name| donor_name != "Anonymous"), + ); + + match response.metadata.after { + Some(next_after) => after = Some(next_after), + None => break, + } + } + + Ok(donors.len()) +} + +async fn amount_raised_usd( + amount: &AmountRaised, + payouts_queue: &PayoutsQueue, +) -> Result { + let currency = amount.currency.to_uppercase(); + + if currency == "USD" { + return Ok(amount.value); + } + + let forex: TremendousForexResponse = payouts_queue + .make_tremendous_request(Method::GET, "forex", None::<()>) + .await + .wrap_internal_err("failed to fetch Tremendous forex data")?; + + let usd_to_currency = forex + .forex + .get(¤cy) + .copied() + .wrap_internal_err_with(|| { + eyre!("no Tremendous forex rate for '{currency}'") + })?; + + Ok(amount.value / usd_to_currency) +} diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index c6b41ba62..871c6a5fd 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -239,6 +239,7 @@ impl TempUser { created: Utc::now(), role: Role::Developer.to_string(), badges: Badges::default(), + campaign_pride_26: None, allow_friend_requests: true, is_subscribed_to_newsletter: false, } @@ -1573,6 +1574,7 @@ pub async fn create_account_with_password( created: Utc::now(), role: Role::Developer.to_string(), badges: Badges::default(), + campaign_pride_26: None, allow_friend_requests: true, is_subscribed_to_newsletter: new_account .sign_up_newsletter diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index eac882c33..2af8ae81f 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod affiliate; pub mod billing; +pub mod campaign; pub mod delphi; pub mod external_notifications; pub mod flows; @@ -85,6 +86,11 @@ pub fn utoipa_config( .wrap(default_cors()) .configure(affiliate::config), ) + .service( + utoipa_actix_web::scope("/_internal/campaign") + .wrap(default_cors()) + .configure(campaign::config), + ) .service( utoipa_actix_web::scope("/_internal/search-management") .wrap(default_cors()) diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index 19688c767..97fd0ea4a 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -18,5 +18,6 @@ pub mod redis; pub mod routes; pub mod sentry; pub mod tags; +pub mod tiltify; pub mod validate; pub mod webhook; diff --git a/apps/labrinth/src/util/tiltify.rs b/apps/labrinth/src/util/tiltify.rs new file mode 100644 index 000000000..95631070c --- /dev/null +++ b/apps/labrinth/src/util/tiltify.rs @@ -0,0 +1,91 @@ +use std::time::{Duration, Instant}; + +use eyre::eyre; +use serde::Deserialize; +use serde_json::json; +use tokio::sync::Mutex; + +use crate::{ + env::ENV, + util::{error::Context, http::HttpClient}, +}; + +#[derive(Debug)] +pub struct TiltifyClient { + http: HttpClient, + token: Mutex>, +} + +#[derive(Debug)] +struct TiltifyAccessToken { + access_token: String, + expires_at: Instant, +} + +#[derive(Debug, Deserialize)] +struct TiltifyTokenResponse { + access_token: String, + expires_in: u64, +} + +impl TiltifyClient { + pub fn new(http: HttpClient) -> Self { + Self { + http, + token: Mutex::new(None), + } + } + + pub async fn access_token(&self) -> eyre::Result { + let mut token = self.token.lock().await; + + if let Some(token) = token.as_ref() + && token.expires_at > Instant::now() + { + return Ok(token.access_token.clone()); + } + + let response = self.fetch_access_token().await?; + let expires_at = Instant::now() + + Duration::from_secs(response.expires_in) + .saturating_sub(Duration::from_secs(60)); + let access_token = response.access_token; + + *token = Some(TiltifyAccessToken { + access_token: access_token.clone(), + expires_at, + }); + + Ok(access_token) + } + + async fn fetch_access_token(&self) -> eyre::Result { + if ENV.TILTIFY_CLIENT_ID.is_empty() + || ENV.TILTIFY_CLIENT_SECRET.is_empty() + { + return Err(eyre!( + "TILTIFY_CLIENT_ID and TILTIFY_CLIENT_SECRET must be set" + )); + } + + let response = self + .http + .post("https://v5api.tiltify.com/oauth/token") + .json(&json!({ + "grant_type": "client_credentials", + "client_id": &ENV.TILTIFY_CLIENT_ID, + "client_secret": &ENV.TILTIFY_CLIENT_SECRET, + "scope": "public", + })) + .send() + .await + .wrap_err("fetching OAuth token")? + .error_for_status() + .wrap_err("fetching OAuth token")? + .json::() + .await + .wrap_err("parsing OAuth token response")?; + + Ok(response) + } +}