Pride 26 campaign backend integration (#6254)

* wip: pride 2026 webhooks and stuff

* setup webhook and link to user

* fix up code

* improve donation resolution

* Pride 26 campaign

* idempotency

* wip: tiltify

* fix

* redis caching

* add num donators

* fix

* Revert openapi

* Prepare

* improve oauth token gen code
This commit is contained in:
aecsocket
2026-05-30 20:21:33 +01:00
committed by GitHub
parent 8c95f0bb81
commit c29973ec1a
17 changed files with 621 additions and 23 deletions
+3
View File
@@ -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
+3
View File
@@ -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
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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);
+10 -6
View File
@@ -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",
@@ -48,6 +48,8 @@ pub struct DBUser {
pub created: DateTime<Utc>,
pub role: String,
pub badges: Badges,
#[serde(default)]
pub campaign_pride_26: Option<DateTime<Utc>>,
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,
+3
View File
@@ -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;
+6
View File
@@ -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<ArchonClient>,
pub gotenberg_client: GotenbergClient,
pub http_client: web::Data<HttpClient>,
pub tiltify_client: web::Data<TiltifyClient>,
}
#[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())
+1
View File
@@ -1,6 +1,7 @@
use ariadne::ids::base62_id;
base62_id!(ChargeId);
base62_id!(CampaignDonationId);
base62_id!(CollectionId);
base62_id!(FileId);
base62_id!(ImageId);
+12
View File
@@ -56,6 +56,7 @@ pub struct User {
pub created: DateTime<Utc>,
pub role: Role,
pub badges: Badges,
pub campaigns: UserCampaigns,
pub auth_providers: Option<Vec<AuthProvider>>,
pub email: Option<String>,
@@ -72,6 +73,11 @@ pub struct User {
pub github_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserCampaigns {
pub pride_26: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserPayoutData {
pub paypal_address: Option<String>,
@@ -94,6 +100,9 @@ impl From<DBUser> 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()),
@@ -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<Utc>,
event_type: String,
generated_at: DateTime<Utc>,
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<TiltifyDonation>,
metadata: TiltifyPaginationMetadata,
}
#[derive(Debug, Deserialize)]
struct TiltifyDonation {
donor_name: String,
}
#[derive(Debug, Deserialize)]
struct TiltifyPaginationMetadata {
after: Option<String>,
}
struct CampaignDonation {
id: DBCampaignDonationId,
tiltify_event_id: Uuid,
raw_data: serde_json::Value,
donated_at: DateTime<Utc>,
amount_usd: Option<Decimal>,
user_id: Option<DBUserId>,
}
impl CampaignDonation {
async fn insert(
&self,
transaction: &mut PgTransaction<'_>,
) -> Result<bool, ApiError> {
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<PgPool>,
redis: web::Data<RedisPool>,
payouts_queue: web::Data<PayoutsQueue>,
web::Json(raw_payload): web::Json<serde_json::Value>,
) -> 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("<unknown>"),
amount_usd
.map(|a| a.to_string())
.unwrap_or_else(|| "<unknown>".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<HttpClient>,
redis: web::Data<RedisPool>,
tiltify: web::Data<TiltifyClient>,
) -> Result<web::Json<CampaignInfo>, 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::<CampaignInfo>(&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::<TiltifyCampaignResponse>()
.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<usize, ApiError> {
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::<TiltifyDonationResponse>()
.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<Decimal, ApiError> {
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(&currency)
.copied()
.wrap_internal_err_with(|| {
eyre!("no Tremendous forex rate for '{currency}'")
})?;
Ok(amount.value / usd_to_currency)
}
@@ -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
+6
View File
@@ -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())
+1
View File
@@ -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;
+91
View File
@@ -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<Option<TiltifyAccessToken>>,
}
#[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<String> {
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<TiltifyTokenResponse> {
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::<TiltifyTokenResponse>()
.await
.wrap_err("parsing OAuth token response")?;
Ok(response)
}
}