From ab2c4bb3d41213437e0dd5c50c46dc169e3bc204 Mon Sep 17 00:00:00 2001 From: Niklas Anderson Date: Sat, 4 Jan 2020 13:54:22 -0800 Subject: [PATCH 1/3] Add token for crate owner invitation email Adds a token to the crate_owner_invitations table to be used in token-based acceptance of invitation via email. --- .../down.sql | 4 ++++ .../up.sql | 13 +++++++++++++ src/models/crate_owner_invitation.rs | 4 +++- src/schema.rs | 12 ++++++++++++ src/tasks/dump_db/dump-db.toml | 2 ++ 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 migrations/2019-12-13-053745_add_crate_owner_invitation_token/down.sql create mode 100644 migrations/2019-12-13-053745_add_crate_owner_invitation_token/up.sql diff --git a/migrations/2019-12-13-053745_add_crate_owner_invitation_token/down.sql b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/down.sql new file mode 100644 index 00000000000..84d63bec0e1 --- /dev/null +++ b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/down.sql @@ -0,0 +1,4 @@ +DROP TRIGGER trigger_crate_owner_invitations_set_token_generated_at ON crate_owner_invitations; +DROP FUNCTION crate_owner_invitations_set_token_generated_at(); +ALTER TABLE crate_owner_invitations DROP COLUMN token_generated_at; +ALTER TABLE crate_owner_invitations DROP COLUMN token; diff --git a/migrations/2019-12-13-053745_add_crate_owner_invitation_token/up.sql b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/up.sql new file mode 100644 index 00000000000..9b8452d9635 --- /dev/null +++ b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE crate_owner_invitations ADD COLUMN token TEXT NOT NULL DEFAULT random_string(26); +ALTER TABLE crate_owner_invitations ADD COLUMN token_generated_at TIMESTAMP; + +CREATE FUNCTION crate_owner_invitations_set_token_generated_at() RETURNS trigger AS $$ + BEGIN + NEW.token_generated_at := CURRENT_TIMESTAMP; + RETURN NEW; + END +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_crate_owner_invitations_set_token_generated_at BEFORE +INSERT OR UPDATE OF token ON crate_owner_invitations +FOR EACH ROW EXECUTE PROCEDURE crate_owner_invitations_set_token_generated_at(); diff --git a/src/models/crate_owner_invitation.rs b/src/models/crate_owner_invitation.rs index 2ede8fbe18d..e94fac6cac6 100644 --- a/src/models/crate_owner_invitation.rs +++ b/src/models/crate_owner_invitation.rs @@ -5,13 +5,15 @@ use crate::schema::{crate_owner_invitations, crates, users}; use crate::views::EncodableCrateOwnerInvitation; /// The model representing a row in the `crate_owner_invitations` database table. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Identifiable, Queryable)] +#[derive(Clone, Debug, PartialEq, Eq, Identifiable, Queryable)] #[primary_key(invited_user_id, crate_id)] pub struct CrateOwnerInvitation { pub invited_user_id: i32, pub invited_by_user_id: i32, pub crate_id: i32, pub created_at: NaiveDateTime, + pub token: String, + pub token_created_at: Option, } #[derive(Insertable, Clone, Copy, Debug)] diff --git a/src/schema.rs b/src/schema.rs index c2be65ec8e3..b765de57fe5 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -208,6 +208,18 @@ table! { /// /// (Automatically generated by Diesel.) created_at -> Timestamp, + /// The `token` column of the `crate_owner_invitations` table. + /// + /// Its SQL type is `Text`. + /// + /// (Automatically generated by Diesel.) + token -> Text, + /// The `token_generated_at` column of the `crate_owner_invitations` table. + /// + /// Its SQL type is `Nullable`. + /// + /// (Automatically generated by Diesel.) + token_generated_at -> Nullable, } } diff --git a/src/tasks/dump_db/dump-db.toml b/src/tasks/dump_db/dump-db.toml index bf552c6ad57..7d514e631aa 100644 --- a/src/tasks/dump_db/dump-db.toml +++ b/src/tasks/dump_db/dump-db.toml @@ -56,6 +56,8 @@ invited_user_id = "private" invited_by_user_id = "private" crate_id = "private" created_at = "private" +token = "private" +token_generated_at = "private" [crate_owners] dependencies = ["crates", "users"] From c14a03e1401d159945622e799466e11cecab86a0 Mon Sep 17 00:00:00 2001 From: Niklas Anderson Date: Sat, 4 Jan 2020 14:44:10 -0800 Subject: [PATCH 2/3] Add token-based crate ownership invitation acceptance Adds an API endpoint, handler, and web route for token-based acceptance. Also updates crate ownership invitation email to contain a URL with a token for accepting an invitation. --- app/router.js | 1 + app/routes/accept-invite.js | 15 +++++++++++++ app/templates/accept-invite.hbs | 7 ++++++ src/controllers/crate_owner_invitation.rs | 27 ++++++++++++++++++++--- src/email.rs | 8 +++---- src/models/krate.rs | 3 ++- src/router.rs | 4 ++++ 7 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 app/routes/accept-invite.js create mode 100644 app/templates/accept-invite.hbs diff --git a/app/router.js b/app/router.js index 5a5f85cea47..7c98f97a3a4 100644 --- a/app/router.js +++ b/app/router.js @@ -48,6 +48,7 @@ Router.map(function() { this.route('policies'); this.route('data-access'); this.route('confirm', { path: '/confirm/:email_token' }); + this.route('accept-invite', { path: '/accept-invite/:token' }); this.route('catch-all', { path: '*path' }); }); diff --git a/app/routes/accept-invite.js b/app/routes/accept-invite.js new file mode 100644 index 00000000000..6834c16b966 --- /dev/null +++ b/app/routes/accept-invite.js @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; +import ajax from 'ember-fetch/ajax'; + +export default Route.extend({ + async model(params) { + try { + await ajax(`/api/v1//me/crate_owner_invitations/accept/${params.token}`, { method: 'PUT', body: '{}' }); + this.set('response', { accepted: true }); + return { response: this.get('response') }; + } catch (error) { + this.set('response', { accepted: false }); + return { response: this.get('response') }; + } + }, +}); diff --git a/app/templates/accept-invite.hbs b/app/templates/accept-invite.hbs new file mode 100644 index 00000000000..3c2ea989860 --- /dev/null +++ b/app/templates/accept-invite.hbs @@ -0,0 +1,7 @@ +{{#if this.model.response.accepted}} +

