diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 9fd6130af8..5bd20f45c3 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -431,7 +431,7 @@ label: 'User lookup', icon: UserSearchIcon, type: 'link', - to: '/admin/user_email', + to: '/admin/user_lookup', shown: isAdmin(auth.user), }, { diff --git a/apps/frontend/src/pages/admin.vue b/apps/frontend/src/pages/admin.vue index 370399b2d5..1244396e9b 100644 --- a/apps/frontend/src/pages/admin.vue +++ b/apps/frontend/src/pages/admin.vue @@ -15,7 +15,7 @@ icon: FileSearchCornerIcon, }, { - link: '/admin/user_email', + link: '/admin/user_lookup', label: 'User lookup', icon: UserSearchIcon, shown: admin, diff --git a/apps/frontend/src/pages/admin/user_email.vue b/apps/frontend/src/pages/admin/user_email.vue deleted file mode 100644 index da2daf0a2b..0000000000 --- a/apps/frontend/src/pages/admin/user_email.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - User lookup - - - - - User email - * - - - - - - - - Get user account - - - - - - diff --git a/apps/frontend/src/pages/admin/user_lookup.vue b/apps/frontend/src/pages/admin/user_lookup.vue new file mode 100644 index 0000000000..ebf7e101b5 --- /dev/null +++ b/apps/frontend/src/pages/admin/user_lookup.vue @@ -0,0 +1,134 @@ + + + User lookup + + + + + User email + * + + + + + + + + Get user account + + + + + + + + Discord ID + * + + + + + + + + Get user account + + + + + + diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index cb88f48a72..e20adf2236 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -320,6 +320,26 @@ impl DBUser { Ok(users) } + pub async fn get_by_discord_id<'a, E>( + discord_id: u64, + exec: E, + ) -> Result, sqlx::Error> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let Ok(discord_id) = i64::try_from(discord_id) else { + return Ok(None); + }; + + sqlx::query_scalar!( + r#"SELECT id FROM users WHERE discord_id = $1"#, + discord_id + ) + .fetch_optional(exec) + .await + .map(|id| id.map(DBUserId)) + } + pub async fn get_by_email<'a, E>( email: &str, exec: E, diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index c8e95928c8..1d5235fc99 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -198,6 +198,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { threads::message_delete_route, users::all_projects, users::admin_user_email, + users::admin_user_discord, users::projects_list_route, users::user_auth_get_route, users::users_search, diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 3ec7205286..b9016c66a8 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -6,7 +6,7 @@ use std::{ use xredis::RedisPool; use super::{ApiError, oauth_clients::get_user_clients}; -use crate::database::PgPool; +use crate::database::{PgPool, ReadOnlyPgPool}; use crate::util::error::Context; use crate::{ auth::{ @@ -22,7 +22,7 @@ use crate::{ organizations::Organization, pats::Scopes, projects::Project, - users::{Badges, Role}, + users::{Badges, Role, User}, }, queue::session::AuthQueue, util::{img::delete_old_images, routes::read_limited_from_payload}, @@ -41,6 +41,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(users_get_route) .service(users_search) .service(admin_user_email) + .service(admin_user_discord) .service(all_projects) .service(projects_list_route) .service(user_notes_edit) @@ -69,6 +70,11 @@ pub struct UserEmailQuery { pub email: String, } +#[derive(Deserialize)] +pub struct UserDiscordQuery { + pub discord_id: u64, +} + #[utoipa::path(tag = "users", responses((status = OK)))] #[get("/user/{user_id}/all-projects")] pub async fn all_projects( @@ -216,7 +222,7 @@ pub async fn all_projects( #[utoipa::path( tag = "users", params(("email" = String, Query)), - responses((status = OK)) + responses((status = OK, body = User)) )] #[get("/user_email")] pub async fn admin_user_email( @@ -225,7 +231,7 @@ pub async fn admin_user_email( redis: web::Data, session_queue: web::Data, email: web::Query, -) -> Result { +) -> Result, ApiError> { let user = get_user_from_headers( &req, &**pool, @@ -267,12 +273,57 @@ pub async fn admin_user_email( .wrap_internal_err("fetching user from database")?; if let Some(user) = user { - Ok(HttpResponse::Ok().json(user)) + Ok(web::Json(user.into())) } else { Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } +#[utoipa::path( + tag = "users", + params(("discord_id" = u64, Query)), + responses((status = OK, body = User)) +)] +#[get("/user_discord")] +pub async fn admin_user_discord( + req: HttpRequest, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, + query: web::Query, +) -> Result, ApiError> { + let user = get_user_from_headers( + &req, + &***ro_pool, + &redis, + &session_queue, + Scopes::SESSION_ACCESS, + ) + .await + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; + + if !user.role.is_admin() { + return Err(ApiError::Auth(eyre!( + "you must be an admin to look up users by discord ID" + ))); + } + + let user_id = DBUser::get_by_discord_id(query.discord_id, &***ro_pool) + .await + .wrap_internal_err("fetching user ID from database")? + .wrap_request_err( + "the discord ID provided is not associated with a user", + )?; + + let user = DBUser::get_id(user_id, &***ro_pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; + + Ok(web::Json(user.into())) +} + #[utoipa::path(tag = "users", responses((status = OK)))] #[get("/user/{user_id}/projects")] pub async fn projects_list_route( diff --git a/apps/labrinth/tests/user.rs b/apps/labrinth/tests/user.rs index ee2e841245..6286343cd6 100644 --- a/apps/labrinth/tests/user.rs +++ b/apps/labrinth/tests/user.rs @@ -1,8 +1,11 @@ -use crate::common::api_common::{ApiProject, ApiTeams}; +use crate::common::api_common::{ApiProject, ApiTeams, AppendsOptionalPat}; use actix_web::test; use common::dummy_data::TestFile; use common::{ - database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT}, + database::{ + ADMIN_USER_PAT, FRIEND_USER_ID, FRIEND_USER_PAT, MOD_USER_PAT, + USER_USER_ID, USER_USER_PAT, + }, environment::{ TestEnvironment, with_test_environment, with_test_environment_all, }, @@ -11,6 +14,103 @@ use labrinth::test::api_v3::ApiV3; pub mod common; +#[actix_rt::test] +async fn discord_lookup_requires_admin_and_returns_public_user() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let mut user = + Box::pin(labrinth::database::models::DBUser::get_id( + labrinth::database::models::DBUserId(3), + &*test_env.db.pool, + &test_env.db.redis_pool, + )) + .await + .unwrap() + .unwrap(); + user.id = labrinth::database::models::DBUserId(6); + user.username = "DiscordLookupUser".to_string(); + user.email = None; + user.discord_id = Some(123456789012345678); + let mut transaction = test_env.db.pool.begin().await.unwrap(); + user.insert(&mut transaction).await.unwrap(); + transaction.commit().await.unwrap(); + + for pat in [None, USER_USER_PAT, MOD_USER_PAT] { + let req = test::TestRequest::get() + .uri("/v3/user_discord?discord_id=123456789012345678") + .append_pat(pat) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, actix_http::StatusCode::UNAUTHORIZED); + } + + let req = test::TestRequest::get() + .uri("/v3/user_discord?discord_id=123456789012345678") + .append_pat(ADMIN_USER_PAT) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, actix_http::StatusCode::OK); + + let user: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(user["id"], "6"); + assert_eq!(user["username"], "DiscordLookupUser"); + assert!(user["email"].is_null()); + assert!(user["payout_data"].is_null()); + assert!(user.get("password").is_none()); + assert!(user.get("totp_secret").is_none()); + + for query in [ + "discord_id=123456789012345679", + "discord_id=0", + "discord_id=-1", + "discord_id=invalid", + "discord_id=9223372036854775808", + "discord_id=18446744073709551615", + "discord_id=18446744073709551616", + "", + ] { + let req = test::TestRequest::get() + .uri(&format!("/v3/user_discord?{query}")) + .append_pat(ADMIN_USER_PAT) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, actix_http::StatusCode::BAD_REQUEST); + } + }, + ) + .await; +} + +#[actix_rt::test] +async fn email_lookup_remains_admin_only_and_returns_public_user() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + for pat in [None, USER_USER_PAT, MOD_USER_PAT, ADMIN_USER_PAT] { + let req = test::TestRequest::get() + .uri("/v3/user_email?email=USER%40MODRINTH.COM") + .append_pat(pat) + .to_request(); + let resp = test_env.call(req).await; + + if pat == ADMIN_USER_PAT { + assert_status!(&resp, actix_http::StatusCode::OK); + let user: serde_json::Value = + test::read_body_json(resp).await; + assert_eq!(user["id"], USER_USER_ID); + assert_eq!(user["username"], "User"); + assert!(user.get("password").is_none()); + assert!(user.get("totp_secret").is_none()); + } else { + assert_status!(&resp, actix_http::StatusCode::UNAUTHORIZED); + } + } + }, + ) + .await; +} + // user GET (permissions, different users) // users GET // user auth