You've already forked AstralRinth
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }>>
|
||||
|
||||
Generated
+20
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
Generated
+20
@@ -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'
|
||||
)
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user