Skip to content

Commit

Permalink
add the /api/private/crate-owner-invitations endpoint
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pietroalbini authored and Turbo87 committed Jul 1, 2021
1 parent 32aba8b commit cdd06d6
Show file tree
Hide file tree
Showing 4 changed files with 577 additions and 66 deletions.
292 changes: 229 additions & 63 deletions src/controllers/crate_owner_invitation.rs
Original file line number Diff line number Diff line change
@@ -1,76 +1,48 @@
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::{EncodableCrateOwnerInvitationV1, 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 /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);
EncodableCrateOwnerInvitationV1::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 {
Expand All @@ -83,6 +55,200 @@ 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,
Expand Down
6 changes: 6 additions & 0 deletions src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ pub fn build_router(app: &App) -> RouteBuilder {
// Metrics
router.get("/api/private/metrics/:kind", C(metrics::prometheus));

// Crate ownership invitations management in the frontend
router.get(
"/api/private/crate-owner-invitations",
C(crate_owner_invitation::private_list),
);

// Only serve the local checkout of the git index in development mode.
// In production, for crates.io, cargo gets the index from
// https://github.com/rust-lang/crates.io-index directly.
Expand Down
Loading

0 comments on commit cdd06d6

Please sign in to comment.