Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/frontend/src/composables/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ export const getAuthUrl = (provider: string, redirect = '/dashboard') => {
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
}

export const getCrowdinVerifyUrl = (token: string) => {
const config = useRuntimeConfig()
return `${config.public.apiBaseUrl}badges/crowdin/init?token=${encodeURIComponent(token)}`
}

export const promotePendingSignInOAuthProvider = () => {
if (!import.meta.client) return
const pending = useStorage<string | null>(
Expand Down
88 changes: 87 additions & 1 deletion apps/frontend/src/pages/settings/account.vue
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,32 @@
</Button>
</div>
</div>
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">{{ formatMessage(messages.contributorBadgeFieldTitle) }}</span>
<span class="label__description">{{
formatMessage(messages.contributorBadgeFieldDescription)
}}</span>
</label>
<div>
<Button @click="checkContributorBadge">
<UpdatedIcon /> {{ formatMessage(messages.contributorBadgeCheckButton) }}
</Button>
</div>
</div>
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">{{ formatMessage(messages.translatorBadgeFieldTitle) }}</span>
<span class="label__description">{{
formatMessage(messages.translatorBadgeFieldDescription)
}}</span>
</label>
<div>
<ButtonLink :href="getCrowdinVerifyUrl(auth.token)">
<ExternalIcon /> {{ formatMessage(messages.translatorBadgeVerifyButton) }}
</ButtonLink>
</div>
</div>
<PasskeySettings />
</section>

Expand Down Expand Up @@ -492,8 +518,10 @@ import MicrosoftIcon from 'assets/icons/auth/sso-microsoft.svg'
import SteamIcon from 'assets/icons/auth/sso-steam.svg'
import QrcodeVue from 'qrcode.vue'

import { UserBadge } from '@modrinth/utils'

import PasskeySettings from '~/components/ui/auth/PasskeySettings.vue'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.ts'
import { getAuthUrl, getCrowdinVerifyUrl, removeAuthProvider } from '~/composables/auth.ts'

definePageMeta({
middleware: 'auth',
Expand Down Expand Up @@ -542,6 +570,38 @@ const messages = defineMessages({
id: 'settings.account.email.action.save',
defaultMessage: 'Save email',
},
contributorBadgeFieldTitle: {
id: 'settings.account.badges.contributor.title',
defaultMessage: 'Contributor badge',
},
contributorBadgeFieldDescription: {
id: 'settings.account.badges.contributor.description',
defaultMessage: 'Check your linked GitHub account for merged pull requests.',
},
contributorBadgeCheckButton: {
id: 'settings.account.badges.contributor.action.check',
defaultMessage: 'Check contributor status',
},
contributorBadgeEarnedText: {
id: 'settings.account.badges.contributor.result.earned',
defaultMessage: "You've earned the Contributor badge!",
},
contributorBadgeNotEarnedText: {
id: 'settings.account.badges.contributor.result.not-earned',
defaultMessage: "You don't have enough merged pull requests yet.",
},
translatorBadgeFieldTitle: {
id: 'settings.account.badges.translator.title',
defaultMessage: 'Translator & proofreader badges',
},
translatorBadgeFieldDescription: {
id: 'settings.account.badges.translator.description',
defaultMessage: 'Briefly sign in with Crowdin to check your translation stats.',
},
translatorBadgeVerifyButton: {
id: 'settings.account.badges.translator.action.verify',
defaultMessage: 'Verify on Crowdin',
},
passwordHeaderRemove: {
id: 'settings.account.password.modal.header.remove',
defaultMessage: 'Remove password',
Expand Down Expand Up @@ -821,6 +881,32 @@ async function handleRemoveAuthProvider(provider) {
}
}

async function checkContributorBadge() {
startLoading()
try {
const res = await useBaseFetch('badges/contributor/check', {
method: 'POST',
})
await useAuth(auth.value.token)

const earned = (res.badges & UserBadge.CONTRIBUTOR) !== 0
addNotification({
title: formatMessage(messages.contributorBadgeFieldTitle),
text: formatMessage(
earned ? messages.contributorBadgeEarnedText : messages.contributorBadgeNotEarnedText,
),
type: earned ? 'success' : 'info',
})
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}

const managePasswordModal = ref()
const removePasswordMode = ref(false)
const oldPassword = ref('')
Expand Down
89 changes: 88 additions & 1 deletion apps/labrinth/src/background_task.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use crate::database;
use crate::database::PgPool;
use crate::database::models::DBUser;
use crate::database::models::ids::DBUserId;
use crate::database::models::notification_item::NotificationBuilder;
use crate::file_hosting::FileHost;
use crate::models::notifications::NotificationBody;
use crate::models::users::Badges;
use crate::queue::analytics::cache::cache_analytics;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::email::EmailQueue;
Expand All @@ -15,10 +17,11 @@ use crate::queue::payouts::{
};
use crate::search::SearchBackend;
use crate::util::anrok;
use crate::util::github_contributor;
use actix_web::web;
use clap::ValueEnum;
use eyre::WrapErr;
use tracing::{info, instrument};
use tracing::{info, instrument, warn};
use xredis::RedisPool;

#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -46,6 +49,8 @@ pub enum BackgroundTask {
ScanPendingFiles,
/// Queues Discord Creator Club role claim emails for newly eligible users.
DiscordRoleEmailCampaign,
/// Rechecks linked GitHub accounts for newly eligible Contributor badges.
RecheckContributorBadges,
}

impl BackgroundTask {
Expand Down Expand Up @@ -131,6 +136,9 @@ impl BackgroundTask {
DiscordRoleEmailCampaign => {
discord_role_email_campaign(pool, redis_pool).await
}
RecheckContributorBadges => {
recheck_contributor_badges(pool, redis_pool).await
}
}
}
}
Expand Down Expand Up @@ -329,6 +337,85 @@ pub async fn discord_role_email_campaign(
Ok(())
}

pub async fn recheck_contributor_badges(
pool: PgPool,
redis_pool: RedisPool,
) -> eyre::Result<()> {
info!("Started rechecking contributor badges");

let candidate_ids = sqlx::query_scalar!(
r#"
SELECT id AS "id!"
FROM users
WHERE github_id IS NOT NULL AND (badges & $1) = 0
LIMIT 500
"#,
Badges::CONTRIBUTOR.bits() as i64,
)
.fetch_all(&pool)
.await
.wrap_err("failed to fetch contributor badge candidates")?
.into_iter()
.map(DBUserId)
.collect::<Vec<_>>();

let mut updated = 0usize;

for user_id in candidate_ids {
let Some(db_user) = DBUser::get_id(user_id, &pool, &redis_pool)
.await
.wrap_err("failed to fetch user for contributor badge recheck")?
else {
continue;
};
let Some(github_id) = db_user.github_id else {
continue;
};

let eligible = match github_contributor::is_eligible_contributor(
github_id,
)
.await
{
Ok(eligible) => eligible,
Err(err) => {
warn!(
"failed to check GitHub contributor status for {:?}: {err:#}",
db_user.id
);
continue;
}
};

if !eligible {
continue;
}

let badges = db_user.badges | Badges::CONTRIBUTOR;

sqlx::query!(
"UPDATE users SET badges = $1 WHERE id = $2",
badges.bits() as i64,
db_user.id as DBUserId,
)
.execute(&pool)
.await
.wrap_err("failed to update contributor badge")?;

DBUser::clear_caches(
&[(db_user.id, Some(db_user.username))],
&redis_pool,
)
.await
.wrap_err("failed to clear user cache")?;

updated += 1;
}

info!("Finished rechecking contributor badges, updated {updated} users");
Ok(())
}

pub async fn sync_payout_statuses(
pool: PgPool,
mural: muralpay::Client,
Expand Down
5 changes: 5 additions & 0 deletions apps/labrinth/src/database/models/flow_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ pub enum DBFlow {
#[serde_binhum(binary(with = "json_string"))]
state: DiscoverableAuthentication,
},
/// State for the on-demand Crowdin verification used to award the
/// Translator/Proofreader badges.
CrowdinVerify {
user_id: DBUserId,
},
}

mod json_string {
Expand Down
9 changes: 9 additions & 0 deletions apps/labrinth/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,4 +396,13 @@ vars! {
SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64;

WEBAUTHN_RP_NAME: String = "Modrinth";

// Contributor/translator/proofreader profile badges
GITHUB_CONTRIBUTOR_PAT: String = "none";
GITHUB_CONTRIBUTOR_REPO: String = "modrinth/code";
GITHUB_CONTRIBUTOR_MERGED_PR_THRESHOLD: u32 = 3u32;
CROWDIN_CLIENT_ID: String = "none";
CROWDIN_CLIENT_SECRET: String = "none";
CROWDIN_PROJECT_ID: String = "none";
CROWDIN_PROJECT_API_TOKEN: String = "none";
}
16 changes: 16 additions & 0 deletions apps/labrinth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ pub fn app_setup(
}
});

let pool_ref = pool.clone();
let redis_pool_ref = redis_pool.clone();
scheduler.run(Duration::from_secs(60 * 60 * 6), move || {
let pool_ref = pool_ref.clone();
let redis_ref = redis_pool_ref.clone();
async move {
if let Err(e) = background_task::recheck_contributor_badges(
pool_ref, redis_ref,
)
.await
{
warn!("Rechecking contributor badges failed: {e:#}");
}
}
});

let version_index_interval =
Duration::from_secs(ENV.VERSION_INDEX_INTERVAL);
let pool_ref = pool.clone();
Expand Down
1 change: 1 addition & 0 deletions apps/labrinth/src/models/v3/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ bitflags::bitflags! {
const CONTRIBUTOR = 1 << 5;
const TRANSLATOR = 1 << 6;
const AFFILIATE = 1 << 7;
const PROOFREADER = 1 << 8;
}
}

Expand Down
Loading