diff --git a/src/controllers.rs b/src/controllers.rs index cd055a67c18..a58d7b1ab1b 100644 --- a/src/controllers.rs +++ b/src/controllers.rs @@ -1,6 +1,7 @@ pub mod helpers; pub mod util; +pub mod admin; pub mod category; pub mod crate_owner_invitation; pub mod git; diff --git a/src/controllers/admin.rs b/src/controllers/admin.rs new file mode 100644 index 00000000000..f75e350ccec --- /dev/null +++ b/src/controllers/admin.rs @@ -0,0 +1,168 @@ +use crate::{ + app::AppState, + auth::AuthCheck, + models::{CrateOwner, OwnerKind, User}, + schema::*, + util::errors::{AppResult, custom}, +}; +use axum::{Json, extract::Path}; +use chrono::{DateTime, Utc}; +use diesel::{dsl::count_star, prelude::*}; +use diesel_async::RunQueryDsl; +use http::{StatusCode, request::Parts}; +use serde::Serialize; + +#[derive(Debug, Queryable, Selectable)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct DatabaseCrateInfo { + #[diesel(select_expression = crates::columns::name)] + name: String, + + #[diesel(select_expression = crates::columns::description)] + description: Option, + + #[diesel(select_expression = crates::columns::updated_at)] + updated_at: DateTime, + + #[diesel(select_expression = crate_downloads::columns::downloads.nullable())] + downloads: Option, + + #[diesel(select_expression = recent_crate_downloads::columns::downloads.nullable())] + recent_crate_downloads: Option, + + #[diesel(select_expression = default_versions::columns::num_versions)] + num_versions: Option, + + #[diesel(select_expression = versions::columns::yanked)] + yanked: bool, + + #[diesel(select_expression = versions::columns::num)] + default_version_num: String, + + #[diesel(select_expression = versions::columns::crate_size)] + crate_size: i32, + + #[diesel(select_expression = versions::columns::bin_names)] + bin_names: Option>>, + + #[diesel(select_expression = rev_deps_subquery())] + num_rev_deps: i64, +} + +/// Handles the `GET /api/private/admin_list/{username}` endpoint. +pub async fn list( + state: AppState, + Path(username): Path, + req: Parts, +) -> AppResult> { + let mut conn = state.db_read().await?; + + let auth = AuthCheck::default().check(&req, &mut conn).await?; + let logged_in_user = auth.user(); + + if !logged_in_user.is_admin { + return Err(custom( + StatusCode::FORBIDDEN, + "must be an admin to use this route", + )); + } + + let (user, verified, user_email) = users::table + .left_join(emails::table) + .filter(users::gh_login.eq(username)) + .select(( + User::as_select(), + emails::verified.nullable(), + emails::email.nullable(), + )) + .first::<(User, Option, Option)>(&mut conn) + .await?; + + let crates: Vec = CrateOwner::by_owner_kind(OwnerKind::User) + .inner_join(crates::table) + .left_join(crate_downloads::table.on(crates::id.eq(crate_downloads::crate_id))) + .left_join( + recent_crate_downloads::table.on(crates::id.eq(recent_crate_downloads::crate_id)), + ) + .inner_join(default_versions::table.on(crates::id.eq(default_versions::crate_id))) + .inner_join(versions::table.on(default_versions::version_id.eq(versions::id))) + .filter(crate_owners::owner_id.eq(user.id)) + .select(DatabaseCrateInfo::as_select()) + .order(crates::name.asc()) + .load(&mut conn) + .await?; + + let crates = crates + .into_iter() + .map(|database_crate_info| { + let DatabaseCrateInfo { + name, + description, + updated_at, + downloads, + recent_crate_downloads, + num_versions, + yanked, + default_version_num, + crate_size, + bin_names, + num_rev_deps, + } = database_crate_info; + + AdminCrateInfo { + name, + description, + updated_at, + downloads: downloads.unwrap_or_default() + + recent_crate_downloads.unwrap_or_default(), + num_rev_deps, + num_versions: num_versions.unwrap_or_default() as usize, + yanked, + default_version_num, + crate_size, + bin_names, + } + }) + .collect(); + Ok(Json(AdminListResponse { + user_email, + user_email_verified: verified.unwrap_or_default(), + crates, + })) +} + +#[derive(Debug, Serialize)] +pub struct AdminListResponse { + user_email: Option, + user_email_verified: bool, + crates: Vec, +} + +#[derive(Debug, Serialize)] +pub struct AdminCrateInfo { + pub name: String, + pub description: Option, + pub updated_at: DateTime, + pub downloads: i64, + pub num_rev_deps: i64, + pub num_versions: usize, + pub yanked: bool, + pub default_version_num: String, + pub crate_size: i32, + pub bin_names: Option>>, +} + +/// A subquery that returns the number of reverse dependencies of a crate. +/// +/// **Warning:** this is an incorrect reverse dependencies query, since it +/// includes the `dependencies` rows for all versions, not just the +/// "default version" per crate. However, it's good enough for our +/// purposes here. +#[diesel::dsl::auto_type] +fn rev_deps_subquery() -> _ { + dependencies::table + .select(count_star()) + .filter(dependencies::crate_id.eq(crates::id)) + .single_value() + .assume_not_null() +} diff --git a/src/router.rs b/src/router.rs index 254c5e8ced3..91d598f0cb5 100644 --- a/src/router.rs +++ b/src/router.rs @@ -103,6 +103,8 @@ pub fn build_axum_router(state: AppState) -> Router<()> { let mut router = router // Metrics .route("/api/private/metrics/{kind}", get(metrics::prometheus)) + // Listing a user's crates for admin/support purposes + .route("/api/private/admin_list/{username}", get(admin::list)) // Alerts from GitHub scanning for exposed API tokens .route( "/api/github/secret-scanning/verify", diff --git a/src/tests/mod.rs b/src/tests/mod.rs index eba892f98a8..6fe0e62148c 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -84,7 +84,6 @@ pub struct OkBool { #[allow(dead_code)] ok: bool, } - #[derive(Deserialize, Debug)] pub struct OwnerResp { // server must include `ok: true` to support old cargo clients diff --git a/src/tests/routes/crates/admin.rs b/src/tests/routes/crates/admin.rs new file mode 100644 index 00000000000..892715d5acd --- /dev/null +++ b/src/tests/routes/crates/admin.rs @@ -0,0 +1,75 @@ +use crate::{ + schema::users, + tests::{ + builders::{CrateBuilder, VersionBuilder}, + util::{RequestHelper, TestApp}, + }, +}; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use insta::{assert_json_snapshot, assert_snapshot}; + +#[tokio::test(flavor = "multi_thread")] +async fn admin_list_by_a_non_admin_fails() { + let (_app, anon, user) = TestApp::init().with_user().await; + + let response = anon.admin_list::<()>("anything").await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!( + response.text(), + @r#"{"errors":[{"detail":"this action requires authentication"}]}"# + ); + + let response = user.admin_list::<()>("anything").await; + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!( + response.text(), + @r#"{"errors":[{"detail":"must be an admin to use this route"}]}"# + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn index_include_yanked() -> anyhow::Result<()> { + let (app, _anon, user) = TestApp::init().with_user().await; + let mut conn = app.db_conn().await; + let user = user.as_model(); + + let admin = app.db_new_user("admin").await; + + diesel::update(admin.as_model()) + .set(users::is_admin.eq(true)) + .execute(&mut conn) + .await + .unwrap(); + + let crate_1 = CrateBuilder::new("unyanked", user.id) + .description("My Fun Crate") + .downloads(500) + .recent_downloads(36) + .version(VersionBuilder::new("0.1.0").yanked(true)) + .version(VersionBuilder::new("1.0.0")) + .version(VersionBuilder::new("2.0.0").yanked(true)) + .expect_build(&mut conn) + .await; + + CrateBuilder::new("all_yanked", user.id) + .version(VersionBuilder::new("1.0.0").yanked(true)) + .version(VersionBuilder::new("2.0.0").yanked(true)) + .expect_build(&mut conn) + .await; + + CrateBuilder::new("someone_elses_crate", admin.as_model().id) + .version(VersionBuilder::new("1.0.0").dependency(&crate_1, None)) + .expect_build(&mut conn) + .await; + + // Include fully yanked (all versions were yanked) crates + let username = &user.gh_login; + let response = admin.admin_list::<()>(username).await; + + assert_json_snapshot!(response.json(), { + ".crates[].updated_at" => "[datetime]", + }); + + Ok(()) +} diff --git a/src/tests/routes/crates/mod.rs b/src/tests/routes/crates/mod.rs index 2619dcbfb94..a2160a66431 100644 --- a/src/tests/routes/crates/mod.rs +++ b/src/tests/routes/crates/mod.rs @@ -1,3 +1,4 @@ +mod admin; pub mod downloads; mod following; mod list; diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__admin__index_include_yanked.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__admin__index_include_yanked.snap new file mode 100644 index 00000000000..6bc1c1257cb --- /dev/null +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__admin__index_include_yanked.snap @@ -0,0 +1,35 @@ +--- +source: src/tests/routes/crates/admin.rs +expression: response.json() +snapshot_kind: text +--- +{ + "crates": [ + { + "bin_names": null, + "crate_size": 0, + "default_version_num": "2.0.0", + "description": null, + "downloads": 0, + "name": "all_yanked", + "num_rev_deps": 0, + "num_versions": 2, + "updated_at": "[datetime]", + "yanked": true + }, + { + "bin_names": null, + "crate_size": 0, + "default_version_num": "1.0.0", + "description": "My Fun Crate", + "downloads": 536, + "name": "unyanked", + "num_rev_deps": 1, + "num_versions": 3, + "updated_at": "[datetime]", + "yanked": false + } + ], + "user_email": "foo@example.com", + "user_email_verified": true +} diff --git a/src/tests/util.rs b/src/tests/util.rs index c6f4c3766fc..6b6bfae932f 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -186,6 +186,12 @@ pub trait RequestHelper { self.get_with_query("/api/v1/crates", query).await.good() } + /// Request the JSON used for the admin list page + async fn admin_list(&self, owner: &str) -> Response { + let url = format!("/api/private/admin_list/{owner}"); + self.get(&url).await + } + /// Publish the crate and run background jobs to completion /// /// Background jobs will publish to the git index and sync to the HTTP index.