diff --git a/apps/frontend/src/composables/auth.ts b/apps/frontend/src/composables/auth.ts index bffb9b72fc..c47e2de1ba 100644 --- a/apps/frontend/src/composables/auth.ts +++ b/apps/frontend/src/composables/auth.ts @@ -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( diff --git a/apps/frontend/src/pages/settings/account.vue b/apps/frontend/src/pages/settings/account.vue index 33942d51e5..77cc475530 100644 --- a/apps/frontend/src/pages/settings/account.vue +++ b/apps/frontend/src/pages/settings/account.vue @@ -423,6 +423,32 @@ +
+ +
+ +
+
+
+ +
+ + {{ formatMessage(messages.translatorBadgeVerifyButton) }} + +
+
@@ -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', @@ -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', @@ -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('') diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index 3936356840..5d836d2a8e 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -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; @@ -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)] @@ -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 { @@ -131,6 +136,9 @@ impl BackgroundTask { DiscordRoleEmailCampaign => { discord_role_email_campaign(pool, redis_pool).await } + RecheckContributorBadges => { + recheck_contributor_badges(pool, redis_pool).await + } } } } @@ -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::>(); + + 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, diff --git a/apps/labrinth/src/database/models/flow_item.rs b/apps/labrinth/src/database/models/flow_item.rs index dd675941ae..b7c2322232 100644 --- a/apps/labrinth/src/database/models/flow_item.rs +++ b/apps/labrinth/src/database/models/flow_item.rs @@ -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 { diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 9ddbdb9c24..94dc717ffb 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -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"; } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 382dd9fc66..912b26eb61 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -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(); diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index fe5de4c18d..2df9071f5e 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -19,6 +19,7 @@ bitflags::bitflags! { const CONTRIBUTOR = 1 << 5; const TRANSLATOR = 1 << 6; const AFFILIATE = 1 << 7; + const PROOFREADER = 1 << 8; } } diff --git a/apps/labrinth/src/routes/internal/badges.rs b/apps/labrinth/src/routes/internal/badges.rs new file mode 100644 index 0000000000..b119f70948 --- /dev/null +++ b/apps/labrinth/src/routes/internal/badges.rs @@ -0,0 +1,249 @@ +//! Eligibility checks for the Contributor/Translator/Proofreader profile badges. + +use actix_web::web::Query; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; +use chrono::Duration; +use eyre::eyre; +use serde::{Deserialize, Serialize}; +use xredis::RedisPool; + +use crate::auth::get_user_from_headers; +use crate::auth::validate::get_user_record_from_bearer_token; +use crate::database::PgPool; +use crate::database::models::flow_item::DBFlow; +use crate::database::models::{DBUser, DBUserId}; +use crate::env::ENV; +use crate::models::pats::Scopes; +use crate::models::users::Badges; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::error::Context; +use crate::util::{crowdin, github_contributor}; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(check_contributor) + .service(crowdin_init) + .service(crowdin_callback); +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct BadgeCheckResponse { + pub badges: Badges, +} + +/// Re-checks the calling user's linked GitHub account for the Contributor badge. +#[utoipa::path(tag = "badges", responses((status = OK)))] +#[post("/contributor/check")] +pub async fn check_contributor( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (_, user) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_READ, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let db_user = DBUser::get_id(user.id.into(), &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| ApiError::NotFound(eyre!("resource not found")))?; + + let mut badges = db_user.badges; + + if !badges.contains(Badges::CONTRIBUTOR) { + let Some(github_id) = db_user.github_id else { + return Err(ApiError::Request(eyre!( + "a GitHub account must be linked before checking contributor status", + ))); + }; + + let eligible = github_contributor::is_eligible_contributor(github_id) + .await + .wrap_internal_err("checking GitHub contributor status")?; + + if eligible { + 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_internal_err("updating user badges")?; + + DBUser::clear_caches( + &[(db_user.id, Some(db_user.username))], + &redis, + ) + .await + .wrap_internal_err("clearing cached data from Redis")?; + } + } + + Ok(HttpResponse::Ok().json(BadgeCheckResponse { badges })) +} + +#[derive(Deserialize)] +pub struct CrowdinInitQuery { + /// A first-party session token (`mra_...`). + pub token: String, +} + +fn crowdin_redirect_uri() -> String { + format!("{}/_internal/badges/crowdin/callback", &ENV.SELF_ADDR) +} + +/// Starts the on-demand Crowdin verification flow for the calling user. +#[utoipa::path(tag = "badges", responses((status = TEMPORARY_REDIRECT)))] +#[get("/crowdin/init")] +pub async fn crowdin_init( + req: HttpRequest, + Query(info): Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + if !info.token.starts_with("mra_") { + return Err(ApiError::Auth(eyre!("invalid session token"))); + } + + let (_, user) = get_user_record_from_bearer_token( + &req, + Some(&info.token), + &**pool, + &redis, + &session_queue, + false, + ) + .await + .wrap_auth_err("authenticating API request")? + .ok_or_else(|| ApiError::Auth(eyre!("invalid session token")))?; + + let user_id: DBUserId = user.id.into(); + + let state = DBFlow::CrowdinVerify { user_id } + .insert(Duration::minutes(10), &redis) + .await + .wrap_internal_err("creating Crowdin verification flow")?; + + let url = crowdin::authorize_url(&state, &crowdin_redirect_uri()); + + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", url.as_str())) + .json(serde_json::json!({ "url": url }))) +} + +#[derive(Deserialize)] +pub struct CrowdinCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, +} + +/// Finishes the on-demand Crowdin verification flow and awards the +/// Translator/Proofreader badges if earned. +#[utoipa::path(tag = "badges", responses((status = TEMPORARY_REDIRECT)))] +#[get("/crowdin/callback")] +pub async fn crowdin_callback( + Query(query): Query, + pool: web::Data, + redis: web::Data, +) -> Result { + let redirect_target = format!("{}/settings/account", &ENV.SITE_URL); + + let Some(state) = query.state else { + return Err(ApiError::Request(eyre!("missing OAuth state"))); + }; + + let flow = DBFlow::take_if( + &state, + |flow| matches!(flow, DBFlow::CrowdinVerify { .. }), + &redis, + ) + .await + .wrap_internal_err("fetching Crowdin verification flow")?; + + let Some(DBFlow::CrowdinVerify { user_id }) = flow else { + return Err(ApiError::Request(eyre!( + "invalid or expired Crowdin verification flow", + ))); + }; + + if query.error.is_some() { + let location = format!("{redirect_target}?crowdin_verified=denied"); + return Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", location.as_str())) + .finish()); + } + + let Some(code) = query.code else { + return Err(ApiError::Request(eyre!("missing OAuth code"))); + }; + + let access_token = + crowdin::exchange_code(&code, &crowdin_redirect_uri()) + .await + .wrap_internal_err("exchanging Crowdin OAuth code")?; + + let crowdin_user_id = crowdin::fetch_own_user_id(&access_token) + .await + .wrap_internal_err("fetching Crowdin user profile")?; + + let stats = crowdin::fetch_contribution_stats(crowdin_user_id) + .await + .wrap_internal_err("fetching Crowdin contribution stats")?; + + // Fall back to raw stats only if the role lookup itself fails. + let is_proofreader = match crowdin::has_proofreader_role(crowdin_user_id) + .await + { + Ok(is_proofreader) => is_proofreader, + Err(_) => stats.approved > 0, + }; + + let db_user = DBUser::get_id(user_id, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| ApiError::NotFound(eyre!("resource not found")))?; + + let mut badges = db_user.badges; + let mut updated = false; + + if stats.has_contribution() && !badges.contains(Badges::TRANSLATOR) { + badges |= Badges::TRANSLATOR; + updated = true; + } + if is_proofreader && !badges.contains(Badges::PROOFREADER) { + badges |= Badges::PROOFREADER; + updated = true; + } + + if updated { + sqlx::query!( + "UPDATE users SET badges = $1 WHERE id = $2", + badges.bits() as i64, + db_user.id as DBUserId, + ) + .execute(&**pool) + .await + .wrap_internal_err("updating user badges")?; + + DBUser::clear_caches(&[(db_user.id, Some(db_user.username))], &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; + } + + let location = format!("{redirect_target}?crowdin_verified=1"); + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", location.as_str())) + .finish()) +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 1ad10523b1..c6162a46f4 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod affiliate; pub mod analytics_event; pub mod attribution; +pub mod badges; pub mod billing; pub mod blocked_users; pub mod campaign; @@ -42,6 +43,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { ) .service(web::scope("/moderation").configure(moderation::config)) .service(web::scope("/affiliate").configure(affiliate::config)) + .service(web::scope("/badges").configure(badges::config)) .service(web::scope("/campaign").configure(campaign::config)) .service(web::scope("/search-management").configure(search::config)) .service(web::scope("/globals").configure(globals::config)) @@ -99,6 +101,9 @@ pub fn config(cfg: &mut web::ServiceConfig) { flows::list_passkeys, flows::rename_passkey, flows::delete_passkey, + badges::check_contributor, + badges::crowdin_init, + badges::crowdin_callback, pats::get_pats, pats::create_pat, pats::edit_pat, diff --git a/apps/labrinth/src/util/crowdin.rs b/apps/labrinth/src/util/crowdin.rs new file mode 100644 index 0000000000..2b5ae1fcb0 --- /dev/null +++ b/apps/labrinth/src/util/crowdin.rs @@ -0,0 +1,277 @@ +use eyre::{Result, eyre}; +use serde::Deserialize; +use serde_json::json; + +use crate::env::ENV; +use crate::util::error::Context; +use crate::util::http::HTTP_CLIENT; + +/// How many times to poll a freshly-generated Crowdin report before giving up. +const REPORT_POLL_ATTEMPTS: u32 = 10; +const REPORT_POLL_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(1); + +pub fn authorize_url(state: &str, redirect_uri: &str) -> String { + format!( + "https://accounts.crowdin.com/oauth/authorize?client_id={}&response_type=code&scope=project&redirect_uri={}&state={state}", + ENV.CROWDIN_CLIENT_ID, + urlencoding::encode(redirect_uri), + ) +} + +#[derive(Deserialize)] +struct CrowdinTokenResponse { + access_token: String, +} + +/// Exchanges a one-time OAuth code for a short-lived Crowdin access token. +pub async fn exchange_code(code: &str, redirect_uri: &str) -> Result { + let response: CrowdinTokenResponse = HTTP_CLIENT + .post("https://accounts.crowdin.com/oauth/token") + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", ENV.CROWDIN_CLIENT_ID.as_str()), + ("client_secret", ENV.CROWDIN_CLIENT_SECRET.as_str()), + ("redirect_uri", redirect_uri), + ("code", code), + ]) + .send() + .await + .wrap_err("exchanging Crowdin OAuth code")? + .error_for_status() + .wrap_err("exchanging Crowdin OAuth code")? + .json() + .await + .wrap_err("parsing Crowdin token response")?; + + Ok(response.access_token) +} + +#[derive(Deserialize)] +struct CrowdinUserResponse { + data: CrowdinUser, +} + +#[derive(Deserialize)] +struct CrowdinUser { + id: i64, +} + +/// Looks up the Crowdin account id for the given (single-use) access token. +pub async fn fetch_own_user_id(access_token: &str) -> Result { + let response: CrowdinUserResponse = HTTP_CLIENT + .get("https://api.crowdin.com/api/v2/user") + .bearer_auth(access_token) + .send() + .await + .wrap_err("fetching Crowdin user profile")? + .error_for_status() + .wrap_err("fetching Crowdin user profile")? + .json() + .await + .wrap_err("parsing Crowdin user profile")?; + + Ok(response.data.id) +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ContributionStats { + pub translated: u32, + pub approved: u32, +} + +impl ContributionStats { + /// Any translated or approved string is enough for the Translator badge. + pub fn has_contribution(&self) -> bool { + self.translated > 0 || self.approved > 0 + } +} + +#[derive(Deserialize)] +struct GenerateReportResponse { + data: ReportIdentifier, +} + +#[derive(Deserialize)] +struct ReportIdentifier { + identifier: String, +} + +#[derive(Deserialize)] +struct ReportStatusResponse { + data: ReportStatus, +} + +#[derive(Deserialize)] +struct ReportStatus { + status: String, +} + +#[derive(Deserialize)] +struct ReportDownloadResponse { + data: ReportDownloadUrl, +} + +#[derive(Deserialize)] +struct ReportDownloadUrl { + url: String, +} + +#[derive(Deserialize)] +struct TopMembersReport { + data: Vec, +} + +#[derive(Deserialize)] +struct TopMembersReportEntry { + user: TopMembersReportUser, + #[serde(default)] + translated: u32, + #[serde(default)] + approved: u32, +} + +#[derive(Deserialize)] +struct TopMembersReportUser { + id: i64, +} + +/// Looks up how much a Crowdin user has translated/proofread on the Modrinth +/// project, using our own project-level API token. +pub async fn fetch_contribution_stats( + crowdin_user_id: i64, +) -> Result { + let project_id = &ENV.CROWDIN_PROJECT_ID; + let reports_url = format!( + "https://api.crowdin.com/api/v2/projects/{project_id}/reports" + ); + + let generated: GenerateReportResponse = HTTP_CLIENT + .post(&reports_url) + .bearer_auth(&ENV.CROWDIN_PROJECT_API_TOKEN) + .json(&json!({ + "name": "top-members", + "schema": { "unit": "strings", "format": "json" }, + })) + .send() + .await + .wrap_err("generating Crowdin top-members report")? + .error_for_status() + .wrap_err("generating Crowdin top-members report")? + .json() + .await + .wrap_err("parsing Crowdin report generation response")?; + + let report_url = format!("{reports_url}/{}", generated.data.identifier); + let mut finished = false; + + for _ in 0..REPORT_POLL_ATTEMPTS { + let status: ReportStatusResponse = HTTP_CLIENT + .get(&report_url) + .bearer_auth(&ENV.CROWDIN_PROJECT_API_TOKEN) + .send() + .await + .wrap_err("polling Crowdin report status")? + .error_for_status() + .wrap_err("polling Crowdin report status")? + .json() + .await + .wrap_err("parsing Crowdin report status response")?; + + if status.data.status == "finished" { + finished = true; + break; + } + + tokio::time::sleep(REPORT_POLL_INTERVAL).await; + } + + if !finished { + return Err(eyre!("Crowdin report did not finish generating in time")); + } + + let download: ReportDownloadResponse = HTTP_CLIENT + .get(format!("{report_url}/download")) + .bearer_auth(&ENV.CROWDIN_PROJECT_API_TOKEN) + .send() + .await + .wrap_err("fetching Crowdin report download url")? + .error_for_status() + .wrap_err("fetching Crowdin report download url")? + .json() + .await + .wrap_err("parsing Crowdin report download response")?; + + let report: TopMembersReport = HTTP_CLIENT + .get(&download.data.url) + .send() + .await + .wrap_err("downloading Crowdin report")? + .error_for_status() + .wrap_err("downloading Crowdin report")? + .json() + .await + .wrap_err("parsing Crowdin report")?; + + let stats = report + .data + .into_iter() + .find(|entry| entry.user.id == crowdin_user_id) + .map(|entry| ContributionStats { + translated: entry.translated, + approved: entry.approved, + }) + .unwrap_or_default(); + + Ok(stats) +} + +#[derive(Deserialize)] +struct MemberResponse { + data: Member, +} + +#[derive(Deserialize)] +struct Member { + #[serde(default)] + role: Option, + #[serde(default)] + roles: Vec, +} + +#[derive(Deserialize)] +struct MemberRole { + #[serde(default)] + name: Option, +} + +/// Checks whether the user holds a proofreader-level role on the project. +pub async fn has_proofreader_role(crowdin_user_id: i64) -> Result { + let project_id = &ENV.CROWDIN_PROJECT_ID; + let response: MemberResponse = HTTP_CLIENT + .get(format!( + "https://api.crowdin.com/api/v2/projects/{project_id}/members/{crowdin_user_id}" + )) + .bearer_auth(&ENV.CROWDIN_PROJECT_API_TOKEN) + .send() + .await + .wrap_err("fetching Crowdin project member")? + .error_for_status() + .wrap_err("fetching Crowdin project member")? + .json() + .await + .wrap_err("parsing Crowdin project member response")?; + let member = response.data; + + let primary_role = member.role.unwrap_or_default().to_lowercase(); + if matches!(primary_role.as_str(), "proofreader" | "owner" | "manager") { + return Ok(true); + } + + Ok(member.roles.iter().any(|role| { + matches!( + role.name.as_deref().unwrap_or_default().to_lowercase().as_str(), + "proofreader" | "language_coordinator" + ) + })) +} diff --git a/apps/labrinth/src/util/github_contributor.rs b/apps/labrinth/src/util/github_contributor.rs new file mode 100644 index 0000000000..31c3bd1199 --- /dev/null +++ b/apps/labrinth/src/util/github_contributor.rs @@ -0,0 +1,75 @@ +use eyre::Result; +use serde::Deserialize; + +use crate::env::ENV; +use crate::util::error::Context; +use crate::util::http::HTTP_CLIENT; + +#[derive(Deserialize)] +struct GitHubUserLookup { + login: String, +} + +#[derive(Deserialize)] +struct GitHubSearchResult { + total_count: u32, +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if ENV.GITHUB_CONTRIBUTOR_PAT.is_empty() + || ENV.GITHUB_CONTRIBUTOR_PAT == "none" + { + builder + } else { + builder.bearer_auth(&ENV.GITHUB_CONTRIBUTOR_PAT) + } +} + +/// Resolves a GitHub user id (as stored on `users.github_id`) to their current +/// login/username, since the search API only accepts usernames. +async fn username_for_id(github_id: i64) -> Result { + let user: GitHubUserLookup = authed( + HTTP_CLIENT.get(format!("https://api.github.com/user/{github_id}")), + ) + .send() + .await + .wrap_err("fetching GitHub user by id")? + .error_for_status() + .wrap_err("fetching GitHub user by id")? + .json() + .await + .wrap_err("parsing GitHub user response")?; + + Ok(user.login) +} + +/// Counts merged pull requests authored by `username` against the configured +/// contributor repository (`modrinth/code` by default). +async fn merged_pr_count(username: &str) -> Result { + let repo = &ENV.GITHUB_CONTRIBUTOR_REPO; + let query = format!("repo:{repo} is:pr is:merged author:{username}"); + + let result: GitHubSearchResult = authed( + HTTP_CLIENT + .get("https://api.github.com/search/issues") + .query(&[("q", query.as_str()), ("per_page", "1")]), + ) + .send() + .await + .wrap_err("searching GitHub for merged pull requests")? + .error_for_status() + .wrap_err("searching GitHub for merged pull requests")? + .json() + .await + .wrap_err("parsing GitHub search response")?; + + Ok(result.total_count) +} + +/// Checks whether the GitHub account linked via `github_id` has enough merged +/// pull requests to earn the Contributor badge. +pub async fn is_eligible_contributor(github_id: i64) -> Result { + let username = username_for_id(github_id).await?; + let count = merged_pr_count(&username).await?; + Ok(count >= ENV.GITHUB_CONTRIBUTOR_MERGED_PR_THRESHOLD) +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index dc17c4eeb6..9828592489 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -5,9 +5,11 @@ pub mod avalara1099; pub mod bitflag; pub mod captcha; pub mod cors; +pub mod crowdin; pub mod date; pub mod error; pub mod ext; +pub mod github_contributor; pub mod gotenberg; pub mod guards; pub mod http; diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 0038e7d6cf..47e3d0424b 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -27,6 +27,7 @@ import _BadgeCheckIcon from './icons/badge-check.svg?component' import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component' import _AlphaBadge from './icons/badges/alpha.svg?component' import _BetaBadge from './icons/badges/beta.svg?component' +import _ContributorBadge from './icons/badges/contributor.svg?component' import _Downloads1mBadge from './icons/badges/downloads-1m.svg?component' import _Downloads10mBadge from './icons/badges/downloads-10m.svg?component' import _Downloads25mBadge from './icons/badges/downloads-25m.svg?component' @@ -44,7 +45,9 @@ import _EarlyShadersBadge from './icons/badges/early-shaders.svg?component' import _ModeratorBadge from './icons/badges/moderator.svg?component' import _PlusBadge from './icons/badges/plus.svg?component' import _PrideBadge from './icons/badges/pride.svg?component' +import _ProofreaderBadge from './icons/badges/proofreader.svg?component' import _StaffBadge from './icons/badges/staff.svg?component' +import _TranslatorBadge from './icons/badges/translator.svg?component' import _BanIcon from './icons/ban.svg?component' import _BellIcon from './icons/bell.svg?component' import _BellRingIcon from './icons/bell-ring.svg?component' @@ -460,6 +463,7 @@ export const BadgeCheckIcon = _BadgeCheckIcon export const BadgeDollarSignIcon = _BadgeDollarSignIcon export const AlphaBadge = _AlphaBadge export const BetaBadge = _BetaBadge +export const ContributorBadge = _ContributorBadge export const Downloads1mBadge = _Downloads1mBadge export const Downloads10mBadge = _Downloads10mBadge export const Downloads25mBadge = _Downloads25mBadge @@ -477,7 +481,9 @@ export const EarlyShadersBadge = _EarlyShadersBadge export const ModeratorBadge = _ModeratorBadge export const PlusBadge = _PlusBadge export const PrideBadge = _PrideBadge +export const ProofreaderBadge = _ProofreaderBadge export const StaffBadge = _StaffBadge +export const TranslatorBadge = _TranslatorBadge export const BanIcon = _BanIcon export const BellIcon = _BellIcon export const BellRingIcon = _BellRingIcon @@ -871,140 +877,141 @@ export const XCircleIcon = _XCircleIcon export const ZoomInIcon = _ZoomInIcon export const ZoomOutIcon = _ZoomOutIcon + export const categoryIconMap: Record = { - adventure: TagCategoryAdventureIcon, - atmosphere: TagCategoryAtmosphereIcon, - audio: TagCategoryAudioIcon, - backpack: TagCategoryBackpackIcon, - badge: TagCategoryBadgeIcon, + 'adventure': TagCategoryAdventureIcon, + 'atmosphere': TagCategoryAtmosphereIcon, + 'audio': TagCategoryAudioIcon, + 'backpack': TagCategoryBackpackIcon, + 'badge': TagCategoryBadgeIcon, 'badge-check': TagCategoryBadgeCheckIcon, 'bed-double': TagCategoryBedDoubleIcon, - blocks: TagCategoryBlocksIcon, - bloom: TagCategoryBloomIcon, + 'blocks': TagCategoryBlocksIcon, + 'bloom': TagCategoryBloomIcon, 'building-2': TagCategoryBuilding2Icon, - camera: TagCategoryCameraIcon, - cartoon: TagCategoryCartoonIcon, - castle: TagCategoryCastleIcon, - challenging: TagCategoryChallengingIcon, - clapperboard: TagCategoryClapperboardIcon, - cloud: TagCategoryCloudIcon, + 'camera': TagCategoryCameraIcon, + 'cartoon': TagCategoryCartoonIcon, + 'castle': TagCategoryCastleIcon, + 'challenging': TagCategoryChallengingIcon, + 'clapperboard': TagCategoryClapperboardIcon, + 'cloud': TagCategoryCloudIcon, 'colored-lighting': TagCategoryColoredLightingIcon, - combat: TagCategoryCombatIcon, - compass: TagCategoryCompassIcon, + 'combat': TagCategoryCombatIcon, + 'compass': TagCategoryCompassIcon, 'core-shaders': TagCategoryCoreShadersIcon, - crown: TagCategoryCrownIcon, - cursed: TagCategoryCursedIcon, - decoration: TagCategoryDecorationIcon, - dices: TagCategoryDicesIcon, - economy: TagCategoryEconomyIcon, - entities: TagCategoryEntitiesIcon, - environment: TagCategoryEnvironmentIcon, - equipment: TagCategoryEquipmentIcon, - fantasy: TagCategoryFantasyIcon, - film: TagCategoryFilmIcon, - flag: TagCategoryFlagIcon, - foliage: TagCategoryFoliageIcon, - fonts: TagCategoryFontsIcon, - food: TagCategoryFoodIcon, - footprints: TagCategoryFootprintsIcon, + 'crown': TagCategoryCrownIcon, + 'cursed': TagCategoryCursedIcon, + 'decoration': TagCategoryDecorationIcon, + 'dices': TagCategoryDicesIcon, + 'economy': TagCategoryEconomyIcon, + 'entities': TagCategoryEntitiesIcon, + 'environment': TagCategoryEnvironmentIcon, + 'equipment': TagCategoryEquipmentIcon, + 'fantasy': TagCategoryFantasyIcon, + 'film': TagCategoryFilmIcon, + 'flag': TagCategoryFlagIcon, + 'foliage': TagCategoryFoliageIcon, + 'fonts': TagCategoryFontsIcon, + 'food': TagCategoryFoodIcon, + 'footprints': TagCategoryFootprintsIcon, 'game-mechanics': TagCategoryGameMechanicsIcon, 'gamepad-2': TagCategoryGamepad2Icon, - gauge: TagCategoryGaugeIcon, - globe: TagCategoryGlobeIcon, + 'gauge': TagCategoryGaugeIcon, + 'globe': TagCategoryGlobeIcon, 'grid-3x3': TagCategoryGrid3x3Icon, - gui: TagCategoryGuiIcon, - handshake: TagCategoryHandshakeIcon, + 'gui': TagCategoryGuiIcon, + 'handshake': TagCategoryHandshakeIcon, 'heart-crack': TagCategoryHeartCrackIcon, 'heart-pulse': TagCategoryHeartPulseIcon, - high: TagCategoryHighIcon, - house: TagCategoryHouseIcon, - items: TagCategoryItemsIcon, + 'high': TagCategoryHighIcon, + 'house': TagCategoryHouseIcon, + 'items': TagCategoryItemsIcon, 'kitchen-sink': TagCategoryKitchenSinkIcon, - library: TagCategoryLibraryIcon, - lightweight: TagCategoryLightweightIcon, - locale: TagCategoryLocaleIcon, - lock: TagCategoryLockIcon, - low: TagCategoryLowIcon, - magic: TagCategoryMagicIcon, - management: TagCategoryManagementIcon, + 'library': TagCategoryLibraryIcon, + 'lightweight': TagCategoryLightweightIcon, + 'locale': TagCategoryLocaleIcon, + 'lock': TagCategoryLockIcon, + 'low': TagCategoryLowIcon, + 'magic': TagCategoryMagicIcon, + 'management': TagCategoryManagementIcon, 'map-pinned': TagCategoryMapPinnedIcon, - medium: TagCategoryMediumIcon, - minigame: TagCategoryMinigameIcon, - mobs: TagCategoryMobsIcon, - modded: TagCategoryModdedIcon, - models: TagCategoryModelsIcon, - multiplayer: TagCategoryMultiplayerIcon, - network: TagCategoryNetworkIcon, - optimization: TagCategoryOptimizationIcon, - palette: TagCategoryPaletteIcon, + 'medium': TagCategoryMediumIcon, + 'minigame': TagCategoryMinigameIcon, + 'mobs': TagCategoryMobsIcon, + 'modded': TagCategoryModdedIcon, + 'models': TagCategoryModelsIcon, + 'multiplayer': TagCategoryMultiplayerIcon, + 'network': TagCategoryNetworkIcon, + 'optimization': TagCategoryOptimizationIcon, + 'palette': TagCategoryPaletteIcon, 'path-tracing': TagCategoryPathTracingIcon, 'paw-print': TagCategoryPawPrintIcon, - pbr: TagCategoryPbrIcon, - pickaxe: TagCategoryPickaxeIcon, - potato: TagCategoryPotatoIcon, - quests: TagCategoryQuestsIcon, - realistic: TagCategoryRealisticIcon, - reflections: TagCategoryReflectionsIcon, + 'pbr': TagCategoryPbrIcon, + 'pickaxe': TagCategoryPickaxeIcon, + 'potato': TagCategoryPotatoIcon, + 'quests': TagCategoryQuestsIcon, + 'realistic': TagCategoryRealisticIcon, + 'reflections': TagCategoryReflectionsIcon, 'refresh-ccw': TagCategoryRefreshCcwIcon, - screenshot: TagCategoryScreenshotIcon, + 'screenshot': TagCategoryScreenshotIcon, 'scroll-text': TagCategoryScrollTextIcon, 'semi-realistic': TagCategorySemiRealisticIcon, - shadows: TagCategoryShadowsIcon, - shield: TagCategoryShieldIcon, - simplistic: TagCategorySimplisticIcon, - skull: TagCategorySkullIcon, - social: TagCategorySocialIcon, - square: TagCategorySquareIcon, - storage: TagCategoryStorageIcon, - sword: TagCategorySwordIcon, - swords: TagCategorySwordsIcon, - target: TagCategoryTargetIcon, - technology: TagCategoryTechnologyIcon, - terminal: TagCategoryTerminalIcon, - theater: TagCategoryTheaterIcon, - themed: TagCategoryThemedIcon, - transportation: TagCategoryTransportationIcon, + 'shadows': TagCategoryShadowsIcon, + 'shield': TagCategoryShieldIcon, + 'simplistic': TagCategorySimplisticIcon, + 'skull': TagCategorySkullIcon, + 'social': TagCategorySocialIcon, + 'square': TagCategorySquareIcon, + 'storage': TagCategoryStorageIcon, + 'sword': TagCategorySwordIcon, + 'swords': TagCategorySwordsIcon, + 'target': TagCategoryTargetIcon, + 'technology': TagCategoryTechnologyIcon, + 'terminal': TagCategoryTerminalIcon, + 'theater': TagCategoryTheaterIcon, + 'themed': TagCategoryThemedIcon, + 'transportation': TagCategoryTransportationIcon, 'tree-pine': TagCategoryTreePineIcon, - trophy: TagCategoryTrophyIcon, - tweaks: TagCategoryTweaksIcon, - users: TagCategoryUsersIcon, - utility: TagCategoryUtilityIcon, + 'trophy': TagCategoryTrophyIcon, + 'tweaks': TagCategoryTweaksIcon, + 'users': TagCategoryUsersIcon, + 'utility': TagCategoryUtilityIcon, 'vanilla-like': TagCategoryVanillaLikeIcon, 'wand-sparkles': TagCategoryWandSparklesIcon, 'wifi-off': TagCategoryWifiOffIcon, - worldgen: TagCategoryWorldgenIcon, - zap: TagCategoryZapIcon, + 'worldgen': TagCategoryWorldgenIcon, + 'zap': TagCategoryZapIcon, } export const loaderIconMap: Record = { - babric: TagLoaderBabricIcon, + 'babric': TagLoaderBabricIcon, 'bta-babric': TagLoaderBtaBabricIcon, - bukkit: TagLoaderBukkitIcon, - bungeecord: TagLoaderBungeecordIcon, - canvas: TagLoaderCanvasIcon, - datapack: TagLoaderDatapackIcon, - fabric: TagLoaderFabricIcon, - folia: TagLoaderFoliaIcon, - forge: TagLoaderForgeIcon, - geyser: TagLoaderGeyserIcon, - iris: TagLoaderIrisIcon, + 'bukkit': TagLoaderBukkitIcon, + 'bungeecord': TagLoaderBungeecordIcon, + 'canvas': TagLoaderCanvasIcon, + 'datapack': TagLoaderDatapackIcon, + 'fabric': TagLoaderFabricIcon, + 'folia': TagLoaderFoliaIcon, + 'forge': TagLoaderForgeIcon, + 'geyser': TagLoaderGeyserIcon, + 'iris': TagLoaderIrisIcon, 'java-agent': TagLoaderJavaAgentIcon, 'legacy-fabric': TagLoaderLegacyFabricIcon, - liteloader: TagLoaderLiteloaderIcon, - minecraft: TagLoaderMinecraftIcon, - modloader: TagLoaderModloaderIcon, - mrpack: TagLoaderMrpackIcon, - neoforge: TagLoaderNeoforgeIcon, - nilloader: TagLoaderNilloaderIcon, - optifine: TagLoaderOptifineIcon, - ornithe: TagLoaderOrnitheIcon, - paper: TagLoaderPaperIcon, - purpur: TagLoaderPurpurIcon, - quilt: TagLoaderQuiltIcon, - rift: TagLoaderRiftIcon, - spigot: TagLoaderSpigotIcon, - sponge: TagLoaderSpongeIcon, - vanilla: TagLoaderVanillaIcon, - velocity: TagLoaderVelocityIcon, - waterfall: TagLoaderWaterfallIcon, + 'liteloader': TagLoaderLiteloaderIcon, + 'minecraft': TagLoaderMinecraftIcon, + 'modloader': TagLoaderModloaderIcon, + 'mrpack': TagLoaderMrpackIcon, + 'neoforge': TagLoaderNeoforgeIcon, + 'nilloader': TagLoaderNilloaderIcon, + 'optifine': TagLoaderOptifineIcon, + 'ornithe': TagLoaderOrnitheIcon, + 'paper': TagLoaderPaperIcon, + 'purpur': TagLoaderPurpurIcon, + 'quilt': TagLoaderQuiltIcon, + 'rift': TagLoaderRiftIcon, + 'spigot': TagLoaderSpigotIcon, + 'sponge': TagLoaderSpongeIcon, + 'vanilla': TagLoaderVanillaIcon, + 'velocity': TagLoaderVelocityIcon, + 'waterfall': TagLoaderWaterfallIcon, } diff --git a/packages/assets/icons/badges/contributor.svg b/packages/assets/icons/badges/contributor.svg new file mode 100644 index 0000000000..fdda1ce748 --- /dev/null +++ b/packages/assets/icons/badges/contributor.svg @@ -0,0 +1 @@ + diff --git a/packages/assets/icons/badges/proofreader.svg b/packages/assets/icons/badges/proofreader.svg new file mode 100644 index 0000000000..ae6bd32f9b --- /dev/null +++ b/packages/assets/icons/badges/proofreader.svg @@ -0,0 +1 @@ + diff --git a/packages/assets/icons/badges/translator.svg b/packages/assets/icons/badges/translator.svg new file mode 100644 index 0000000000..e2b232f500 --- /dev/null +++ b/packages/assets/icons/badges/translator.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/components/user/UserBadges.vue b/packages/ui/src/components/user/UserBadges.vue index e6e3f76101..6185702eca 100644 --- a/packages/ui/src/components/user/UserBadges.vue +++ b/packages/ui/src/components/user/UserBadges.vue @@ -3,6 +3,7 @@ import type { Labrinth } from '@modrinth/api-client' import { AlphaBadge, BetaBadge, + ContributorBadge, Downloads1mBadge, Downloads10mBadge, Downloads25mBadge, @@ -20,7 +21,9 @@ import { ModeratorBadge, PlusBadge, PrideBadge, + ProofreaderBadge, StaffBadge, + TranslatorBadge, } from '@modrinth/assets' import { defineMessage, @@ -177,6 +180,84 @@ const BADGES = [ }), }, }, + { + icon: ContributorBadge, + name: defineMessage({ + id: 'user.profile.badge.contributor.name', + defaultMessage: 'Contributor', + }), + about: [ + defineMessage({ + id: 'user.profile.badge.contributor.about.1', + defaultMessage: `This user has contributed code to Modrinth's open source projects.`, + }), + ], + criteria: [ + { + type: 'badge', + bitflag: BadgeBitflag.CONTRIBUTOR, + }, + ], + link: { + href: 'https://github.com/modrinth/code', + message: defineMessage({ + id: 'user.profile.badge.contributor.link', + defaultMessage: `Click to view Modrinth's source code on GitHub.`, + }), + }, + }, + { + icon: TranslatorBadge, + name: defineMessage({ + id: 'user.profile.badge.translator.name', + defaultMessage: 'Translator', + }), + about: [ + defineMessage({ + id: 'user.profile.badge.translator.about.1', + defaultMessage: `This user has helped translate Modrinth into other languages.`, + }), + ], + criteria: [ + { + type: 'badge', + bitflag: BadgeBitflag.TRANSLATOR, + }, + ], + link: { + href: 'https://crowdin.com/project/modrinth', + message: defineMessage({ + id: 'user.profile.badge.translator.link', + defaultMessage: `Click to help translate Modrinth on Crowdin.`, + }), + }, + }, + { + icon: ProofreaderBadge, + name: defineMessage({ + id: 'user.profile.badge.proofreader.name', + defaultMessage: 'Proofreader', + }), + about: [ + defineMessage({ + id: 'user.profile.badge.proofreader.about.1', + defaultMessage: `This user has helped proofread translations of Modrinth into other languages.`, + }), + ], + criteria: [ + { + type: 'badge', + bitflag: BadgeBitflag.PROOFREADER, + }, + ], + link: { + href: 'https://crowdin.com/project/modrinth', + message: defineMessage({ + id: 'user.profile.badge.proofreader.link', + defaultMessage: `Click to help translate Modrinth on Crowdin.`, + }), + }, + }, { icon: PlusBadge, name: defineMessage({ diff --git a/packages/utils/types.ts b/packages/utils/types.ts index 03dbd10093..5ce4d5c319 100644 --- a/packages/utils/types.ts +++ b/packages/utils/types.ts @@ -342,6 +342,7 @@ export enum UserBadge { CONTRIBUTOR = 1 << 5, TRANSLATOR = 1 << 6, AFFILIATE = 1 << 7, + PROOFREADER = 1 << 8, } export type UserBadges = number