Skip to content

Commit cdd06d6

Browse files
pietroalbiniTurbo87
authored andcommitted
add the /api/private/crate-owner-invitations endpoint
The endpoint provides a listing of all the invitations sent to the current user or all the invitations to a crate the user owns. Unauthenticated users or unrelated users won't be able to see others' invitations to prevent abuses. The two ways to query the endpoint are: GET /api/private/crate-owner-invitations?crate_name={name} GET /api/private/crate-owner-invitations?invitee_id={uid} The endpoint is paginated using only seek-based pagination, and the next page field is provided when more results are available. Once the frontend switches to use the new endpoint we can remove safely remove the old "v1" endpoint, as that's only used for the frontend. Because of this, the "v1" endpoint internally uses the same logic as the new one and converts the data to the old schema.
1 parent 32aba8b commit cdd06d6

File tree

4 files changed

+577
-66
lines changed

4 files changed

+577
-66
lines changed

src/controllers/crate_owner_invitation.rs

Lines changed: 229 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,48 @@
11
use super::frontend_prelude::*;
22

3-
use crate::models::{CrateOwnerInvitation, User};
3+
use crate::controllers::helpers::pagination::{Page, PaginationOptions};
4+
use crate::controllers::util::AuthenticatedUser;
5+
use crate::models::{Crate, CrateOwnerInvitation, Rights, User};
46
use crate::schema::{crate_owner_invitations, crates, users};
5-
use crate::views::{EncodableCrateOwnerInvitationV1, EncodablePublicUser, InvitationResponse};
6-
use diesel::dsl::any;
7-
use std::collections::HashMap;
7+
use crate::util::errors::{forbidden, internal};
8+
use crate::views::{
9+
EncodableCrateOwnerInvitation, EncodableCrateOwnerInvitationV1, EncodablePublicUser,
10+
InvitationResponse,
11+
};
12+
use chrono::{Duration, Utc};
13+
use diesel::{pg::Pg, sql_types::Bool};
14+
use indexmap::IndexMap;
15+
use std::collections::{HashMap, HashSet};
816