You've been added as a crate owner!

+

Visit your dashboard to view all of your crates, or account settings to manage email notification preferences for all of your crates.

+{{else}} +

Error in accepting crate ownership.

+

You may want to visit crates.io/me/pending-invites to try again.

+{{/if}} diff --git a/src/controllers/crate_owner_invitation.rs b/src/controllers/crate_owner_invitation.rs index 655946d2351..aa313efff5e 100644 --- a/src/controllers/crate_owner_invitation.rs +++ b/src/controllers/crate_owner_invitation.rs @@ -41,23 +41,44 @@ pub fn handle_invite(req: &mut dyn Request) -> AppResult { serde_json::from_str(&body).map_err(|_| bad_request("invalid json request"))?; let crate_invite = crate_invite.crate_owner_invite; + let user_id = req.user()?.id; if crate_invite.accepted { - accept_invite(req, conn, crate_invite) + accept_invite(req, conn, crate_invite, user_id) } else { decline_invite(req, conn, crate_invite) } } +/// Handles the `PUT /me/crate_owner_invitations/accept/:token` route. +pub fn handle_invite_with_token(req: &mut dyn Request) -> AppResult { + let conn = req.db_conn()?; + let req_token = &req.params()["token"]; + + let crate_owner_invite: CrateOwnerInvitation = crate_owner_invitations::table + .filter(crate_owner_invitations::token.eq(req_token)) + .first::(&*conn)?; + + let invite_reponse = InvitationResponse { + crate_id: crate_owner_invite.crate_id, + accepted: true, + }; + accept_invite( + req, + &conn, + invite_reponse, + crate_owner_invite.invited_user_id, + ) +} + fn accept_invite( req: &dyn Request, conn: &PgConnection, crate_invite: InvitationResponse, + user_id: i32, ) -> AppResult { use diesel::{delete, insert_into}; - let user_id = req.user()?.id; - conn.transaction(|| { let pending_crate_owner = crate_owner_invitations::table .find((user_id, crate_invite.crate_id)) diff --git a/src/email.rs b/src/email.rs index d4f6af7f605..1dd1211da6d 100644 --- a/src/email.rs +++ b/src/email.rs @@ -90,13 +90,13 @@ https://crates.io/confirm/{}", /// Whether or not the email is sent, the invitation entry will be created in /// the database and the user will see the invitation when they visit /// https://crates.io/me/pending-invites/. -pub fn send_owner_invite_email(email: &str, user_name: &str, crate_name: &str) { +pub fn send_owner_invite_email(email: &str, user_name: &str, crate_name: &str, token: &str) { let subject = "Crate ownership invitation"; let body = format!( "{} has invited you to become an owner of the crate {}!\n -Please visit https://crates.io/me/pending-invites to accept or reject -this invitation.", - user_name, crate_name +Visit https://crates.io/accept-invite/{} to accept this invitation, +or go to https://crates.io/me/pending-invites to manage all of your crate ownership invitations.", + user_name, crate_name, token ); let _ = send_email(email, subject, &body); diff --git a/src/models/krate.rs b/src/models/krate.rs index 46d035a4dae..5494ded40c3 100644 --- a/src/models/krate.rs +++ b/src/models/krate.rs @@ -440,12 +440,13 @@ impl Crate { .get_result::(conn) .optional()?; - if maybe_inserted.is_some() { + if let Some(ownership_invitation) = maybe_inserted { if let Ok(Some(email)) = user.verified_email(&conn) { email::send_owner_invite_email( &email.as_str(), &req_user.gh_login.as_str(), &self.name.as_str(), + &ownership_invitation.token.as_str(), ); } } diff --git a/src/router.rs b/src/router.rs index a4b01ef9e02..f4ec258dd90 100644 --- a/src/router.rs +++ b/src/router.rs @@ -89,6 +89,10 @@ pub fn build_router(app: &App) -> R404 { "/me/crate_owner_invitations/:crate_id", C(crate_owner_invitation::handle_invite), ); + api_router.put( + "/me/crate_owner_invitations/accept/:token", + C(crate_owner_invitation::handle_invite_with_token), + ); api_router.put( "/me/email_notifications", C(user::me::update_email_notifications), From b12465e783d9fc3b4d99e2f87ab924d5b60fabc4 Mon Sep 17 00:00:00 2001 From: "Carol (Nichols || Goulding)" Date: Wed, 15 Jan 2020 09:46:01 -0500 Subject: [PATCH 3/3] Remove extra slash in URL --- app/routes/accept-invite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/accept-invite.js b/app/routes/accept-invite.js index 6834c16b966..a8669bec927 100644 --- a/app/routes/accept-invite.js +++ b/app/routes/accept-invite.js @@ -4,7 +4,7 @@ import ajax from 'ember-fetch/ajax'; export default Route.extend({ async model(params) { try { - await ajax(`/api/v1//me/crate_owner_invitations/accept/${params.token}`, { method: 'PUT', body: '{}' }); + await ajax(`/api/v1/me/crate_owner_invitations/accept/${params.token}`, { method: 'PUT', body: '{}' }); this.set('response', { accepted: true }); return { response: this.get('response') }; } catch (error) {