Skip to content

Commit

Permalink
Auto merge of #3763 - pietroalbini:get-crate-owner-invitations, r=Tur…
Browse files Browse the repository at this point in the history
…bo87

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`
  • Loading branch information
bors committed Aug 18, 2021
2 parents ea1fe1f + 2d1ac26 commit 0aadc73
Show file tree
Hide file tree
Showing 5 changed files with 635 additions and 104 deletions.
300 changes: 233 additions & 67 deletions src/controllers/crate_owner_invitation.rs
Original file line number Diff line number Diff line change
@@ -1,80 +1,52 @@
use super::frontend_prelude::*;

use crate::models::{CrateOwnerInvitation, User};
use crate::controllers::helpers::pagination::{Page, PaginationOptions};
use crate::controllers::util::AuthenticatedUser;
use crate::models::{Crate, CrateOwnerInvitation, Rights, User};
use crate::schema::{crate_owner_invitations, crates, users};
use crate::views::{EncodableCrateOwnerInvitation, EncodablePublicUser, InvitationResponse};
use diesel::dsl::any;
use std::collections::HashMap;
use crate::util::errors::{forbidden, internal};
use crate::views::{
EncodableCrateOwnerInvitation, EncodableCrateOwnerInvitationV1, EncodablePublicUser,
InvitationResponse,
};
use chrono::{Duration, Utc};
use diesel::{pg::Pg, sql_types::Bool};
use indexmap::IndexMap;
use std::collections::{HashMap, HashSet};

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

// Load all pending invitations for the user
let conn = &*req.db_read_only()?;
let crate_owner_invitations: Vec<CrateOwnerInvitation> = crate_owner_invitations::table
.filter(crate_owner_invitations::invited_user_id.eq(user.id))
.load(&*conn)?;
let PrivateListResponse {
invitations, users, ..
} = prepare_list(req, auth, ListFilter::InviteeId(user_id))?;

// Make a list of all related users
let user_ids: Vec<_> = crate_owner_invitations
.iter()
.map(|invitation| invitation.invited_by_user_id)
.collect();

// Load all related users
let users: Vec<User> = users::table
.filter(users::id.eq(any(user_ids)))
.load(conn)?;

let users: HashMap<i32, User> = users.into_iter().map(|user| (user.id, user)).collect();

// Make a list of all related crates
let crate_ids: Vec<_> = crate_owner_invitations
.iter()
.map(|invitation| invitation.crate_id)
.collect();

// Load all related crates
let crates: Vec<_> = crates::table
.select((crates::id, crates::name))
.filter(crates::id.eq(any(crate_ids)))
.load(conn)?;

let crates: HashMap<i32, String> = crates.into_iter().collect();

// Turn `CrateOwnerInvitation` list into `EncodableCrateOwnerInvitation` list
let config = &req.app().config;
let crate_owner_invitations = crate_owner_invitations
// The schema for the private endpoints is converted to the schema used by v1 endpoints.
let crate_owner_invitations = invitations
.into_iter()
.filter(|i| !i.is_expired(config))
.map(|invitation| {
let inviter_id = invitation.invited_by_user_id;
let inviter_name = users
.get(&inviter_id)
.map(|user| user.gh_login.clone())
.unwrap_or_default();

let crate_name = crates
.get(&invitation.crate_id)
.cloned()
.unwrap_or_else(|| String::from("(unknown crate name)"));

let expires_at = invitation.expires_at(config);
EncodableCrateOwnerInvitation::from(invitation, inviter_name, crate_name, expires_at)
.map(|private| {
Ok(EncodableCrateOwnerInvitationV1 {
invited_by_username: users
.iter()
.find(|u| u.id == private.inviter_id)
.ok_or_else(|| internal(&format!("missing user {}", private.inviter_id)))?
.login
.clone(),
invitee_id: private.invitee_id,
inviter_id: private.inviter_id,
crate_name: private.crate_name,
crate_id: private.crate_id,
created_at: private.created_at,
expires_at: private.expires_at,
})
})
.collect();

// Turn `User` list into `EncodablePublicUser` list
let users = users
.into_iter()
.map(|(_, user)| EncodablePublicUser::from(user))
.collect();
.collect::<AppResult<Vec<EncodableCrateOwnerInvitationV1>>>()?;

#[derive(Serialize)]
struct R {
crate_owner_invitations: Vec<EncodableCrateOwnerInvitation>,
crate_owner_invitations: Vec<EncodableCrateOwnerInvitationV1>,
users: Vec<EncodablePublicUser>,
}
Ok(req.json(&R {
Expand All @@ -83,12 +55,206 @@ pub fn list(req: &mut dyn RequestExt) -> EndpointResult {
}))
}

/// Handles the `GET /api/private/crate-owner-invitations` route.
pub fn private_list(req: &mut dyn RequestExt) -> EndpointResult {
let auth = req.authenticate()?.forbid_api_token_auth()?;

let filter = if let Some(crate_name) = req.query().get("crate_name") {
ListFilter::CrateName(crate_name.clone())
} else if let Some(id) = req.query().get("invitee_id").and_then(|i| i.parse().ok()) {
ListFilter::InviteeId(id)
} else {
return Err(bad_request("missing or invalid filter"));
};

let list = prepare_list(req, auth, filter)?;
Ok(req.json(&list))
}

enum ListFilter {
CrateName(String),
InviteeId(i32),
}

fn prepare_list(
req: &mut dyn RequestExt,
auth: AuthenticatedUser,
filter: ListFilter,
) -> AppResult<PrivateListResponse> {
let pagination: PaginationOptions = PaginationOptions::builder()
.enable_pages(false)
.enable_seek(true)
.gather(req)?;

let user = auth.user();
let conn = req.db_read_only()?;
let config = &req.app().config;

let mut crate_names = HashMap::new();
let mut users = IndexMap::new();
users.insert(user.id, user.clone());

let sql_filter: Box<dyn BoxableExpression<crate_owner_invitations::table, Pg, SqlType = Bool>> =
match filter {
ListFilter::CrateName(crate_name) => {
// Only allow crate owners to query pending invitations for their crate.
let krate: Crate = Crate::by_name(&crate_name).first(&*conn)?;
let owners = krate.owners(&*conn)?;
if user.rights(req.app(), &owners)? != Rights::Full {
return Err(forbidden());
}

// Cache the crate name to avoid querying it from the database again
crate_names.insert(krate.id, krate.name.clone());

Box::new(crate_owner_invitations::crate_id.eq(krate.id))
}
ListFilter::InviteeId(invitee_id) => {
if invitee_id != user.id {
return Err(forbidden());
}
Box::new(crate_owner_invitations::invited_user_id.eq(invitee_id))
}
};

// Load all the non-expired invitations matching the filter.
let expire_cutoff = Duration::days(config.ownership_invitations_expiration_days as i64);
let query = crate_owner_invitations::table
.filter(sql_filter)
.filter(crate_owner_invitations::created_at.gt((Utc::now() - expire_cutoff).naive_utc()))
.order_by((
crate_owner_invitations::crate_id,
crate_owner_invitations::invited_user_id,
))
// We fetch one element over the page limit to then detect whether there is a next page.
.limit(pagination.per_page as i64 + 1);

// Load and paginate the results.
let mut raw_invitations: Vec<CrateOwnerInvitation> = match pagination.page {
Page::Unspecified => query.load(&*conn)?,
Page::Seek(s) => {
let seek_key: (i32, i32) = s.decode()?;
query
.filter(
crate_owner_invitations::crate_id.gt(seek_key.0).or(
crate_owner_invitations::crate_id
.eq(seek_key.0)
.and(crate_owner_invitations::invited_user_id.gt(seek_key.1)),
),
)
.load(&*conn)?
}
Page::Numeric(_) => unreachable!("page-based pagination is disabled"),
};
let next_page = if raw_invitations.len() > pagination.per_page as usize {
// We fetch `per_page + 1` to check if there are records for the next page. Since the last
// element is not what the user wanted it's discarded.
raw_invitations.pop();

if let Some(last) = raw_invitations.last() {
let mut params = IndexMap::new();
params.insert(
"seek".into(),
crate::controllers::helpers::pagination::encode_seek((
last.crate_id,
last.invited_user_id,
))?,
);
Some(req.query_with_params(params))
} else {
None
}
} else {
None
};

// Load all the related crates.
let missing_crate_names = raw_invitations
.iter()
.map(|i| i.crate_id)
.filter(|id| !crate_names.contains_key(id))
.collect::<Vec<_>>();
if !missing_crate_names.is_empty() {
let new_names: Vec<(i32, String)> = crates::table
.select((crates::id, crates::name))
.filter(crates::id.eq_any(missing_crate_names))
.load(&*conn)?;
for (id, name) in new_names.into_iter() {
crate_names.insert(id, name);
}
}

// Load all the related users.
let missing_users = raw_invitations
.iter()
.flat_map(|invite| {
std::iter::once(invite.invited_user_id)
.chain(std::iter::once(invite.invited_by_user_id))
})
.filter(|id| !users.contains_key(id))
.collect::<Vec<_>>();
if !missing_users.is_empty() {
let new_users: Vec<User> = users::table
.filter(users::id.eq_any(missing_users))
.load(&*conn)?;
for user in new_users.into_iter() {
users.insert(user.id, user);
}
}

// Turn `CrateOwnerInvitation`s into `EncodablePrivateCrateOwnerInvitation`.
let config = &req.app().config;
let mut invitations = Vec::new();
let mut users_in_response = HashSet::new();
for invitation in raw_invitations.into_iter() {
invitations.push(EncodableCrateOwnerInvitation {
invitee_id: invitation.invited_user_id,
inviter_id: invitation.invited_by_user_id,
crate_id: invitation.crate_id,
crate_name: crate_names
.get(&invitation.crate_id)
.ok_or_else(|| internal(&format!("missing crate with id {}", invitation.crate_id)))?
.clone(),
created_at: invitation.created_at,
expires_at: invitation.expires_at(config),
});
users_in_response.insert(invitation.invited_user_id);
users_in_response.insert(invitation.invited_by_user_id);
}

// Provide a stable response for the users list, only including the referenced users with
// stable sorting.
users.retain(|k, _| users_in_response.contains(k));
users.sort_keys();

Ok(PrivateListResponse {
invitations,
users: users
.into_iter()
.map(|(_, user)| EncodablePublicUser::from(user))
.collect(),
meta: ResponseMeta { next_page },
})
}

#[derive(Serialize)]
struct PrivateListResponse {
invitations: Vec<EncodableCrateOwnerInvitation>,
users: Vec<EncodablePublicUser>,
meta: ResponseMeta,
}

#[derive(Serialize)]
struct ResponseMeta {
next_page: Option<String>,
}

#[derive(Deserialize)]
struct OwnerInvitation {
crate_owner_invite: InvitationResponse,
}

/// Handles the `PUT /me/crate_owner_invitations/:crate_id` route.
/// Handles the `PUT /api/v1/me/crate_owner_invitations/:crate_id` route.
pub fn handle_invite(req: &mut dyn RequestExt) -> EndpointResult {
let mut body = String::new();
req.body().read_to_string(&mut body)?;
Expand Down Expand Up @@ -117,7 +283,7 @@ pub fn handle_invite(req: &mut dyn RequestExt) -> EndpointResult {
}))
}

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

0 comments on commit 0aadc73

Please sign in to comment.