917
/// Handles the `GET /api/v1/me/crate_owner_invitations` route.
1018
pub fn list(req: &mut dyn RequestExt) -> EndpointResult {
11-
// Ensure that the user is authenticated
12-
let user = req.authenticate()?.forbid_api_token_auth()?.user();
19+
let auth = req.authenticate()?.forbid_api_token_auth()?;
20+
let user_id = auth.user_id();
1321

14-
// Load all pending invitations for the user
15-
let conn = &*req.db_read_only()?;
16-
let crate_owner_invitations: Vec<CrateOwnerInvitation> = crate_owner_invitations::table
17-
.filter(crate_owner_invitations::invited_user_id.eq(user.id))
18-
.load(&*conn)?;
22+
let PrivateListResponse {
23+
invitations, users, ..
24+
} = prepare_list(req, auth, ListFilter::InviteeId(user_id))?;
1925

20-
// Make a list of all related users
21-
let user_ids: Vec<_> = crate_owner_invitations
22-
.iter()
23-
.map(|invitation| invitation.invited_by_user_id)
24-
.collect();
25-
26-
// Load all related users
27-
let users: Vec<User> = users::table
28-
.filter(users::id.eq(any(user_ids)))
29-
.load(conn)?;
30-
31-
let users: HashMap<i32, User> = users.into_iter().map(|user| (user.id, user)).collect();
32-
33-
// Make a list of all related crates
34-
let crate_ids: Vec<_> = crate_owner_invitations
35-
.iter()
36-
.map(|invitation| invitation.crate_id)
37-
.collect();
38-
39-
// Load all related crates
40-
let crates: Vec<_> = crates::table
41-
.select((crates::id, crates::name))
42-
.filter(crates::id.eq(any(crate_ids)))
43-
.load(conn)?;
44-
45-
let crates: HashMap<i32, String> = crates.into_iter().collect();
46-
47-
// Turn `CrateOwnerInvitation` list into `EncodableCrateOwnerInvitation` list
48-
let config = &req.app().config;
49-
let crate_owner_invitations = crate_owner_invitations
26+
// The schema for the private endpoints is converted to the schema used by v1 endpoints.
27+
let crate_owner_invitations = invitations
5028
.into_iter()
51-
.filter(|i| !i.is_expired(config))
52-
.map(|invitation| {
53-
let inviter_id = invitation.invited_by_user_id;
54-
let inviter_name = users
55-
.get(&inviter_id)
56-
.map(|user| user.gh_login.clone())
57-
.unwrap_or_default();
58-
59-
let crate_name = crates
60-
.get(&invitation.crate_id)
61-
.cloned()
62-
.unwrap_or_else(|| String::from("(unknown crate name)"));
63-
64-
let expires_at = invitation.expires_at(config);
65-
EncodableCrateOwnerInvitationV1::from(invitation, inviter_name, crate_name, expires_at)
29+
.map(|private| {
30+
Ok(EncodableCrateOwnerInvitationV1 {
31+
invited_by_username: users
32+
.iter()
33+
.find(|u| u.id == private.inviter_id)
34+
.ok_or_else(|| internal(&format!("missing user {}", private.inviter_id)))?
35+
.login
36+
.clone(),
37+
invitee_id: private.invitee_id,
38+
inviter_id: private.inviter_id,
39+
crate_name: private.crate_name,
40+
crate_id: private.crate_id,
41+
created_at: private.created_at,
42+
expires_at: private.expires_at,
43+
})
6644
})
67-
.collect();
68-
69-
// Turn `User` list into `EncodablePublicUser` list
70-
let users = users
71-
.into_iter()
72-
.map(|(_, user)| EncodablePublicUser::from(user))
73-
.collect();
45+
.collect::<AppResult<Vec<EncodableCrateOwnerInvitationV1>>>()?;
7446

7547
#[derive(Serialize)]
7648
struct R {
@@ -83,6 +55,200 @@ pub fn list(req: &mut dyn RequestExt) -> EndpointResult {
8355
}))
8456
}
8557

58+
/// Handles the `GET /api/private/crate-owner-invitations` route.
59+
pub fn private_list(req: &mut dyn RequestExt) -> EndpointResult {
60+
let auth = req.authenticate()?.forbid_api_token_auth()?;
61+
62+
let filter = if let Some(crate_name) = req.query().get("crate_name") {
63+
ListFilter::CrateName(crate_name.clone())
64+
} else if let Some(id) = req.query().get("invitee_id").and_then(|i| i.parse().ok()) {
65+
ListFilter::InviteeId(id)
66+
} else {
67+
return Err(bad_request("missing or invalid filter"));
68+
};
69+
70+
let list = prepare_list(req, auth, filter)?;
71+
Ok(req.json(&list))
72+
}
73+
74+
enum ListFilter {
75+
CrateName(String),
76+
InviteeId(i32),
77+
}
78+
79+
fn prepare_list(
80+
req: &mut dyn RequestExt,
81+
auth: AuthenticatedUser,
82+
filter: ListFilter,
83+
) -> AppResult<PrivateListResponse> {
84+
let pagination: PaginationOptions = PaginationOptions::builder()
85+
.enable_pages(false)
86+
.enable_seek(true)
87+
.gather(req)?;
88+
89+
let user = auth.user();
90+
let conn = req.db_read_only()?;
91+
let config = &req.app().config;
92+
93+
let mut crate_names = HashMap::new();
94+
let mut users = IndexMap::new();
95+
users.insert(user.id, user.clone());
96+
97+
let sql_filter: Box<dyn BoxableExpression<crate_owner_invitations::table, Pg, SqlType = Bool>> =
98+
match filter {
99+
ListFilter::CrateName(crate_name) => {
100+
// Only allow crate owners to query pending invitations for their crate.
101+
let krate: Crate = Crate::by_name(&crate_name).first(&*conn)?;
102+
let owners = krate.owners(&*conn)?;
103+
if user.rights(req.app(), &owners)? != Rights::Full {
104+
return Err(forbidden());
105+
}
106+
107+
// Cache the crate name to avoid querying it from the database again
108+
crate_names.insert(krate.id, krate.name.clone());
109+
110+
Box::new(crate_owner_invitations::crate_id.eq(krate.id))
111+
}
112+
ListFilter::InviteeId(invitee_id) => {
113+
if invitee_id != user.id {
114+
return Err(forbidden());
115+
}
116+
Box::new(crate_owner_invitations::invited_user_id.eq(invitee_id))
117+
}
118+
};
119+
120+
// Load all the non-expired invitations matching the filter.
121+
let expire_cutoff = Duration::days(config.ownership_invitations_expiration_days as i64);
122+
let query = crate_owner_invitations::table
123+
.filter(sql_filter)
124+
.filter(crate_owner_invitations::created_at.gt((Utc::now() - expire_cutoff).naive_utc()))
125+
.order_by((
126+
crate_owner_invitations::crate_id,
127+
crate_owner_invitations::invited_user_id,
128+
))
129+
// We fetch one element over the page limit to then detect whether there is a next page.
130+
.limit(pagination.per_page as i64 + 1);
131+
132+
// Load and paginate the results.
133+
let mut raw_invitations: Vec<CrateOwnerInvitation> = match pagination.page {
134+
Page::Unspecified => query.load(&*conn)?,
135+
Page::Seek(s) => {
136+
let seek_key: (i32, i32) = s.decode()?;
137+
query
138+
.filter(
139+
crate_owner_invitations::crate_id.gt(seek_key.0).or(
140+
crate_owner_invitations::crate_id
141+
.eq(seek_key.0)
142+
.and(crate_owner_invitations::invited_user_id.gt(seek_key.1)),
143+
),
144+
)
145+
.load(&*conn)?
146+
}
147+
Page::Numeric(_) => unreachable!("page-based pagination is disabled"),
148+
};
149+
let next_page = if raw_invitations.len() > pagination.per_page as usize {
150+
// We fetch `per_page + 1` to check if there are records for the next page. Since the last
151+
// element is not what the user wanted it's discarded.
152+
raw_invitations.pop();
153+
154+
if let Some(last) = raw_invitations.last() {
155+
let mut params = IndexMap::new();
156+
params.insert(
157+
"seek".into(),
158+
crate::controllers::helpers::pagination::encode_seek((
159+
last.crate_id,
160+
last.invited_user_id,
161+
))?,
162+
);
163+
Some(req.query_with_params(params))
164+
} else {
165+
None
166+
}
167+
} else {
168+
None
169+
};
170+
171+
// Load all the related crates.
172+
let missing_crate_names = raw_invitations
173+
.iter()
174+
.map(|i| i.crate_id)
175+
.filter(|id| !crate_names.contains_key(id))
176+
.collect::<Vec<_>>();
177+
if !missing_crate_names.is_empty() {
178+
let new_names: Vec<(i32, String)> = crates::table
179+
.select((crates::id, crates::name))
180+
.filter(crates::id.eq_any(missing_crate_names))
181+
.load(&*conn)?;
182+
for (id, name) in new_names.into_iter() {
183+
crate_names.insert(id, name);
184+
}
185+
}
186+
187+
// Load all the related users.
188+
let missing_users = raw_invitations
189+
.iter()
190+
.flat_map(|invite| {
191+
std::iter::once(invite.invited_user_id)
192+
.chain(std::iter::once(invite.invited_by_user_id))
193+
})
194+
.filter(|id| !users.contains_key(id))
195+
.collect::<Vec<_>>();
196+
if !missing_users.is_empty() {
197+
let new_users: Vec<User> = users::table
198+
.filter(users::id.eq_any(missing_users))
199+
.load(&*conn)?;
200+
for user in new_users.into_iter() {
201+
users.insert(user.id, user);
202+
}
203+
}
204+
205+
// Turn `CrateOwnerInvitation`s into `EncodablePrivateCrateOwnerInvitation`.
206+
let config = &req.app().config;
207+
let mut invitations = Vec::new();
208+
let mut users_in_response = HashSet::new();
209+
for invitation in raw_invitations.into_iter() {
210+
invitations.push(EncodableCrateOwnerInvitation {
211+
invitee_id: invitation.invited_user_id,
212+
inviter_id: invitation.invited_by_user_id,
213+
crate_id: invitation.crate_id,
214+
crate_name: crate_names
215+
.get(&invitation.crate_id)
216+
.ok_or_else(|| internal(&format!("missing crate with id {}", invitation.crate_id)))?
217+
.clone(),
218+
created_at: invitation.created_at,
219+
expires_at: invitation.expires_at(config),
220+
});
221+
users_in_response.insert(invitation.invited_user_id);
222+
users_in_response.insert(invitation.invited_by_user_id);
223+
}
224+
225+
// Provide a stable response for the users list, only including the referenced users with
226+
// stable sorting.
227+
users.retain(|k, _| users_in_response.contains(k));
228+
users.sort_keys();
229+
230+
Ok(PrivateListResponse {
231+
invitations,
232+
users: users
233+
.into_iter()
234+
.map(|(_, user)| EncodablePublicUser::from(user))
235+
.collect(),
236+
meta: ResponseMeta { next_page },
237+
})
238+
}
239+
240+
#[derive(Serialize)]
241+
struct PrivateListResponse {
242+
invitations: Vec<EncodableCrateOwnerInvitation>,
243+
users: Vec<EncodablePublicUser>,
244+
meta: ResponseMeta,
245+
}
246+
247+
#[derive(Serialize)]
248+
struct ResponseMeta {
249+
next_page: Option<String>,
250+
}
251+
86252
#[derive(Deserialize)]
87253
struct OwnerInvitation {
88254
crate_owner_invite: InvitationResponse,

src/router.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ pub fn build_router(app: &App) -> RouteBuilder {
126126
// Metrics
127127
router.get("/api/private/metrics/:kind", C(metrics::prometheus));
128128

129+
// Crate ownership invitations management in the frontend
130+
router.get(
131+
"/api/private/crate-owner-invitations",
132+
C(crate_owner_invitation::private_list),
133+
);
134+
129135
// Only serve the local checkout of the git index in development mode.
130136
// In production, for crates.io, cargo gets the index from
131137
// https://github.com/rust-lang/crates.io-index directly.

0 commit comments

Comments
 (0)