Skip to content

Commit 4dcf06d

Browse files
Merge pull request #11528 from rust-lang/superuser-super-user-view
Add an API route for admins to assess crates
2 parents 80d33f1 + e36f29b commit 4dcf06d

File tree

8 files changed

+288
-1
lines changed

8 files changed

+288
-1
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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
use crate::{
2+
app::AppState,
3+
auth::AuthCheck,
4+
models::{CrateOwner, OwnerKind, User},
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+
15+
#[derive(Debug, Queryable, Selectable)]
16+
#[diesel(check_for_backend(diesel::pg::Pg))]
17+
struct DatabaseCrateInfo {
18+
#[diesel(select_expression = crates::columns::name)]
19+
name: String,
20+
21+
#[diesel(select_expression = crates::columns::description)]
22+
description: Option<String>,
23+
24+
#[diesel(select_expression = crates::columns::updated_at)]
25+
updated_at: DateTime<Utc>,
26+
27+
#[diesel(select_expression = crate_downloads::columns::downloads.nullable())]
28+
downloads: Option<i64>,
29+
30+
#[diesel(select_expression = recent_crate_downloads::columns::downloads.nullable())]
31+
recent_crate_downloads: Option<i64>,
32+
33+
#[diesel(select_expression = default_versions::columns::num_versions)]
34+
num_versions: Option<i32>,
35+
36+
#[diesel(select_expression = versions::columns::yanked)]
37+
yanked: bool,
38+
39+
#[diesel(select_expression = versions::columns::num)]
40+
default_version_num: String,
41+
42+
#[diesel(select_expression = versions::columns::crate_size)]
43+
crate_size: i32,
44+
45+
#[diesel(select_expression = versions::columns::bin_names)]
46+
bin_names: Option<Vec<Option<String>>>,
47+
48+
#[diesel(select_expression = rev_deps_subquery())]
49+
num_rev_deps: i64,
50+
}
51+
52+
/// Handles the `GET /api/private/admin_list/{username}` endpoint.
53+
pub async fn list(
54+
state: AppState,
55+
Path(username): Path<String>,
56+
req: Parts,
57+
) -> AppResult<Json<AdminListResponse>> {
58+
let mut conn = state.db_read().await?;
59+
60+
let auth = AuthCheck::default().check(&req, &mut conn).await?;
61+
let logged_in_user = auth.user();
62+
63+
if !logged_in_user.is_admin {
64+
return Err(custom(
65+
StatusCode::FORBIDDEN,
66+
"must be an admin to use this route",
67+
));
68+
}
69+
70+
let (user, verified, user_email) = users::table
71+
.left_join(emails::table)
72+
.filter(users::gh_login.eq(username))
73+
.select((
74+
User::as_select(),
75+
emails::verified.nullable(),
76+
emails::email.nullable(),
77+
))
78+
.first::<(User, Option<bool>, Option<String>)>(&mut conn)
79+
.await?;
80+
81+
let crates: Vec<DatabaseCrateInfo> = CrateOwner::by_owner_kind(OwnerKind::User)
82+
.inner_join(crates::table)
83+
.left_join(crate_downloads::table.on(crates::id.eq(crate_downloads::crate_id)))
84+
.left_join(
85+
recent_crate_downloads::table.on(crates::id.eq(recent_crate_downloads::crate_id)),
86+
)
87+
.inner_join(default_versions::table.on(crates::id.eq(default_versions::crate_id)))
88+
.inner_join(versions::table.on(default_versions::version_id.eq(versions::id)))
89+
.filter(crate_owners::owner_id.eq(user.id))
90+
.select(DatabaseCrateInfo::as_select())
91+
.order(crates::name.asc())
92+
.load(&mut conn)
93+
.await?;
94+
95+
let crates = crates
96+
.into_iter()
97+
.map(|database_crate_info| {
98+
let DatabaseCrateInfo {
99+
name,
100+
description,
101+
updated_at,
102+
downloads,
103+
recent_crate_downloads,
104+
num_versions,
105+
yanked,
106+
default_version_num,
107+
crate_size,
108+
bin_names,
109+
num_rev_deps,
110+
} = database_crate_info;
111+
112+
AdminCrateInfo {
113+
name,
114+
description,
115+
updated_at,
116+
downloads: downloads.unwrap_or_default()
117+
+ recent_crate_downloads.unwrap_or_default(),
118+
num_rev_deps,
119+
num_versions: num_versions.unwrap_or_default() as usize,
120+
yanked,
121+
default_version_num,
122+
crate_size,
123+
bin_names,
124+
}
125+
})
126+
.collect();
127+
Ok(Json(AdminListResponse {
128+
user_email,
129+
user_email_verified: verified.unwrap_or_default(),
130+
crates,
131+
}))
132+
}
133+
134+
#[derive(Debug, Serialize)]
135+
pub struct AdminListResponse {
136+
user_email: Option<String>,
137+
user_email_verified: bool,
138+
crates: Vec<AdminCrateInfo>,
139+
}
140+
141+
#[derive(Debug, Serialize)]
142+
pub struct AdminCrateInfo {
143+
pub name: String,
144+
pub description: Option<String>,
145+
pub updated_at: DateTime<Utc>,
146+
pub downloads: i64,
147+
pub num_rev_deps: i64,
148+
pub num_versions: usize,
149+
pub yanked: bool,
150+
pub default_version_num: String,
151+
pub crate_size: i32,
152+
pub bin_names: Option<Vec<Option<String>>>,
153+
}
154+
155+
/// A subquery that returns the number of reverse dependencies of a crate.
156+
///
157+
/// **Warning:** this is an incorrect reverse dependencies query, since it
158+
/// includes the `dependencies` rows for all versions, not just the
159+
/// "default version" per crate. However, it's good enough for our
160+
/// purposes here.
161+
#[diesel::dsl::auto_type]
162+
fn rev_deps_subquery() -> _ {
163+
dependencies::table
164+
.select(count_star())
165+
.filter(dependencies::crate_id.eq(crates::id))
166+
.single_value()
167+
.assume_not_null()
168+
}

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: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ pub struct OkBool {
8484
#[allow(dead_code)]
8585
ok: bool,
8686
}
87-
8887
#[derive(Deserialize, Debug)]
8988
pub struct OwnerResp {
9089
// server must include `ok: true` to support old cargo clients

src/tests/routes/crates/admin.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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_json_snapshot, 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+
.description("My Fun Crate")
47+
.downloads(500)
48+
.recent_downloads(36)
49+
.version(VersionBuilder::new("0.1.0").yanked(true))
50+
.version(VersionBuilder::new("1.0.0"))
51+
.version(VersionBuilder::new("2.0.0").yanked(true))
52+
.expect_build(&mut conn)
53+
.await;
54+
55+
CrateBuilder::new("all_yanked", user.id)
56+
.version(VersionBuilder::new("1.0.0").yanked(true))
57+
.version(VersionBuilder::new("2.0.0").yanked(true))
58+
.expect_build(&mut conn)
59+
.await;
60+
61+
CrateBuilder::new("someone_elses_crate", admin.as_model().id)
62+
.version(VersionBuilder::new("1.0.0").dependency(&crate_1, None))
63+
.expect_build(&mut conn)
64+
.await;
65+
66+
// Include fully yanked (all versions were yanked) crates
67+
let username = &user.gh_login;
68+
let response = admin.admin_list::<()>(username).await;
69+
70+
assert_json_snapshot!(response.json(), {
71+
".crates[].updated_at" => "[datetime]",
72+
});
73+
74+
Ok(())
75+
}

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;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
source: src/tests/routes/crates/admin.rs
3+
expression: response.json()
4+
snapshot_kind: text
5+
---
6+
{
7+
"crates": [
8+
{
9+
"bin_names": null,
10+
"crate_size": 0,
11+
"default_version_num": "2.0.0",
12+
"description": null,
13+
"downloads": 0,
14+
"name": "all_yanked",
15+
"num_rev_deps": 0,
16+
"num_versions": 2,
17+
"updated_at": "[datetime]",
18+
"yanked": true
19+
},
20+
{
21+
"bin_names": null,
22+
"crate_size": 0,
23+
"default_version_num": "1.0.0",
24+
"description": "My Fun Crate",
25+
"downloads": 536,
26+
"name": "unyanked",
27+
"num_rev_deps": 1,
28+
"num_versions": 3,
29+
"updated_at": "[datetime]",
30+
"yanked": false
31+
}
32+
],
33+
"user_email": "foo@example.com",
34+
"user_email_verified": true
35+
}

src/tests/util.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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<T>(&self, owner: &str) -> Response<T> {
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)