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 18:33:07 +01:00
committed by GitHub
parent bc5a761312
commit 543d25e2d6
20 changed files with 726 additions and 59 deletions
@@ -1466,6 +1466,24 @@
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
"message": "You've used up your <b>{withdrawLimit}</b> 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"
},
+125 -3
View File
@@ -48,28 +48,69 @@
/>
</div>
<div class="normal-page__content mt-4 lg:!mt-0">
<Admonition
v-if="showDiscordRoleBanner"
class="mb-3"
type="info"
:header="formatMessage(messages.discordRoleBannerTitle)"
show-actions-underneath
dismissible
@dismiss="dismissDiscordRoleBanner"
>
<div class="text-primary">
{{
formatMessage(messages.discordRoleBannerBody, {
roles: eligibleDiscordRolesLabel,
})
}}
</div>
<template #actions>
<ButtonStyled color="blue">
<NuxtLink to="/discord/link" class="w-fit !px-4">
<ExternalIcon />
{{ formatMessage(messages.discordRoleBannerCta) }}
</NuxtLink>
</ButtonStyled>
</template>
</Admonition>
<NuxtPage :route="route" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BellIcon as NotificationsIcon,
ChartIcon,
CurrencyIcon,
DashboardIcon,
ExternalIcon,
LibraryIcon,
ListIcon,
OrganizationIcon,
ReportIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { type User, UserBadge } from '@modrinth/utils'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
injectModrinthClient,
useVIntl,
} from '@modrinth/ui'
import { UserBadge } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import NavStack from '~/components/ui/NavStack.vue'
const auth = (await useAuth()) as Ref<{ user: User | null }>
const auth = (await useAuth()) as Ref<{ user: Labrinth.Users.v3.User | null }>
const client = injectModrinthClient()
const dismissedDiscordRoleBannerUsers = useLocalStorage<string[]>(
'dashboard-discord-role-banner-dismissed-users',
[],
)
const isAffiliate = computed(() => {
return auth.value.user && auth.value.user.badges & UserBadge.AFFILIATE
@@ -114,6 +155,31 @@ const messages = defineMessages({
id: 'dashboard.sidebar.label.revenue',
defaultMessage: 'Revenue',
},
discordRoleBannerTitle: {
id: 'dashboard.discord-roles.banner.title',
defaultMessage: 'Claim your Discord roles',
},
discordRoleBannerBody: {
id: 'dashboard.discord-roles.banner.body',
defaultMessage:
"You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically.",
},
discordRoleBannerCta: {
id: 'dashboard.discord-roles.banner.cta',
defaultMessage: 'Link Discord',
},
discordRolePride: {
id: 'dashboard.discord-roles.role.pride',
defaultMessage: 'Pride 2026',
},
discordRoleCreator: {
id: 'dashboard.discord-roles.role.creator',
defaultMessage: 'Creator',
},
discordRoleBigCreator: {
id: 'dashboard.discord-roles.role.big-creator',
defaultMessage: '1M+ Downloads',
},
})
definePageMeta({
@@ -125,4 +191,60 @@ useSeoMeta({
})
const route = useNativeRoute()
const { data: projects } = useQuery({
queryKey: computed(() => ['dashboard-discord-role-eligibility', auth.value.user?.id, 'projects']),
queryFn: () => {
const userId = auth.value.user?.id
if (!userId) return []
return client.labrinth.users_v2.getProjects(userId)
},
enabled: computed(() => !!auth.value.user?.id),
})
const totalProjectDownloads = computed(() =>
(projects.value ?? []).reduce((total, project) => total + (project.downloads ?? 0), 0),
)
const eligibleDiscordRoles = computed(() => {
const roles = []
if (auth.value.user?.campaigns?.pride_26?.has_badge === true) {
roles.push(formatMessage(messages.discordRolePride))
}
if (totalProjectDownloads.value >= 20_000) {
roles.push(formatMessage(messages.discordRoleCreator))
}
if (totalProjectDownloads.value >= 1_000_000) {
roles.push(formatMessage(messages.discordRoleBigCreator))
}
return roles
})
const roleListFormatter = new Intl.ListFormat(undefined, {
style: 'long',
type: 'conjunction',
})
const eligibleDiscordRolesLabel = computed(() =>
roleListFormatter.format(eligibleDiscordRoles.value),
)
const hasDismissedDiscordRoleBanner = computed(() =>
dismissedDiscordRoleBannerUsers.value.includes(auth.value.user?.id ?? ''),
)
const showDiscordRoleBanner = computed(
() => eligibleDiscordRoles.value.length > 0 && !hasDismissedDiscordRoleBanner.value,
)
function dismissDiscordRoleBanner() {
const userId = auth.value.user?.id
if (!userId || dismissedDiscordRoleBannerUsers.value.includes(userId)) return
dismissedDiscordRoleBannerUsers.value = [...dismissedDiscordRoleBannerUsers.value, userId]
}
</script>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { injectModrinthClient } from '@modrinth/ui'
import { getAuthUrl } from '~/composables/auth.js'
definePageMeta({
layout: 'empty',
middleware: 'auth',
})
const route = useRoute()
const auth = await useAuth()
const client = injectModrinthClient()
const error = ref<unknown>(null)
const isLinkedCallback = computed(() => route.query.callback === 'linked')
onMounted(async () => {
if (isLinkedCallback.value) return
try {
if (!auth.value.user?.auth_providers?.includes('discord')) {
window.location.href = `${getAuthUrl('discord', '/discord/link')}&token=${auth.value.token}`
return
}
const res = await client.labrinth.auth_internal.createDiscordCommunityLink()
window.location.href = res.url
} catch (err) {
error.value = err
}
})
</script>
<template>
<section class="discord-link-container universal-card">
<h1>{{ isLinkedCallback ? 'Modrinth account linked' : 'Linking Discord' }}</h1>
<p v-if="isLinkedCallback">Your Modrinth account has been linked to the Discord server.</p>
<p v-else-if="!error">Connecting your Modrinth account to the Discord server...</p>
<p v-else>Discord linking failed. Please try again later.</p>
</section>
</template>
<style scoped>
.discord-link-container {
width: 26rem;
max-width: calc(100% - 2rem);
margin: 1rem auto;
display: flex;
flex-direction: column;
gap: 2rem;
}
.discord-link-container h1 {
font-size: var(--font-size-xl);
margin: 0 0 -1rem 0;
color: var(--color-contrast);
}
.discord-link-container p {
margin: 0;
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Button, Heading, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You're invited to the Creator Club"
:manual-links="[{ link: '{discord.link_url}', label: 'Link Discord' }]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You're invited to the Creator Club</Heading>
<Text class="text-base">Hey <span class="no-auto-link">{user.name}</span>!</Text>
<Text class="text-base"> Your projects just passed 20,000 total downloads, nice! </Text>
<Text class="text-base">
We want to invite you to Modrinth's Creator Club, a space in our discord where you can chat
with other creators, share feedback with us, and stay plugged in.
</Text>
<Text class="text-base">
To join just link your Discord account through Modrinth and we'll grant access automatically!
</Text>
<Section class="mb-4 mt-4">
<Button
href="{discord.link_url}"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Join the Creator Club
</Button>
</Section>
</StyledEmail>
</template>
@@ -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<string, () => Promise<{ default: Component }>>
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_try_advisory_xact_lock(hashtextextended('discord_role_email_campaign', 0)) AS \"lock_acquired!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "lock_acquired!",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "67d6f3431c2c78227c10c1ff2658e89ffec91b671e65915d7f6923dc2c95f82b"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT n.id AS \"id!\"\n FROM notifications n\n INNER JOIN notifications_types nt ON nt.name = n.body ->> 'type'\n WHERE n.id = ANY($1::BIGINT[])\n AND nt.expose_in_site_notifications = TRUE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "e89c5b87467dc019925f58ec789e15599d0f6121c41a4224746cfe2fde41ab60"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH\n user_project_downloads AS (\n SELECT\n tm.user_id,\n SUM(m.downloads)::BIGINT total_downloads\n FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n WHERE tm.accepted = TRUE\n GROUP BY tm.user_id\n )\n SELECT u.id AS \"id!\"\n FROM users u\n INNER JOIN user_project_downloads upd ON upd.user_id = u.id\n WHERE u.email IS NOT NULL\n AND u.email_verified = TRUE\n AND upd.total_downloads > 20000\n AND NOT EXISTS (\n SELECT 1\n FROM notifications n\n WHERE n.user_id = u.id\n AND n.body ->> 'type' = 'discord_role_creator_club'\n )\n ORDER BY upd.total_downloads DESC, u.id\n LIMIT 1000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "ffb5c12a0af95670946839a2f17247fc27601850a7b74b92863b740efbf794c9"
}
@@ -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;
@@ -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'
)
);
+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);
@@ -29,4 +29,18 @@ export class LabrinthAuthInternalModule extends AbstractModule {
method: 'POST',
})
}
/**
* Create a signed Discord community bot handoff URL
*/
public async createDiscordCommunityLink(): Promise<Labrinth.Auth.Internal.DiscordCommunityLinkResponse> {
return this.client.request<Labrinth.Auth.Internal.DiscordCommunityLinkResponse>(
'/auth/discord-community-link',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
}
@@ -510,6 +510,10 @@ export namespace Labrinth {
export type SubscriptionStatus = {
subscribed: boolean
}
export type DiscordCommunityLinkResponse = {
url: string
}
}
export namespace v2 {