diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 9f43effd71..446da42c20 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -1460,6 +1460,24 @@ "dashboard.creator-withdraw-modal.withdraw-limit-used": { "message": "You've used up your {withdrawLimit} withdrawal limit. You must complete a tax form to withdraw more." }, + "dashboard.discord-roles.banner.body": { + "message": "You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically." + }, + "dashboard.discord-roles.banner.cta": { + "message": "Link Discord" + }, + "dashboard.discord-roles.banner.title": { + "message": "Claim your Discord roles" + }, + "dashboard.discord-roles.role.big-creator": { + "message": "1M+ Downloads" + }, + "dashboard.discord-roles.role.creator": { + "message": "Creator" + }, + "dashboard.discord-roles.role.pride": { + "message": "Pride 2026" + }, "dashboard.head-title": { "message": "Dashboard" }, diff --git a/apps/frontend/src/pages/dashboard.vue b/apps/frontend/src/pages/dashboard.vue index 2be324416d..2a82c1ab89 100644 --- a/apps/frontend/src/pages/dashboard.vue +++ b/apps/frontend/src/pages/dashboard.vue @@ -48,28 +48,69 @@ />
+ +
+ {{ + formatMessage(messages.discordRoleBannerBody, { + roles: eligibleDiscordRolesLabel, + }) + }} +
+ +
diff --git a/apps/frontend/src/pages/discord/link.vue b/apps/frontend/src/pages/discord/link.vue new file mode 100644 index 0000000000..8df78809ae --- /dev/null +++ b/apps/frontend/src/pages/discord/link.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/frontend/src/templates/emails/discord/DiscordRoleCreatorClub.vue b/apps/frontend/src/templates/emails/discord/DiscordRoleCreatorClub.vue new file mode 100644 index 0000000000..9be940ed30 --- /dev/null +++ b/apps/frontend/src/templates/emails/discord/DiscordRoleCreatorClub.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/frontend/src/templates/emails/index.ts b/apps/frontend/src/templates/emails/index.ts index 015c1fdeb9..787558de22 100644 --- a/apps/frontend/src/templates/emails/index.ts +++ b/apps/frontend/src/templates/emails/index.ts @@ -36,6 +36,9 @@ export default { 'server-invited': () => import('./server/ServerInvited.vue'), 'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'), + // Discord + 'discord-role-creator-club': () => import('./discord/DiscordRoleCreatorClub.vue'), + // Organizations 'organization-invited': () => import('./organization/OrganizationInvited.vue'), } as Record Promise<{ default: Component }>> diff --git a/apps/labrinth/fixtures/hgwxdegw-badges-project.sql b/apps/labrinth/fixtures/hgwxdegw-badges-project.sql new file mode 100644 index 0000000000..30251535ec --- /dev/null +++ b/apps/labrinth/fixtures/hgwxdegw-badges-project.sql @@ -0,0 +1,80 @@ +-- Fixture for user HGwXDEgw. +-- User 60829878552966 = HGwXDEgw +-- Team 930000000000001 = 4G5AdLiy1 +-- Team member 930000000000002 = 4G5AdLiy2 +-- Project 930000000000003 = 4G5AdLiy3 +-- Thread 930000000000004 = 4G5AdLiy4 +-- Pride donation 930000000000005 = 4G5AdLiy5 + +INSERT INTO users ( + id, username, email, role, badges, balance, email_verified +) +VALUES ( + 60829878552966, 'fixture_hgwxdegw', 'admin@modrinth.invalid', + 'developer', 15, 0, TRUE +) +ON CONFLICT (id) DO UPDATE SET + badges = users.badges | EXCLUDED.badges, + email = COALESCE(users.email, EXCLUDED.email), + email_verified = TRUE; + +INSERT INTO teams (id) +VALUES (930000000000001) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO team_members ( + id, team_id, user_id, role, permissions, accepted, payouts_split, ordering, + organization_permissions, is_owner +) +VALUES ( + 930000000000002, 930000000000001, 60829878552966, 'Owner', + 1023, TRUE, 100, 0, NULL, TRUE +) +ON CONFLICT (id) DO UPDATE SET + team_id = EXCLUDED.team_id, + user_id = EXCLUDED.user_id, + permissions = EXCLUDED.permissions, + accepted = EXCLUDED.accepted, + is_owner = EXCLUDED.is_owner; + +INSERT INTO mods ( + id, team_id, name, summary, downloads, slug, description, follows, + license, status, requested_status, monetization_status, + side_types_migration_review_status, components +) +VALUES ( + 930000000000003, 930000000000001, 'HGwXDEgw Million Download Fixture', + 'Project used to exercise badges and high download counts.', 1000000, + 'hgwxdegw-million-download-fixture', '', 0, + 'LicenseRef-All-Rights-Reserved', 'approved', 'approved', + 'monetized', 'reviewed', '{}'::jsonb +) +ON CONFLICT (id) DO UPDATE SET + team_id = EXCLUDED.team_id, + name = EXCLUDED.name, + summary = EXCLUDED.summary, + downloads = EXCLUDED.downloads, + slug = EXCLUDED.slug, + status = EXCLUDED.status, + requested_status = EXCLUDED.requested_status, + monetization_status = EXCLUDED.monetization_status, + side_types_migration_review_status = EXCLUDED.side_types_migration_review_status, + components = EXCLUDED.components; + +INSERT INTO threads (id, thread_type, mod_id) +VALUES (930000000000004, 'project', 930000000000003) +ON CONFLICT (id) DO UPDATE SET + thread_type = EXCLUDED.thread_type, + mod_id = EXCLUDED.mod_id; + +INSERT INTO campaign_donations ( + id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id +) +VALUES ( + 930000000000005, '00000000-0000-4000-8000-000000000005', + '{"fixture": "hgwxdegw-badges-project"}'::jsonb, + '2026-06-01T00:00:00Z', 5, 60829878552966 +) +ON CONFLICT (id) DO UPDATE SET + amount_usd = EXCLUDED.amount_usd, + user_id = EXCLUDED.user_id; diff --git a/apps/labrinth/migrations/20260607120000_discord-role-email-campaign.sql b/apps/labrinth/migrations/20260607120000_discord-role-email-campaign.sql new file mode 100644 index 0000000000..4f3d42b201 --- /dev/null +++ b/apps/labrinth/migrations/20260607120000_discord-role-email-campaign.sql @@ -0,0 +1,38 @@ +INSERT INTO notifications_types + (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) +VALUES + ('discord_role_creator_club', 3, FALSE, FALSE); + +INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled) +VALUES + (NULL, 'email', 'discord_role_creator_club', TRUE); + +INSERT INTO notifications_templates + (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback) +VALUES + ( + 'email', + 'discord_role_creator_club', + 'You''re invited to the Creator Club', + 'https://modrinth.com/_internal/templates/email/discord-role-creator-club', + CONCAT( + 'Hi {user.name},', + CHR(10), + CHR(10), + 'Thanks for building on Modrinth. Your projects have passed 20,000 total downloads, which is wild to think about.', + CHR(10), + CHR(10), + 'That means thousands of players have found something useful, fun, or worth coming back to because of what you made.', + CHR(10), + CHR(10), + 'We''re opening up a Creator Club role in the Modrinth Discord for creators like you. Link your Discord account through Modrinth and we''ll sync it automatically.', + CHR(10), + CHR(10), + 'Join the Creator Club: {discord.link_url}', + CHR(10), + CHR(10), + 'Thanks for making Modrinth what it is,', + CHR(10), + 'The Modrinth Team' + ) + ); diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index b44bdfe6cd..2cb766a70b 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -1,6 +1,9 @@ use crate::database; use crate::database::PgPool; +use crate::database::models::ids::DBUserId; +use crate::database::models::notification_item::NotificationBuilder; use crate::database::redis::RedisPool; +use crate::models::notifications::NotificationBody; use crate::queue::analytics::cache::cache_analytics; use crate::queue::billing::{index_billing, index_subscriptions}; use crate::queue::email::EmailQueue; @@ -34,6 +37,8 @@ pub enum BackgroundTask { /// Attempts to ping Minecraft Java servers as if we were a client, to /// collect info on if they're online, game version, description, etc. PingMinecraftJavaServers, + /// Queues Discord Creator Club role claim emails for newly eligible users. + DiscordRoleEmailCampaign, } impl BackgroundTask { @@ -90,6 +95,9 @@ impl BackgroundTask { PingMinecraftJavaServers => { ping_minecraft_java_servers(pool, redis_pool, clickhouse).await } + DiscordRoleEmailCampaign => { + discord_role_email_campaign(pool, redis_pool).await + } } } } @@ -208,6 +216,83 @@ pub async fn payouts( Ok(()) } +pub async fn discord_role_email_campaign( + pool: PgPool, + redis_pool: RedisPool, +) -> eyre::Result<()> { + info!("Started indexing Discord role email campaign"); + + let mut txn = pool + .begin() + .await + .wrap_err("failed to begin Discord role email campaign transaction")?; + + let lock_acquired = sqlx::query_scalar::<_, bool>( + "SELECT pg_try_advisory_xact_lock(hashtextextended('discord_role_email_campaign', 0))", + ) + .fetch_one(&mut txn) + .await + .wrap_err("failed to acquire Discord role email campaign lock")?; + + if !lock_acquired { + info!("Discord role email campaign is already running"); + return Ok(()); + } + + let user_ids = sqlx::query_scalar::<_, i64>( + r#" + WITH + user_project_downloads AS ( + SELECT + tm.user_id, + SUM(m.downloads)::BIGINT total_downloads + FROM team_members tm + INNER JOIN mods m ON m.team_id = tm.team_id + WHERE tm.accepted = TRUE + GROUP BY tm.user_id + ) + SELECT u.id + FROM users u + INNER JOIN user_project_downloads upd ON upd.user_id = u.id + WHERE u.email IS NOT NULL + AND u.email_verified = TRUE + AND upd.total_downloads > 20000 + AND NOT EXISTS ( + SELECT 1 + FROM notifications n + WHERE n.user_id = u.id + AND n.body ->> 'type' = 'discord_role_creator_club' + ) + ORDER BY upd.total_downloads DESC, u.id + LIMIT 1000 + "#, + ) + .fetch_all(&mut txn) + .await + .wrap_err("failed to fetch Discord role email campaign recipients")? + .into_iter() + .map(DBUserId) + .collect::>(); + + let count = user_ids.len(); + + if !user_ids.is_empty() { + NotificationBuilder { + body: NotificationBody::DiscordRoleCreatorClub, + } + .insert_many(user_ids, &mut txn, &redis_pool) + .await + .wrap_err("failed to queue Discord role email notifications")?; + } + + txn.commit() + .await + .wrap_err("failed to commit Discord role email campaign transaction")?; + + info!(count, "Finished indexing Discord role email campaign"); + Ok(()) +} + pub async fn sync_payout_statuses( pool: PgPool, mural: muralpay::Client, diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 1bba4aaa26..b5f31ea1a3 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -189,6 +189,8 @@ vars! { GITLAB_CLIENT_SECRET: String = "none"; DISCORD_CLIENT_ID: String = "none"; DISCORD_CLIENT_SECRET: String = "none"; + DISCORD_COMMUNITY_BOT_HANDOFF_URL: String = "http://localhost:3000/modrinth/handoff"; + DISCORD_COMMUNITY_LINK_SECRET: String = "none"; MICROSOFT_CLIENT_ID: String = "none"; MICROSOFT_CLIENT_SECRET: String = "none"; GOOGLE_CLIENT_ID: String = "none"; diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index a97ec86bdc..e0d6e4e181 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -199,6 +199,23 @@ pub fn app_setup( } }); + let pool_ref = pool.clone(); + let redis_pool_ref = redis_pool.clone(); + scheduler.run(Duration::from_secs(60 * 60 * 24), move || { + let pool_ref = pool_ref.clone(); + let redis_pool_ref = redis_pool_ref.clone(); + async move { + if let Err(e) = background_task::discord_role_email_campaign( + pool_ref, + redis_pool_ref, + ) + .await + { + warn!("Discord role email campaign task failed: {e:#}"); + } + } + }); + let pool_ref = pool.clone(); let redis_ref = redis_pool.clone(); let stripe_client_ref = stripe_client.clone(); diff --git a/apps/labrinth/src/models/v2/notifications.rs b/apps/labrinth/src/models/v2/notifications.rs index 0f88942394..e0b632ca21 100644 --- a/apps/labrinth/src/models/v2/notifications.rs +++ b/apps/labrinth/src/models/v2/notifications.rs @@ -153,6 +153,7 @@ pub enum LegacyNotificationBody { amount: u64, date_available: DateTime, }, + DiscordRoleCreatorClub, Custom { key: String, title: String, @@ -242,6 +243,9 @@ impl LegacyNotification { NotificationBody::PayoutAvailable { .. } => { Some("payout_available".to_string()) } + NotificationBody::DiscordRoleCreatorClub => { + Some("discord_role_creator_club".to_string()) + } NotificationBody::Custom { .. } => Some("custom".to_string()), NotificationBody::LegacyMarkdown { notification_type, .. @@ -350,6 +354,9 @@ impl LegacyNotification { amount, date_available, }, + NotificationBody::DiscordRoleCreatorClub => { + LegacyNotificationBody::DiscordRoleCreatorClub + } NotificationBody::LegacyMarkdown { notification_type, name, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index a2842b7b97..d8ea3d4f71 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -59,6 +59,7 @@ pub enum NotificationType { ProjectStatusNeutral, ProjectTransferred, PayoutAvailable, + DiscordRoleCreatorClub, Custom, Unknown, } @@ -98,6 +99,9 @@ impl NotificationType { NotificationType::Custom => "custom", NotificationType::ProjectStatusNeutral => "project_status_neutral", NotificationType::ProjectTransferred => "project_transferred", + NotificationType::DiscordRoleCreatorClub => { + "discord_role_creator_club" + } NotificationType::Unknown => "unknown", } } @@ -134,6 +138,9 @@ impl NotificationType { } "project_status_neutral" => NotificationType::ProjectStatusNeutral, "project_transferred" => NotificationType::ProjectTransferred, + "discord_role_creator_club" => { + NotificationType::DiscordRoleCreatorClub + } "custom" => NotificationType::Custom, "unknown" => NotificationType::Unknown, _ => NotificationType::Unknown, @@ -259,6 +266,7 @@ pub enum NotificationBody { date_available: DateTime, amount: u64, }, + DiscordRoleCreatorClub, Custom { key: String, title: String, @@ -347,6 +355,9 @@ impl NotificationBody { NotificationBody::PayoutAvailable { .. } => { NotificationType::PayoutAvailable } + NotificationBody::DiscordRoleCreatorClub => { + NotificationType::DiscordRoleCreatorClub + } NotificationBody::Custom { .. } => NotificationType::Custom, NotificationBody::Unknown => NotificationType::Unknown, } @@ -619,6 +630,12 @@ impl From for Notification { "A payout is available!".to_string(), "#".to_string(), vec![], + ), + NotificationBody::DiscordRoleCreatorClub => ( + "Join the Creator Club".to_string(), + "Link your Discord account to claim your creator community role.".to_string(), + "/discord/link".to_string(), + vec![], ), NotificationBody::ModerationMessageReceived { .. } => ( "New message in moderation thread".to_string(), diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs index 5638947da5..a1fd487d29 100644 --- a/apps/labrinth/src/queue/email/templates.rs +++ b/apps/labrinth/src/queue/email/templates.rs @@ -90,6 +90,8 @@ const NEWOWNER_NAME: &str = "new_owner.name"; const PAYOUTAVAILABLE_AMOUNT: &str = "payout.amount"; const PAYOUTAVAILABLE_PERIOD: &str = "payout.period"; +const DISCORD_LINK_URL: &str = "discord.link_url"; + #[derive(Clone)] pub struct MailingIdentity { from_name: String, @@ -602,6 +604,15 @@ async fn collect_template_variables( | NotificationBody::PasswordChanged | NotificationBody::PasswordRemoved => Ok(EmailTemplate::Static(map)), + NotificationBody::DiscordRoleCreatorClub => { + map.insert( + DISCORD_LINK_URL, + format!("{}/discord/link", ENV.SITE_URL.trim_end_matches('/')), + ); + + Ok(EmailTemplate::Static(map)) + } + NotificationBody::EmailChanged { new_email, to_email: _, diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs index 4a26c44164..c06b540f84 100644 --- a/apps/labrinth/src/routes/internal/external_notifications.rs +++ b/apps/labrinth/src/routes/internal/external_notifications.rs @@ -1,6 +1,6 @@ use crate::auth::get_user_from_headers; use crate::database::PgPool; -use crate::database::models::ids::DBUserId; +use crate::database::models::ids::{DBNotificationId, DBUserId}; use crate::database::models::notification_item::DBNotification; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::user_item::DBUser; @@ -64,34 +64,12 @@ pub async fn create( .insert_many(user_ids, &mut txn, &redis) .await?; - let notifications = DBNotification::get_many(¬ification_ids, &mut txn) - .await? - .into_iter() - .map(Notification::from) - .collect::>(); + let notifications = + get_site_exposed_notifications(¬ification_ids, &mut txn).await?; txn.commit().await?; - for notification in notifications { - let notification_id = notification.id; - let to_user = notification.user_id; - if let Err(error) = broadcast_friends_message( - &redis, - RedisFriendsMessage::Notification { - to_user, - notification, - }, - ) - .await - { - tracing::warn!( - ?error, - ?notification_id, - ?to_user, - "failed to broadcast realtime notification" - ); - } - } + broadcast_notifications(&redis, notifications).await; Ok(HttpResponse::Accepted().finish()) } @@ -155,37 +133,12 @@ pub async fn create_email_sync( .insert_many_without_delivery(notification_user_ids, &mut txn, &redis) .await?; - let notifications = DBNotification::get_many(¬ification_ids, &mut txn) - .await? - .into_iter() - .map(Notification::from) - .collect::>(); + let notifications = + get_site_exposed_notifications(¬ification_ids, &mut txn).await?; txn.commit().await?; - for notification in notifications { - let Notification { - user_id: to_user, - id: notification_id, - .. - } = notification; - if let Err(error) = broadcast_friends_message( - &redis, - RedisFriendsMessage::Notification { - to_user, - notification, - }, - ) - .await - { - tracing::warn!( - ?error, - ?notification_id, - ?to_user, - "failed to broadcast realtime notification" - ); - } - } + broadcast_notifications(&redis, notifications).await; let mut email_txn = pool.begin().await?; @@ -332,3 +285,57 @@ pub async fn send_custom_email( Ok(HttpResponse::Accepted().finish()) } + +async fn get_site_exposed_notifications( + notification_ids: &[DBNotificationId], + txn: &mut crate::database::PgTransaction<'_>, +) -> Result, ApiError> { + let raw_ids = notification_ids.iter().map(|x| x.0).collect::>(); + let exposed_ids = sqlx::query_scalar::<_, i64>( + r#" + SELECT n.id + FROM notifications n + INNER JOIN notifications_types nt ON nt.name = n.body ->> 'type' + WHERE n.id = ANY($1::BIGINT[]) + AND nt.expose_in_site_notifications = TRUE + "#, + ) + .bind(&raw_ids[..]) + .fetch_all(&mut *txn) + .await? + .into_iter() + .map(DBNotificationId) + .collect::>(); + + Ok(DBNotification::get_many(&exposed_ids, txn) + .await? + .into_iter() + .map(Notification::from) + .collect()) +} + +async fn broadcast_notifications( + redis: &RedisPool, + notifications: Vec, +) { + for notification in notifications { + let notification_id = notification.id; + let to_user = notification.user_id; + if let Err(error) = broadcast_friends_message( + redis, + RedisFriendsMessage::Notification { + to_user, + notification, + }, + ) + .await + { + tracing::warn!( + ?error, + ?notification_id, + ?to_user, + "failed to broadcast realtime notification" + ); + } + } +} diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 871c6a5fd0..66369e5966 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -29,13 +29,18 @@ use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use ariadne::ids::base62_impl::{parse_base62, to_base62}; use ariadne::ids::random_base62_rng; use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::{Duration, Utc}; use eyre::eyre; +use hmac::{Hmac, Mac}; use lettre::message::Mailbox; +use rand::Rng; +use rand::distributions::Alphanumeric; use rand_chacha::ChaCha20Rng; use rand_chacha::rand_core::SeedableRng; use reqwest::header::AUTHORIZATION; use serde::{Deserialize, Serialize}; +use sha2::Sha256; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; @@ -61,7 +66,8 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service(set_email) .service(verify_email) .service(subscribe_newsletter) - .service(get_newsletter_subscription_status), + .service(get_newsletter_subscription_status) + .service(discord_community_link), ); } @@ -1369,6 +1375,98 @@ pub struct DeleteAuthProvider { pub provider: AuthProvider, } +type HmacSha256 = Hmac; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct DiscordCommunityLinkResponse { + pub url: String, +} + +#[derive(Serialize)] +struct DiscordCommunityHandoffPayload { + v: u8, + modrinth_user_id: String, + discord_user_id: String, + iat: i64, + exp: i64, + nonce: String, +} + +#[utoipa::path( + post, + operation_id = "discordCommunityLink", + responses( + (status = 200, description = "Discord community bot handoff URL", body = DiscordCommunityLinkResponse), + (status = 400, description = "Discord provider not linked"), + (status = 401, description = "Unauthorized") + ), + security(("bearer_auth" = ["SESSION_ACCESS"])) +)] +#[post("/discord-community-link")] +pub async fn discord_community_link( + req: HttpRequest, + client: Data, + redis: Data, + session_queue: Data, +) -> Result, ApiError> { + if ENV.DISCORD_COMMUNITY_LINK_SECRET == "none" + || ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL.is_empty() + { + return Err(ApiError::Internal(eyre!( + "discord community linking is not configured" + ))); + } + + let db_user = get_full_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await? + .1; + + let Some(discord_id) = db_user.discord_id else { + return Err(ApiError::Request(eyre!("discord_not_linked"))); + }; + + let now = Utc::now().timestamp(); + let nonce = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect::(); + + let payload = DiscordCommunityHandoffPayload { + v: 1, + modrinth_user_id: ariadne::ids::UserId::from(db_user.id).to_string(), + discord_user_id: discord_id.to_string(), + iat: now, + exp: now + 600, + nonce, + }; + + let payload_json = serde_json::to_vec(&payload).wrap_internal_err( + "failed to serialize discord community handoff payload", + )?; + let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json); + + let mut mac = HmacSha256::new_from_slice( + ENV.DISCORD_COMMUNITY_LINK_SECRET.as_bytes(), + ) + .wrap_internal_err("failed to initialize discord community link hmac")?; + mac.update(payload_b64.as_bytes()); + let sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + let url = format!( + "{}?payload={}&sig={}", + ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL, payload_b64, sig, + ); + + Ok(web::Json(DiscordCommunityLinkResponse { url })) +} + #[utoipa::path( delete, operation_id = "deleteAuthProvider", diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 2af8ae81f3..5106bc2d0f 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -54,7 +54,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(flows::set_email) .service(flows::verify_email) .service(flows::subscribe_newsletter) - .service(flows::get_newsletter_subscription_status), + .service(flows::get_newsletter_subscription_status) + .service(flows::discord_community_link), ); cfg.service(pats::get_pats); cfg.service(pats::create_pat); diff --git a/packages/api-client/src/modules/labrinth/auth/internal.ts b/packages/api-client/src/modules/labrinth/auth/internal.ts index 16c595ebf7..6050c5432f 100644 --- a/packages/api-client/src/modules/labrinth/auth/internal.ts +++ b/packages/api-client/src/modules/labrinth/auth/internal.ts @@ -29,4 +29,18 @@ export class LabrinthAuthInternalModule extends AbstractModule { method: 'POST', }) } + + /** + * Create a signed Discord community bot handoff URL + */ + public async createDiscordCommunityLink(): Promise { + return this.client.request( + '/auth/discord-community-link', + { + api: 'labrinth', + version: 'internal', + method: 'POST', + }, + ) + } } diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 91a2702091..7cb9b6a370 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -510,6 +510,10 @@ export namespace Labrinth { export type SubscriptionStatus = { subscribed: boolean } + + export type DiscordCommunityLinkResponse = { + url: string + } } export namespace v2 {