mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(¬ification_ids, &mut txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Notification::from)
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user