Skip to content

Commit 02879a3

Browse files
committed
Add an API route for admins to assess crates
Connects to #10387. This API route, under `/private/` rather than `v1`, will make it a lot easier to assess whether crates contain useful functionality or are squatting. This is only the backend; some of the functionality described in the issue will be frontend-only, but I wanted to get the API in first. The API route will only return data if the currently authenticated user is an admin. This is important because the crate owner's verified email address is part of the returned data so that the admin can contact the owner if necessary. Another reason this route is limited to admins is that some of the queries may be slow.
1 parent f948efe commit 02879a3

File tree

7 files changed

+226
-3
lines changed

7 files changed

+226
-3
lines changed

src/controllers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod helpers;
22
pub mod util;
33

4+
pub mod admin;
45
pub mod category;
56
pub mod crate_owner_invitation;
67
pub mod git;

src/controllers/admin.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
use crate::{
2+
app::AppState,
3+
auth::AuthCheck,
4+
models::{CrateOwner, OwnerKind, User, Version},
5+
schema::*,
6+
util::errors::{AppResult, custom},
7+
};
8+
use axum::{Json, extract::Path};
9+
use chrono::{DateTime, Utc};
10+
use diesel::{dsl::count_star, prelude::*};
11+
use diesel_async::RunQueryDsl;
12+
use http::{StatusCode, request::Parts};
13+
use serde::Serialize;
14+
use std::collections::HashMap;
15+
16+
/// Handles the `GET /api/private/admin_list/{username}` endpoint.
17+
pub async fn list(
18+
state: AppState,
19+
Path(username): Path<String>,
20+
req: Parts,
21+
) -> AppResult<Json<AdminListResponse>> {
22+
let mut conn = state.db_write().await?;
23+
24+
let auth = AuthCheck::default().check(&req, &mut conn).await?;
25+
let logged_in_user = auth.user();
26+
27+
if !logged_in_user.is_admin {
28+
return Err(custom(
29+
StatusCode::FORBIDDEN,
30+
"must be an admin to use this route",
31+
));
32+
}
33+
34+
let (user, verified, email) = users::table
35+
.left_join(emails::table)
36+
.filter(users::gh_login.eq(username))
37+
.select((
38+
User::as_select(),
39+
emails::verified.nullable(),
40+
emails::email.nullable(),
41+
))
42+
.first::<(User, Option<bool>, Option<String>)>(&mut conn)
43+
.await?;
44+
45+
let crates: Vec<(i32, String, DateTime<Utc>, i64)> = CrateOwner::by_owner_kind(OwnerKind::User)
46+
.inner_join(crates::table)
47+
.filter(crate_owners::owner_id.eq(user.id))
48+
.select((
49+
crates::id,
50+
crates::name,
51+
crates::updated_at,
52+
rev_deps_subquery(),
53+
))
54+
.order(crates::name.asc())
55+
.load(&mut conn)
56+
.await?;
57+
58+
let crate_ids: Vec<_> = crates.iter().map(|(id, ..)| id).collect();
59+
60+
let versions: Vec<Version> = versions::table
61+
.filter(versions::crate_id.eq_any(crate_ids))
62+
.select(Version::as_select())
63+
.load(&mut conn)
64+
.await?;
65+
let mut versions_by_crate_id: HashMap<i32, Vec<Version>> = HashMap::new();
66+
for version in versions {
67+
let crate_versions = versions_by_crate_id.entry(version.crate_id).or_default();
68+
crate_versions.push(version);
69+
}
70+
71+
let verified = verified.unwrap_or(false);
72+
let crates = crates
73+
.into_iter()
74+
.map(|(crate_id, name, updated_at, num_rev_deps)| {
75+
let versions = versions_by_crate_id.get(&crate_id);
76+
let last_version = versions.and_then(|v| v.last());
77+
AdminCrateInfo {
78+
name,
79+
updated_at,
80+
num_rev_deps,
81+
num_versions: versions.map(|v| v.len()).unwrap_or(0),
82+
crate_size: last_version.map(|v| v.crate_size).unwrap_or(0),
83+
bin_names: last_version
84+
.map(|v| v.bin_names.clone())
85+
.unwrap_or_default(),
86+
}
87+
})
88+
.collect();
89+
Ok(Json(AdminListResponse {
90+
user_email: verified.then_some(email).flatten(),
91+
crates,
92+
}))
93+
}
94+
95+
#[derive(Debug, Serialize)]
96+
pub struct AdminListResponse {
97+
user_email: Option<String>,
98+
crates: Vec<AdminCrateInfo>,
99+
}
100+
101+
#[derive(Debug, Serialize)]
102+
pub struct AdminCrateInfo {
103+
pub name: String,
104+
pub updated_at: DateTime<Utc>,
105+
pub num_rev_deps: i64,
106+
pub num_versions: usize,
107+
pub crate_size: i32,
108+
pub bin_names: Option<Vec<Option<String>>>,
109+
}
110+
111+
/// A subquery that returns the number of reverse dependencies of a crate.
112+
///
113+
/// **Warning:** this is an incorrect reverse dependencies query, since it
114+
/// includes the `dependencies` rows for all versions, not just the
115+
/// "default version" per crate. However, it's good enough for our
116+
/// purposes here.
117+
#[diesel::dsl::auto_type]
118+
fn rev_deps_subquery() -> _ {
119+
dependencies::table
120+
.select(count_star())
121+
.filter(dependencies::crate_id.eq(crates::id))
122+
.single_value()
123+
.assume_not_null()
124+
}

