Skip to content

Add an API route for admins to assess crates #11528

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 10, 2025
Merged
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
1 change: 1 addition & 0 deletions src/controllers.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod helpers;
pub mod util;

pub mod admin;
pub mod category;
pub mod crate_owner_invitation;
pub mod git;
Expand Down
168 changes: 168 additions & 0 deletions src/controllers/admin.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

#[diesel(select_expression = crates::columns::updated_at)]
updated_at: DateTime<Utc>,

#[diesel(select_expression = crate_downloads::columns::downloads.nullable())]
downloads: Option<i64>,

#[diesel(select_expression = recent_crate_downloads::columns::downloads.nullable())]
recent_crate_downloads: Option<i64>,

#[diesel(select_expression = default_versions::columns::num_versions)]
num_versions: Option<i32>,

#[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<Vec<Option<String>>>,

#[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<String>,
req: Parts,
) -> AppResult<Json<AdminListResponse>> {
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<bool>, Option<String>)>(&mut conn)
.await?;

let crates: Vec<DatabaseCrateInfo> = 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<String>,
user_email_verified: bool,
crates: Vec<AdminCrateInfo>,
}

#[derive(Debug, Serialize)]
pub struct AdminCrateInfo {
pub name: String,
pub description: Option<String>,
pub updated_at: DateTime<Utc>,
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<Vec<Option<String>>>,
}

/// 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()
}
2 changes: 2 additions & 0 deletions src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions src/tests/routes/crates/admin.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
1 change: 1 addition & 0 deletions src/tests/routes/crates/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod admin;
pub mod downloads;
mod following;
mod list;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions src/tests/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(&self, owner: &str) -> Response<T> {
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.
Expand Down