feat: use discord_id from discord sso for role grant (#6326)

* feat: properly use labrinth's linked discord accts sso for discord bot

* Update email for fixture user in SQL insert

Signed-off-by: Calum H. <calum@modrinth.com>

* fix: rev changes

* fix: copy on email

* fix: lint

* fix: rev

* fix: lint

---------

Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
Calum H.
2026-06-09 17:33:07 +00:00
committed by GitHub
parent bc5a761312
commit 543d25e2d6
20 changed files with 726 additions and 59 deletions
+85
View File
@@ -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!(
r#"SELECT pg_try_advisory_xact_lock(hashtextextended('discord_role_email_campaign', 0)) AS "lock_acquired!""#,
)
.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!(
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 AS "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::<Vec<_>>();
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,
+2
View File
@@ -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 = "";
MICROSOFT_CLIENT_ID: String = "none";
MICROSOFT_CLIENT_SECRET: String = "none";
GOOGLE_CLIENT_ID: String = "none";
@@ -153,6 +153,7 @@ pub enum LegacyNotificationBody {
amount: u64,
date_available: DateTime<Utc>,
},
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,
@@ -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<Utc>,
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<DBNotification> 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(),
@@ -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: _,
@@ -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(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
let notifications =
get_site_exposed_notifications(&notification_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(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
let notifications =
get_site_exposed_notifications(&notification_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<Vec<Notification>, ApiError> {
let raw_ids = notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
let exposed_ids = sqlx::query_scalar!(
r#"
SELECT n.id AS "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
"#,
&raw_ids[..],
)
.fetch_all(&mut *txn)
.await?
.into_iter()
.map(DBNotificationId)
.collect::<Vec<_>>();
Ok(DBNotification::get_many(&exposed_ids, txn)
.await?
.into_iter()
.map(Notification::from)
.collect())
}
async fn broadcast_notifications(
redis: &RedisPool,
notifications: Vec<Notification>,
) {
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"
);
}
}
}
+98 -1
View File
@@ -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,97 @@ pub struct DeleteAuthProvider {
pub provider: AuthProvider,
}
type HmacSha256 = Hmac<Sha256>;
#[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(
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<PgPool>,
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
) -> Result<web::Json<DiscordCommunityLinkResponse>, ApiError> {
if ENV.DISCORD_COMMUNITY_LINK_SECRET.is_empty()
|| 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 account is not linked")));
};
let now = Utc::now().timestamp();
let nonce = ChaCha20Rng::from_entropy()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect::<String>();
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",
+2 -1
View File
@@ -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);