src/router.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
103103
let mut router = router
104104
// Metrics
105105
.route("/api/private/metrics/{kind}", get(metrics::prometheus))
106+
// Listing a user's crates for admin/support purposes
107+
.route("/api/private/admin_list/{username}", get(admin::list))
106108
// Alerts from GitHub scanning for exposed API tokens
107109
.route(
108110
"/api/github/secret-scanning/verify",

src/tests/mod.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,17 @@ pub struct OkBool {
8484
#[allow(dead_code)]
8585
ok: bool,
8686
}
87-
87+
#[derive(Deserialize)]
88+
pub struct AdminListResponse {
89+
user_email: Option<String>,
90+
crates: Vec<AdminCrateInfo>,
91+
}
92+
#[derive(Deserialize)]
93+
pub struct AdminCrateInfo {
94+
name: String,
95+
num_versions: usize,
96+
num_rev_deps: i64,
97+
}
8898
#[derive(Deserialize, Debug)]
8999
pub struct OwnerResp {
90100
// server must include `ok: true` to support old cargo clients

src/tests/routes/crates/admin.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
use crate::{
2+
schema::users,
3+
tests::{
4+
builders::{CrateBuilder, VersionBuilder},
5+
util::{RequestHelper, TestApp},
6+
},
7+
};
8+
use diesel::prelude::*;
9+
use diesel_async::RunQueryDsl;
10+
use insta::assert_snapshot;
11+
12+
#[tokio::test(flavor = "multi_thread")]
13+
async fn admin_list_by_a_non_admin_fails() {
14+
let (_app, anon, user) = TestApp::init().with_user().await;
15+
16+
let response = anon.admin_list("anything").await;
17+
assert_snapshot!(response.status(), @"403 Forbidden");
18+
assert_snapshot!(
19+
response.text(),
20+
@r#"{"errors":[{"detail":"this action requires authentication"}]}"#
21+
);
22+
23+
let response = user.admin_list("anything").await;
24+
assert_snapshot!(response.status(), @"403 Forbidden");
25+
assert_snapshot!(
26+
response.text(),
27+
@r#"{"errors":[{"detail":"must be an admin to use this route"}]}"#
28+
);
29+
}
30+
31+
#[tokio::test(flavor = "multi_thread")]
32+
async fn index_include_yanked() -> anyhow::Result<()> {
33+
let (app, _anon, user) = TestApp::init().with_user().await;
34+
let mut conn = app.db_conn().await;
35+
let user = user.as_model();
36+
37+
let admin = app.db_new_user("admin").await;
38+
39+
diesel::update(admin.as_model())
40+
.set(users::is_admin.eq(true))
41+
.execute(&mut conn)
42+
.await
43+
.unwrap();
44+
45+
let crate_1 = CrateBuilder::new("unyanked", user.id)
46+
.version(VersionBuilder::new("0.1.0").yanked(true))
47+
.version(VersionBuilder::new("1.0.0"))
48+
.version(VersionBuilder::new("2.0.0"))
49+
.expect_build(&mut conn)
50+
.await;
51+
52+
CrateBuilder::new("all_yanked", user.id)
53+
.version(VersionBuilder::new("1.0.0").yanked(true))
54+
.version(VersionBuilder::new("2.0.0").yanked(true))
55+
.expect_build(&mut conn)
56+
.await;
57+
58+
CrateBuilder::new("someone_elses_crate", admin.as_model().id)
59+
.version(VersionBuilder::new("1.0.0").dependency(&crate_1, None))
60+
.expect_build(&mut conn)
61+
.await;
62+
63+
// Include fully yanked (all versions were yanked) crates
64+
let username = &user.gh_login;
65+
let json = admin.admin_list(username).await.good();
66+
67+
assert_eq!(json.user_email.unwrap(), "foo@example.com");
68+
assert_eq!(json.crates.len(), 2);
69+
70+
assert_eq!(json.crates[0].name, "all_yanked");
71+
assert_eq!(json.crates[0].num_versions, 2);
72+
assert_eq!(json.crates[0].num_rev_deps, 0);
73+
74+
assert_eq!(json.crates[1].name, "unyanked");
75+
assert_eq!(json.crates[1].num_versions, 3);
76+
assert_eq!(json.crates[1].num_rev_deps, 1);
77+
78+
Ok(())
79+
}

src/tests/routes/crates/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod admin;
12
pub mod downloads;
23
mod following;
34
mod list;

src/tests/util.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
2222
use crate::models::{ApiToken, User};
2323
use crate::tests::{
24-
CategoryListResponse, CategoryResponse, CrateList, CrateResponse, GoodCrate, OwnerResp,
25-
OwnersResponse, VersionResponse,
24+
AdminListResponse, CategoryListResponse, CategoryResponse, CrateList, CrateResponse, GoodCrate,
25+
OwnerResp, OwnersResponse, VersionResponse,
2626
};
2727
use std::future::Future;
2828

@@ -186,6 +186,12 @@ pub trait RequestHelper {
186186
self.get_with_query("/api/v1/crates", query).await.good()
187187
}
188188

189+
/// Request the JSON used for the admin list page
190+
async fn admin_list(&self, owner: &str) -> Response<AdminListResponse> {
191+
let url = format!("/api/private/admin_list/{owner}");
192+
self.get(&url).await
193+
}
194+
189195
/// Publish the crate and run background jobs to completion
190196
///
191197
/// Background jobs will publish to the git index and sync to the HTTP index.

0 commit comments

Comments
 (0)