Skip to content

Commit 58ff83f

Browse files
committed
Auto merge of #3763 - pietroalbini:get-crate-owner-invitations, r=Turbo87
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. Part of #2868 r? `@Turbo87`
2 parents ea1fe1f + cdd06d6 commit 58ff83f

File tree

5 files changed

+635
-104
lines changed

5 files changed

+635
-104
lines changed

src/controllers/crate_owner_invitation.rs

Lines changed: 233 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,52 @@
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::{EncodableCrateOwnerInvitation, 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

9-
/// Handles the `GET /me/crate_owner_invitations` route.
17+
/// 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-
EncodableCrateOwnerInvitation::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 {
77-
crate_owner_invitations: Vec<EncodableCrateOwnerInvitation>,
49+
crate_owner_invitations: Vec<EncodableCrateOwnerInvitationV1>,
7850
users: Vec<EncodablePublicUser>,
7951
}
8052
Ok(req.json(&R {
@@ -83,12 +55,206 @@ 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,
89255
}
90256

91-
/// Handles the `PUT /me/crate_owner_invitations/:crate_id` route.
257+
/// Handles the `PUT /api/v1/me/crate_owner_invitations/:crate_id` route.
92258
pub fn handle_invite(req: &mut dyn RequestExt) -> EndpointResult {
93259
let mut body = String::new();
94260
req.body().read_to_string(&mut body)?;
@@ -117,7 +283,7 @@ pub fn handle_invite(req: &mut dyn RequestExt) -> EndpointResult {
117283
}))
118284
}
119285

120-
/// Handles the `PUT /me/crate_owner_invitations/accept/:token` route.
286+
/// Handles the `PUT /api/v1/me/crate_owner_invitations/accept/:token` route.
121287
pub fn handle_invite_with_token(req: &mut dyn RequestExt) -> EndpointResult {
122288
let config = &req.app().config;
123289
let conn = req.db_conn()?;

0 commit comments

Comments
 (0)