diff --git a/.env.sample b/.env.sample index bb285f3d066..e638e6ecb8a 100644 --- a/.env.sample +++ b/.env.sample @@ -38,3 +38,14 @@ export GIT_REPO_CHECKOUT=./tmp/index-co # to the address `http://localhost:4200/authorize/github`. export GH_CLIENT_ID= export GH_CLIENT_SECRET= + +# Credentials for configuring Mailgun. You can leave these commented out +# if you are not interested in actually sending emails. If left empty, +# a mock email will be sent to a file in your local '/tmp/' directory. +# If interested in setting up Mailgun to send emails, you will have +# to create an account with Mailgun and modify these manually. +# If running a crates mirror on heroku, you can instead add the Mailgun +# app to your instance and shouldn't have to mess with these. +# export MAILGUN_SMTP_LOGIN= +# export MAILGUN_SMTP_PASSWORD= +# export MAILGUN_SMTP_SERVER= diff --git a/Cargo.lock b/Cargo.lock index e7ff161aecb..ad92fe315ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,14 @@ dependencies = [ "libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "base64" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "byteorder 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "base64" version = "0.6.0" @@ -92,6 +100,11 @@ name = "bitflags" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "bufstream" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "byteorder" version = "1.1.0" @@ -141,6 +154,7 @@ dependencies = [ "hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)", "hyper-tls 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "itertools 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", + "lettre 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", "license-exprs 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", "oauth2 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -175,6 +189,15 @@ name = "cfg-if" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "chrono" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num 0.1.39 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "chrono" version = "0.4.0" @@ -538,6 +561,77 @@ name = "either" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "email" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "base64 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)", + "chrono 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "encoding 0.2.33 (registry+https://github.com/rust-lang/crates.io-index)", + "lazy_static 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", + "rand 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc_version 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "encoding-index-japanese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)", + "encoding-index-korean 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)", + "encoding-index-simpchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)", + "encoding-index-singlebyte 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)", + "encoding-index-tradchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "entities" version = "1.0.0" @@ -737,6 +831,22 @@ name = "lazycell" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "lettre" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bufstream 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "email 0.0.17 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "mime 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)", + "openssl 0.9.14 (registry+https://github.com/rust-lang/crates.io-index)", + "rust-crypto 0.2.36 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc-serialize 0.3.24 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)", + "uuid 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "libc" version = "0.2.29" @@ -851,6 +961,14 @@ dependencies = [ "libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "mime" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "mime" version = "0.3.3" @@ -1170,6 +1288,18 @@ name = "route-recognizer" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "rust-crypto" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "gcc 0.3.51 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)", + "rand 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc-serialize 0.3.24 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "rustc-demangle" version = "0.1.4" @@ -1628,6 +1758,14 @@ name = "utf8-ranges" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "uuid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "vcpkg" version = "0.2.2" @@ -1673,13 +1811,16 @@ dependencies = [ "checksum antidote 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "34fde25430d87a9388dadbe6e34d7f72a462c8b43ac8d309b42b0a8505d7e2a5" "checksum backtrace 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "72f9b4182546f4b04ebc4ab7f84948953a118bd6021a1b6a6c909e3e94f6be76" "checksum backtrace-sys 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "3a0d842ea781ce92be2bf78a9b38883948542749640b8378b3b2f03d1fd9f1ff" +"checksum base64 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "30e93c03064e7590d0466209155251b90c22e37fab1daf2771582598b5827557" "checksum base64 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "96434f987501f0ed4eb336a411e0631ecd1afa11574fe148587adc4ff96143c9" "checksum bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d" "checksum bitflags 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5" +"checksum bufstream 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "f2f382711e76b9de6c744cc00d0497baba02fb00a787f088c879f01d09468e32" "checksum byteorder 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ff81738b726f5d099632ceaffe7fb65b90212e8dce59d518729e7e8634032d3d" "checksum bytes 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)" = "d828f97b58cc5de3e40c421d0cf2132d6b2da4ee0e11b8632fa838f0f9333ad6" "checksum cargo_metadata 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "be1057b8462184f634c3a208ee35b0f935cfd94b694b26deadccd98732088d7b" "checksum cfg-if 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d4c819a1287eb618df47cc647173c5c4c66ba19d888a6e50d605672aed3140de" +"checksum chrono 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "158b0bd7d75cbb6bf9c25967a48a2e9f77da95876b858eadfabaa99cd069de6e" "checksum chrono 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7c20ebe0b2b08b0aeddba49c609fe7957ba2e33449882cb186a180bc60682fa9" "checksum civet 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "6263e7af767a5bf9e4d3d0a6c3ceb5f3940ec85cf2fbfee59024b8a264be180f" "checksum civet-sys 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "958d15372bf28b7983cb35e1d4bf36dd843b0d42e507c1c73aad7150372c5936" @@ -1718,6 +1859,14 @@ dependencies = [ "checksum dotenv 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d6f0e2bb24d163428d8031d3ebd2d2bd903ad933205a97d0f18c7c1aade380f3" "checksum dtoa 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "80c8b71fd71146990a9742fc06dcbbde19161a267e0ad4e572c35162f4578c90" "checksum either 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "18785c1ba806c258137c937e44ada9ee7e69a37e3c72077542cd2f069d78562a" +"checksum email 0.0.17 (registry+https://github.com/rust-lang/crates.io-index)" = "88560dfb4e8eb3403e6402dfed790d0ef1c16f6d9f80028243a4f09826772f4f" +"checksum encoding 0.2.33 (registry+https://github.com/rust-lang/crates.io-index)" = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +"checksum encoding-index-japanese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +"checksum encoding-index-korean 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +"checksum encoding-index-simpchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +"checksum encoding-index-singlebyte 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +"checksum encoding-index-tradchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +"checksum encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" "checksum entities 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "34302e97cd148c302c39cf11f322bcf3412c06caddd2edf9222c22eac7fd63ef" "checksum env_logger 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "3ddf21e73e016298f5cb37d6ef8e8da8e39f91f9ec8b0df44b7deb16a9f8cd5b" "checksum error-chain 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d9435d864e017c3c6afeac1654189b06cdb491cf2ff73dbf0d73b0f292f42ff8" @@ -1742,6 +1891,7 @@ dependencies = [ "checksum language-tags 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a" "checksum lazy_static 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "3b37545ab726dd833ec6420aaba8231c5b320814b9029ad585555d2a03e94fbf" "checksum lazycell 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "3b585b7a6811fb03aa10e74b278a0f00f8dd9b45dc681f148bb29fa5cb61859b" +"checksum lettre 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "062777c2e39d4ccf5a1f30bb308d6464341e7587a5e140f79887d522ca906844" "checksum libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)" = "8a014d9226c2cc402676fbe9ea2e15dd5222cd1dd57f576b5b283178c944a264" "checksum libgit2-sys 0.6.12 (registry+https://github.com/rust-lang/crates.io-index)" = "df18a822100352d9863b302faf6f8f25c0e77f0e60feb40e5dbe1238b7f13b1d" "checksum libssh2-sys 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "0db4ec23611747ef772db1c4d650f8bd762f07b461727ec998f953c614024b75" @@ -1756,6 +1906,7 @@ dependencies = [ "checksum matches 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "100aabe6b8ff4e4a7e32c1c13523379802df0772b82466207ac25b013f193376" "checksum memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "d8b629fb514376c675b98c1421e80b151d3817ac42d7c667717d282761418d20" "checksum memchr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1dbccc0e46f1ea47b9f17e6d67c5a96bd27030519c519c9c91327e31275a47b4" +"checksum mime 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "ba626b8a6de5da682e1caa06bdb42a335aee5a84db8e5046a3e8ab17ba0a3ae0" "checksum mime 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "153f98dde2b135dece079e5478ee400ae1bab13afa52d66590eacfc40e912435" "checksum miniz-sys 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)" = "28eaee17666671fa872e567547e8428e83308ebe5808cdf6a0e28397dbe2c726" "checksum mio 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)" = "dbd91d3bfbceb13897065e97b2ef177a09a438cb33612b2d371bf568819a9313" @@ -1793,6 +1944,7 @@ dependencies = [ "checksum regex-syntax 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ad890a5eef7953f55427c50575c680c42841653abd2b028b68cd223d157f62db" "checksum ring 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1f2a6dc7fc06a05e6de183c5b97058582e9da2de0c136eafe49609769c507724" "checksum route-recognizer 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "cf3255338088df8146ba63d60a9b8e3556f1146ce2973bc05a75181a42ce2256" +"checksum rust-crypto 0.2.36 (registry+https://github.com/rust-lang/crates.io-index)" = "f76d05d3993fd5f4af9434e8e436db163a12a9d40e1a58a726f27a01dfd12a2a" "checksum rustc-demangle 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "3058a43ada2c2d0b92b3ae38007a2d0fa5e9db971be260e0171408a4ff471c95" "checksum rustc-serialize 0.3.24 (registry+https://github.com/rust-lang/crates.io-index)" = "dcf128d1287d2ea9d80910b5f1120d0b8eede3fbf1abe91c40d39ea7d51e6fda" "checksum rustc_version 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "c5f5376ea5e30ce23c03eb77cbe4962b988deead10910c372b226388b594c084" @@ -1850,6 +2002,7 @@ dependencies = [ "checksum utf-8 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "b6f923c601c7ac48ef1d66f7d5b5b2d9a7ba9c51333ab75a3ddf8d0309185a56" "checksum utf8-ranges 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a1ca13c08c41c9c3e04224ed9ff80461d97e121589ff27c753a16cb10830ae0f" "checksum utf8-ranges 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "662fab6525a98beff2921d7f61a39e7d59e0b425ebc7d0d9e66d316e55124122" +"checksum uuid 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7cfec50b0842181ba6e713151b72f4ec84a6a7e2c9c8a8a3ffc37bb1cd16b231" "checksum vcpkg 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "9e0a7d8bed3178a8fb112199d466eeca9ed09a14ba8ad67718179b4fd5487d0b" "checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" "checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" diff --git a/Cargo.toml b/Cargo.toml index 7172a7a2e9e..d4270b6fee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ comrak = { version = "0.1.9", default-features = false } ammonia = "0.7.0" docopt = "0.8.1" itertools = "0.6.0" +lettre = "0.6" conduit = "0.8" conduit-conditional-get = "0.8" diff --git a/app/components/email-input.js b/app/components/email-input.js index 88b81063f37..7b22c41b0e2 100644 --- a/app/components/email-input.js +++ b/app/components/email-input.js @@ -1,7 +1,12 @@ import Component from '@ember/component'; import { empty } from '@ember/object/computed'; +import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; export default Component.extend({ + ajax: service(), + flashMessages: service(), + type: '', value: '', isEditing: false, @@ -9,7 +14,18 @@ export default Component.extend({ disableSave: empty('user.email'), notValidEmail: false, prevEmail: '', - emailIsNull: true, + emailIsNull: computed('user.email', function() { + let email = this.get('user.email'); + return (email == null); + }), + emailNotVerified: computed('user.email', 'user.email_verified', function() { + let email = this.get('user.email'); + let verified = this.get('user.email_verified'); + + return (email != null && !verified); + }), + isError: false, + emailError: '', actions: { editEmail() { @@ -48,6 +64,8 @@ export default Component.extend({ msg = 'An unknown error occurred while saving this email.'; } this.set('serverError', msg); + this.set('isError', true); + this.set('emailError', `Error in saving email: ${msg}`); }); this.set('isEditing', false); @@ -57,6 +75,30 @@ export default Component.extend({ cancelEdit() { this.set('isEditing', false); this.set('value', this.get('prevEmail')); + }, + + resendEmail() { + let user = this.get('user'); + + this.get('ajax').raw(`/api/v1/users/${user.id}/resend`, { method: 'PUT', + user: { + avatar: user.avatar, + email: user.email, + email_verified: user.email_verified, + kind: user.kind, + login: user.login, + name: user.name, + url: user.url + } + }).catch((error) => { + if (error.payload) { + this.set('isError', true); + this.set('emailError', `Error in resending message: ${error.payload.errors[0].detail}`); + } else { + this.set('isError', true); + this.set('emailError', 'Unknown error in resending message'); + } + }); } } }); diff --git a/app/models/user.js b/app/models/user.js index a5348032596..5f03635b20f 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -2,6 +2,7 @@ import DS from 'ember-data'; export default DS.Model.extend({ email: DS.attr('string'), + email_verified: DS.attr('boolean'), name: DS.attr('string'), login: DS.attr('string'), avatar: DS.attr('string'), diff --git a/app/router.js b/app/router.js index 13a021ea994..a5c0f57cf9c 100644 --- a/app/router.js +++ b/app/router.js @@ -45,6 +45,7 @@ Router.map(function() { this.route('catchAll', { path: '*path' }); this.route('team', { path: '/teams/:team_id' }); this.route('policies'); + this.route('confirm', { path: '/confirm/:email_token' }); }); export default Router; diff --git a/app/routes/confirm.js b/app/routes/confirm.js new file mode 100644 index 00000000000..391e1ba484a --- /dev/null +++ b/app/routes/confirm.js @@ -0,0 +1,37 @@ +import Ember from 'ember'; +import { inject as service } from '@ember/service'; + +export default Ember.Route.extend({ + flashMessages: service(), + ajax: service(), + + model(params) { + return this.get('ajax').raw(`/api/v1/confirm/${params.email_token}`, { method: 'PUT', data: {} }) + .then(() => { + /* We need this block to reload the user model from the database, + without which if we haven't submitted another GET /me after + clicking the link and before checking their account info page, + the user will still see that their email has not yet been + validated and could potentially be confused, resend the email, + and set up a situation where their email has been verified but + they have an unverified token sitting in the DB. + + Suggestions of a more ideomatic way to fix/test this are welcome! + */ + if (this.session.get('isLoggedIn')) { + this.get('ajax').request('/api/v1/me').then((response) => { + this.session.set('currentUser', this.store.push(this.store.normalize('user', response.user))); + }); + } + }) + .catch((error) => { + if (error.payload) { + this.get('flashMessages').queue(`Error in email confirmation: ${error.payload.errors[0].detail}`); + return this.replaceWith('index'); + } else { + this.get('flashMessages').queue(`Unknown error in email confirmation`); + return this.replaceWith('index'); + } + }); + } +}); diff --git a/app/styles/me.scss b/app/styles/me.scss index 57e21e2a75c..b11ce4e81f9 100644 --- a/app/styles/me.scss +++ b/app/styles/me.scss @@ -65,6 +65,47 @@ } } +#me-email { + border-bottom: 5px solid $gray-border; + padding-bottom: 20px; + margin-bottom: 20px; + @include display-flex; + @include flex-direction(column); + .row { + width: 100%; + border: 1px solid #d5d3cb; + border-bottom-width: 0px; + &:last-child { border-bottom-width: 1px; } + padding: 10px 20px; + @include display-flex; + @include align-items(center); + .label { + @include flex(1); + margin-right: 0.4em; + font-weight: bold; + } + .email { + @include flex(20); + } + .actions { + @include display-flex; + @include align-items(center); + img { margin-left: 10px } + } + .email-form { + display: inline-flex; + } + .space-right { + margin-right: 10px; + } + } + .friendly-message { + width: 95%; + margin-left: auto; + margin-right: auto; + } +} + #me-api { @media only screen and (max-width: 350px) { .api { display: none; } diff --git a/app/templates/components/email-input.hbs b/app/templates/components/email-input.hbs index e3c4be93fef..e064dbbaf2d 100644 --- a/app/templates/components/email-input.hbs +++ b/app/templates/components/email-input.hbs @@ -1,17 +1,22 @@ +{{#if emailIsNull }} +
+

Please add your email address. We will only use + it to contact you about your account. We promise we'll never share it! +

+
+{{/if}} + {{#if isEditing }}
Email
-
- {{input type=type value=value placeholder='Email' class='form-control space-bottom'}} + + {{input type=type value=value placeholder='Email' class='form-control space-right'}} {{#if notValidEmail }} -

Invalid email format. Please try again.

- {{/if}} - {{#if emailIsNull }} -

Please add your email address. We will only use - it to contact you about your account. We promise we'll never share it! -

+
+

Whoops, that email format is invalid

+
{{/if}}
@@ -20,7 +25,7 @@
{{else}} -
+
Email
@@ -31,4 +36,21 @@
+ {{#if emailNotVerified }} +
+
+

Your email has not yet been verified.

+
+
+ +
+
+ {{/if}} + {{#if isError}} +
+
+

{{emailError}}

+
+
+ {{/if}} {{/if}} diff --git a/app/templates/confirm.hbs b/app/templates/confirm.hbs new file mode 100644 index 00000000000..2b38239f2df --- /dev/null +++ b/app/templates/confirm.hbs @@ -0,0 +1 @@ +

Thank you for confirming your email! :)

diff --git a/app/templates/error.hbs b/app/templates/error.hbs index 95e6ededdb7..32e95e8eeff 100644 --- a/app/templates/error.hbs +++ b/app/templates/error.hbs @@ -1,5 +1,5 @@

Something Went Wrong!

{{model.message}}
-  {{model.stack}}
+    {{model.stack}}
 
diff --git a/app/templates/me/index.hbs b/app/templates/me/index.hbs index 7da0ab915d5..ffe434749d4 100644 --- a/app/templates/me/index.hbs +++ b/app/templates/me/index.hbs @@ -16,11 +16,15 @@
{{ model.user.name }}
GitHub Account
{{ model.user.login }}
- {{email-input type='email' value=model.user.email user=model.user}}
+
+

User Email

+ {{email-input type='email' value=model.user.email user=model.user}} +
+

API Access

diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 174234b390c..2b91a10e93b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -338,6 +338,35 @@ yarn run start:local And then you should be able to visit http://localhost:4200! +##### Using Mailgun to Send Emails + +We currently have email functionality enabled for confirming a user's email +address. In development, the sending of emails is simulated by a file +representing the email being created in your local `/tmp/` directory. If +you want to test sending real emails, you will have to either set the +Mailgun environment variables in `.env` manually or run your app instance +on Heroku and add the Mailgun app. + +To set the environment variables manually, create an account and configure +Mailgun. [These quick start instructions] +(http://mailgun-documentation.readthedocs.io/en/latest/quickstart.html) +might be helpful. Once you get the environment variables for the app, you +will have to add them to the bottom of the `.env` file. You will need to +fill in the `MAILGUN_SMTP_LOGIN`, `MAILGUN_SMTP_PASSWORD`, and +`MAILGUN_SMTP_SERVER` fields. + +If using Heroku, you should be able to add the app to your instance on your +dashboard. When your code is pushed and run on Heroku, the environment +variables should be detected and you should not have to set anything +manually. + +In either case, you should be able to check in your Mailgun account to see +if emails are being detected and sent. Relevant information should be under +the 'logs' tab on your Mailgun dashboard. To access, if the variables were +set up manually, log in to your account. If the variables were set through +Heroku, you should be able to click on the Mailgun icon in your Heroku +dashboard, which should take you to your Mailgun dashboard. + #### Running the backend tests In your `.env` file, set `TEST_DATABASE_URL` to a value that's the same as diff --git a/migrations/20170804200817_add_email_table/down.sql b/migrations/20170804200817_add_email_table/down.sql new file mode 100644 index 00000000000..68415a79c95 --- /dev/null +++ b/migrations/20170804200817_add_email_table/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +DROP table tokens; +DROP table emails; diff --git a/migrations/20170804200817_add_email_table/up.sql b/migrations/20170804200817_add_email_table/up.sql new file mode 100644 index 00000000000..d1402e4f95b --- /dev/null +++ b/migrations/20170804200817_add_email_table/up.sql @@ -0,0 +1,17 @@ +-- Your SQL goes here +CREATE table emails ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE, + email VARCHAR NOT NULL, + verified BOOLEAN DEFAULT false NOT NULL +); + +CREATE table tokens ( + id SERIAL PRIMARY KEY, + email_id INTEGER NOT NULL UNIQUE REFERENCES emails, + token VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +INSERT INTO emails (user_id, email) + SELECT id, email FROM users WHERE email IS NOT NULL; diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 00000000000..38fbff23141 --- /dev/null +++ b/src/email.rs @@ -0,0 +1,86 @@ +use dotenv::dotenv; +use std::env; +use std::path::Path; +use util::{CargoResult, bad_request}; +use lettre::email::{EmailBuilder, Email}; +use lettre::transport::file::FileEmailTransport; +use lettre::transport::EmailTransport; +use lettre::transport::smtp::{SecurityLevel, SmtpTransportBuilder}; +use lettre::transport::smtp::SUBMISSION_PORT; +use lettre::transport::smtp::authentication::Mechanism; + +#[derive(Debug)] +pub struct MailgunConfigVars { + pub smtp_login: String, + pub smtp_password: String, + pub smtp_server: String, +} + +pub fn init_config_vars() -> Option { + dotenv().ok(); + + match ( + env::var("MAILGUN_SMTP_LOGIN"), + env::var("MAILGUN_SMTP_PASSWORD"), + env::var("MAILGUN_SMTP_SERVER"), + ) { + (Ok(login), Ok(password), Ok(server)) => { + Some(MailgunConfigVars { + smtp_login: login, + smtp_password: password, + smtp_server: server, + }) + } + _ => None, + } +} + +pub fn build_email( + recipient: &str, + subject: &str, + body: &str, + mailgun_config: &Option, +) -> CargoResult { + let sender = mailgun_config + .as_ref() + .map(|s| s.smtp_login.as_str()) + .unwrap_or("Development Mode"); + + let email = EmailBuilder::new() + .to(recipient) + .from(sender) + .subject(subject) + .body(body) + .build()?; + + Ok(email) +} + +pub fn send_email(recipient: &str, subject: &str, body: &str) -> CargoResult<()> { + let mailgun_config = init_config_vars(); + let email = build_email(recipient, subject, body, &mailgun_config)?; + + match mailgun_config { + Some(mailgun_config) => { + let mut transport = + SmtpTransportBuilder::new((mailgun_config.smtp_server.as_str(), SUBMISSION_PORT))? + .credentials(&mailgun_config.smtp_login, &mailgun_config.smtp_password) + .security_level(SecurityLevel::AlwaysEncrypt) + .smtp_utf8(true) + .authentication_mechanism(Mechanism::Plain) + .build(); + + let result = transport.send(email.clone()); + result.map_err(|_| bad_request("Error in sending email"))?; + } + None => { + let mut sender = FileEmailTransport::new(Path::new("/tmp")); + let result = sender.send(email.clone()); + result.map_err( + |_| bad_request("Email file could not be generated"), + )?; + } + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 6b705f97e10..442ed59d7ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,6 @@ //! All implemented routes are defined in the [middleware](fn.middleware.html) function and //! implemented in the [category](category/index.html), [keyword](keyword/index.html), //! [krate](krate/index.html), [user](user/index.html) and [version](version/index.html) modules. - #![deny(warnings)] #![deny(missing_debug_implementations, missing_copy_implementations)] #![cfg_attr(feature = "clippy", feature(plugin))] @@ -42,6 +41,7 @@ extern crate tar; extern crate time; extern crate toml; extern crate url; +extern crate lettre; extern crate conduit; extern crate conduit_conditional_get; @@ -96,6 +96,7 @@ pub mod uploaders; pub mod user; pub mod util; pub mod version; +pub mod email; mod local_upload; mod pagination; @@ -191,6 +192,8 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder { C(crate_owner_invitation::list), ); api_router.get("/summary", C(krate::summary)); + api_router.put("/confirm/:email_token", C(user::confirm_user_email)); + api_router.put("/users/:user_id/resend", C(user::regenerate_token_and_send)); let api_router = Arc::new(R404(api_router)); let mut router = RouteBuilder::new(); diff --git a/src/schema.rs b/src/schema.rs index 80ef28cc823..e0555444750 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -408,6 +408,38 @@ table! { } } +table! { + /// Representation of the `emails` table. + /// + /// (Automatically generated by Diesel.) + emails (id) { + /// The `id` column of the `emails` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + id -> Int4, + /// The `user_id` column of the `emails` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + user_id -> Int4, + /// The `email` column of the `emails` table. + /// + /// Its SQL type is `Varchar`. + /// + /// (Automatically generated by Diesel.) + email -> Varchar, + /// The `verified` column of the `emails` table. + /// + /// Its SQL type is `Bool`. + /// + /// (Automatically generated by Diesel.) + verified -> Bool, + } +} + table! { /// Representation of the `follows` table. /// @@ -546,6 +578,38 @@ table! { } } +table! { + /// Representation of the `tokens` table. + /// + /// (Automatically generated by Diesel.) + tokens (id) { + /// The `id` column of the `tokens` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + id -> Int4, + /// The `email_id` column of the `tokens` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + email_id -> Int4, + /// The `token` column of the `tokens` table. + /// + /// Its SQL type is `Varchar`. + /// + /// (Automatically generated by Diesel.) + token -> Varchar, + /// The `created_at` column of the `tokens` table. + /// + /// Its SQL type is `Timestamp`. + /// + /// (Automatically generated by Diesel.) + created_at -> Timestamp, + } +} + table! { /// Representation of the `users` table. /// @@ -753,3 +817,4 @@ joinable!(crate_owners -> teams (owner_id)); joinable!(crate_owners -> users (owner_id)); joinable!(readme_rendering -> versions (version_id)); joinable!(crate_owner_invitations -> crates (crate_id)); +joinable!(tokens -> emails (email_id)); diff --git a/src/tests/http-data/user_test_confirm_user_email b/src/tests/http-data/user_test_confirm_user_email new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/src/tests/http-data/user_test_confirm_user_email @@ -0,0 +1 @@ +[] diff --git a/src/tests/http-data/user_test_insert_into_email_table b/src/tests/http-data/user_test_insert_into_email_table new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/src/tests/http-data/user_test_insert_into_email_table @@ -0,0 +1 @@ +[] diff --git a/src/tests/http-data/user_test_insert_into_email_table_with_email_change b/src/tests/http-data/user_test_insert_into_email_table_with_email_change new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/src/tests/http-data/user_test_insert_into_email_table_with_email_change @@ -0,0 +1 @@ +[] diff --git a/src/tests/token.rs b/src/tests/token.rs index db253afe8c6..d164d243892 100644 --- a/src/tests/token.rs +++ b/src/tests/token.rs @@ -385,7 +385,7 @@ fn token_gives_access_to_me() { req.header("Authorization", &token.token); let mut response = ok_resp!(middle.call(&mut req)); - let json: ::user::UserShowResponse = ::json(&mut response); + let json: ::user::UserShowPrivateResponse = ::json(&mut response); assert_eq!(json.user.email, user.email); } diff --git a/src/tests/user.rs b/src/tests/user.rs index 456d0c16faa..0ccaa9c44ae 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -4,7 +4,7 @@ use conduit::{Handler, Method}; use cargo_registry::token::ApiToken; use cargo_registry::krate::EncodableCrate; -use cargo_registry::user::{User, NewUser, EncodablePrivateUser}; +use cargo_registry::user::{User, NewUser, EncodablePrivateUser, EncodablePublicUser, Email, Token}; use cargo_registry::version::EncodableVersion; use diesel::prelude::*; @@ -16,7 +16,12 @@ struct AuthResponse { } #[derive(Deserialize)] -pub struct UserShowResponse { +pub struct UserShowPublicResponse { + pub user: EncodablePublicUser, +} + +#[derive(Deserialize)] +pub struct UserShowPrivateResponse { pub user: EncodablePrivateUser, } @@ -48,7 +53,7 @@ fn me() { let user = ::sign_in(&mut req, &app); let mut response = ok_resp!(middle.call(&mut req)); - let json: UserShowResponse = ::json(&mut response); + let json: UserShowPrivateResponse = ::json(&mut response); assert_eq!(json.user.email, user.email); } @@ -65,15 +70,11 @@ fn show() { let mut req = ::req(app.clone(), Method::Get, "/api/v1/users/foo"); let mut response = ok_resp!(middle.call(&mut req)); - let json: UserShowResponse = ::json(&mut response); - // Emails should be None as when on the user/:user_id page, a user's email should - // not be accessible in order to keep private. - assert_eq!(None, json.user.email); + let json: UserShowPublicResponse = ::json(&mut response); assert_eq!("foo", json.user.login); let mut response = ok_resp!(middle.call(req.with_path("/api/v1/users/bar"))); - let json: UserShowResponse = ::json(&mut response); - assert_eq!(None, json.user.email); + let json: UserShowPublicResponse = ::json(&mut response); assert_eq!("bar", json.user.login); assert_eq!(Some("https://github.com/bar".into()), json.user.url); } @@ -510,3 +511,196 @@ fn test_this_user_cannot_change_that_user_email() { ); } + +/* Given a new user, test that if they sign in with + one email, change their email on GitHub, then + sign in again, that the email will remain + consistent with the original email used on + GitHub. +*/ +#[test] +fn test_insert_into_email_table() { + #[derive(Deserialize)] + struct R { + user: EncodablePrivateUser, + } + + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me"); + { + let conn = app.diesel_database.get().unwrap(); + let user = NewUser { + gh_id: 1, + email: Some("potato@example.com"), + ..::new_user("potato") + }; + + let user = user.create_or_update(&conn).unwrap(); + ::sign_in_as(&mut req, &user); + } + + let mut response = ok_resp!(middle.call( + req.with_path("/api/v1/me").with_method(Method::Get), + )); + let r = ::json::(&mut response); + assert_eq!(r.user.email.unwrap(), "potato@example.com"); + assert_eq!(r.user.login, "potato"); + + ::logout(&mut req); + + // What if user changes their github user email + { + let conn = app.diesel_database.get().unwrap(); + let user = NewUser { + gh_id: 1, + email: Some("banana@example.com"), + ..::new_user("potato") + }; + + let user = user.create_or_update(&conn).unwrap(); + ::sign_in_as(&mut req, &user); + } + + let mut response = ok_resp!(middle.call( + req.with_path("/api/v1/me").with_method(Method::Get), + )); + let r = ::json::(&mut response); + assert_eq!(r.user.email.unwrap(), "potato@example.com"); + assert_eq!(r.user.login, "potato"); +} + +/* Given a new user, check that when an email is added, + changed by user on GitHub, changed on crates.io, + that the email remains consistent with that which + the user has changed +*/ +#[test] +fn test_insert_into_email_table_with_email_change() { + #[derive(Deserialize)] + struct R { + user: EncodablePrivateUser, + } + + #[derive(Deserialize)] + struct S { + ok: bool, + } + + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me"); + let user = { + let conn = app.diesel_database.get().unwrap(); + let user = NewUser { + gh_id: 1, + email: Some("potato@example.com"), + ..::new_user("potato") + }; + + let user = user.create_or_update(&conn).unwrap(); + ::sign_in_as(&mut req, &user); + user + }; + + let mut response = ok_resp!(middle.call( + req.with_path("/api/v1/me").with_method(Method::Get), + )); + let r = ::json::(&mut response); + assert_eq!(r.user.email.unwrap(), "potato@example.com"); + assert_eq!(r.user.login, "potato"); + + let body = r#"{"user":{"email":"apricot@apricots.apricot","name":"potato","login":"potato","avatar":"https://avatars0.githubusercontent.com","url":"https://github.com/potato","kind":null}}"#; + let mut response = ok_resp!( + middle.call( + req.with_path(&format!("/api/v1/users/{}", user.id)) + .with_method(Method::Put) + .with_body(body.as_bytes()), + ) + ); + assert!(::json::(&mut response).ok); + + ::logout(&mut req); + + // What if user changes their github user email + { + let conn = app.diesel_database.get().unwrap(); + let user = NewUser { + gh_id: 1, + email: Some("banana@example.com"), + ..::new_user("potato") + }; + + let user = user.create_or_update(&conn).unwrap(); + ::sign_in_as(&mut req, &user); + } + + let mut response = ok_resp!(middle.call( + req.with_path("/api/v1/me").with_method(Method::Get), + )); + let r = ::json::(&mut response); + assert_eq!(r.user.email.unwrap(), "apricot@apricots.apricot"); + assert_eq!(r.user.login, "potato"); +} + +/* Given a new user, test that their email can be added + to the email table and a token for the email is generated + and added to the token table. When /confirm/:email_token is + requested, check that the response back is ok, and that + the email_verified field on user is now set to true. +*/ +#[test] +fn test_confirm_user_email() { + use cargo_registry::schema::{emails, tokens}; + + #[derive(Deserialize)] + struct R { + user: EncodablePrivateUser, + } + + #[derive(Deserialize)] + struct S { + ok: bool, + } + + let (_b, app, middle) = ::app(); + let mut req = ::req(app.clone(), Method::Get, "/me"); + let user = { + let conn = app.diesel_database.get().unwrap(); + let user = NewUser { + email: Some("potato@example.com"), + ..::new_user("potato") + }; + + let user = user.create_or_update(&conn).unwrap(); + ::sign_in_as(&mut req, &user); + user + }; + + let email_token = { + let conn = app.diesel_database.get().unwrap(); + let email_info = emails::table + .filter(emails::user_id.eq(user.id)) + .first::(&*conn) + .unwrap(); + let token_info = tokens::table + .filter(tokens::email_id.eq(email_info.id)) + .first::(&*conn) + .unwrap(); + token_info.token + }; + + let mut response = ok_resp!( + middle.call( + req.with_path(&format!("/api/v1/confirm/{}", email_token)) + .with_method(Method::Put), + ) + ); + assert!(::json::(&mut response).ok); + + let mut response = ok_resp!(middle.call( + req.with_path("/api/v1/me").with_method(Method::Get), + )); + let r = ::json::(&mut response); + assert_eq!(r.user.email.unwrap(), "potato@example.com"); + assert_eq!(r.user.login, "potato"); + assert!(r.user.email_verified); +} diff --git a/src/user/mod.rs b/src/user/mod.rs index 5ba429bb1e2..307ac007b21 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -5,24 +5,26 @@ use diesel::prelude::*; use rand::{thread_rng, Rng}; use std::borrow::Cow; use serde_json; +use time::Timespec; use app::RequestApp; use db::RequestTransaction; use krate::Follow; use pagination::Paginate; use schema::*; -use util::{RequestUtils, CargoResult, human}; +use util::{RequestUtils, CargoResult, human, bad_request}; use version::EncodableVersion; use {http, Version}; use owner::{Owner, OwnerKind, CrateOwner}; use krate::Crate; +use email; pub use self::middleware::{Middleware, RequestUser, AuthenticationSource}; pub mod middleware; /// The model representing a row in the `users` database table. -#[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset)] +#[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset, Associations)] pub struct User { pub id: i32, pub email: Option, @@ -44,6 +46,40 @@ pub struct NewUser<'a> { pub gh_access_token: Cow<'a, str>, } +#[derive(Debug, Queryable, AsChangeset, Identifiable, Associations)] +#[belongs_to(User)] +pub struct Email { + pub id: i32, + pub user_id: i32, + pub email: String, + pub verified: bool, +} + +#[derive(Debug, Insertable, AsChangeset)] +#[table_name = "emails"] +pub struct NewEmail { + pub user_id: i32, + pub email: String, + pub verified: bool, +} + +#[derive(Debug, Queryable, AsChangeset, Identifiable, Associations)] +#[belongs_to(Email)] +pub struct Token { + pub id: i32, + pub email_id: i32, + pub token: String, + pub created_at: Timespec, +} + +#[derive(Debug, Insertable, AsChangeset)] +#[table_name = "tokens"] +pub struct NewToken { + pub email_id: i32, + pub token: String, + pub created_at: Timespec, +} + impl<'a> NewUser<'a> { pub fn new( gh_id: i32, @@ -69,6 +105,8 @@ impl<'a> NewUser<'a> { use diesel::expression::dsl::sql; use diesel::types::Integer; use diesel::pg::upsert::*; + use time; + use diesel::result::Error; let update_user = NewUser { email: None, @@ -88,12 +126,64 @@ impl<'a> NewUser<'a> { // necessary for most fields in the database to be used as a conflict // target :) let conflict_target = sql::("(gh_id) WHERE gh_id > 0"); - insert(&self.on_conflict( + let result = insert(&self.on_conflict( conflict_target, do_update().set(&update_user), )).into(users::table) .get_result(conn) - .map_err(Into::into) + .map_err(Into::into); + + // To send the user an account verification email... + if let Some(user_email) = self.email { + let user_id = users::table + .select(users::id) + .filter(users::gh_id.eq(&self.gh_id)) + .first(&*conn) + .unwrap(); + + let new_email = NewEmail { + user_id: user_id, + email: String::from(user_email), + verified: false, + }; + + let email_result: QueryResult = insert(&new_email.on_conflict_do_nothing()) + .into(emails::table) + .get_result(conn) + .map_err(Into::into); + + match email_result { + Ok(email) => { + let token = generate_token(); + let new_token = NewToken { + email_id: email.id, + token: token.clone(), + created_at: time::now_utc().to_timespec(), + }; + + insert(&new_token).into(tokens::table).execute(conn)?; + + if send_user_confirm_email(user_email, self.gh_login, &token).is_err() { + return Err(Error::NotFound); + }; + } + Err(Error::NotFound) => { + // This block is reached if a user already has an email stored + // in the database. If an email already exists then we will + // not overwrite it with the one received from GitHub. Doing + // so would force us to resend a verification email each time + // the user logs in, which doesn't make any sense. + // Thus, we don't consider this case to actually be an error, + // so we don't do anything with it, whereas the block below + // will catch all other relevant errors. + } + Err(err) => { + return Err(err); + } + } + } + + result } } @@ -116,6 +206,7 @@ pub struct EncodablePrivateUser { pub id: i32, pub login: String, pub email: Option, + pub email_verified: bool, pub name: Option, pub avatar: Option, pub url: Option, @@ -149,7 +240,7 @@ impl User { } /// Converts this `User` model into an `EncodablePrivateUser` for JSON serialization. - pub fn encodable_private(self) -> EncodablePrivateUser { + pub fn encodable_private(self, email_verified: bool) -> EncodablePrivateUser { let User { id, email, @@ -162,6 +253,7 @@ impl User { EncodablePrivateUser { id: id, email: email, + email_verified, avatar: gh_avatar, login: gh_login, name: name, @@ -320,16 +412,31 @@ pub fn me(req: &mut Request) -> CargoResult { // This change is not preferable, we'd rather fix the request, // perhaps adding `req.mut_extensions().insert(user)` to the // update_user route, however this somehow does not seem to work + use self::users::dsl::{users, id}; - let user_id = req.user()?.id; + use self::emails::dsl::{emails, user_id}; + + let u_id = req.user()?.id; let conn = req.db_conn()?; - let user = users.filter(id.eq(user_id)).first::(&*conn)?; + + let user_info = users.filter(id.eq(u_id)).first::(&*conn)?; + let email_result = emails.filter(user_id.eq(u_id)).first::(&*conn); + + let (email, verified): (Option, bool) = match email_result { + Ok(response) => (Some(response.email), response.verified), + Err(_) => (None, false), + }; + + let user = User { + email: email, + ..user_info + }; #[derive(Serialize)] struct R { user: EncodablePrivateUser, } - Ok(req.json(&R { user: user.encodable_private() })) + Ok(req.json(&R { user: user.encodable_private(verified) })) } /// Handles the `GET /users/:user_id` route. @@ -432,6 +539,11 @@ pub fn stats(req: &mut Request) -> CargoResult { pub fn update_user(req: &mut Request) -> CargoResult { use diesel::update; use self::users::dsl::{users, gh_login, email}; + use self::emails::dsl::user_id; + use self::tokens::dsl::email_id; + use diesel::insert; + use diesel::pg::upsert::*; + use time; let mut body = String::new(); req.body().read_to_string(&mut body)?; @@ -473,9 +585,156 @@ pub fn update_user(req: &mut Request) -> CargoResult { .set(email.eq(user_email)) .execute(&*conn)?; + let new_email = NewEmail { + user_id: user.id, + email: String::from(user_email), + verified: false, + }; + + let email_result: QueryResult = + insert(&new_email.on_conflict(user_id, do_update().set(&new_email))) + .into(emails::table) + .get_result(&*conn) + .map_err(Into::into); + + let token = generate_token(); + + match email_result { + Ok(email_response) => { + let new_token = NewToken { + email_id: email_response.id, + token: token.clone(), + created_at: time::now_utc().to_timespec(), + }; + + insert(&new_token.on_conflict( + email_id, + do_update().set(&new_token), + )).into(tokens::table) + .execute(&*conn)?; + } + Err(_) => { + return Err(human("Error in creating token")); + } + } + + let email_result = send_user_confirm_email(user_email, &user.gh_login, &token); + email_result.map_err( + |_| bad_request("Email could not be sent"), + )?; + #[derive(Serialize)] struct R { ok: bool, } Ok(req.json(&R { ok: true })) } + +fn send_user_confirm_email(email: &str, user_name: &str, token: &str) -> CargoResult<()> { + // Create a URL with token string as path to send to user + // If user clicks on path, look email/user up in database, + // make sure tokens match + + let subject = "Please confirm your email address"; + let body = format!( + "Hello {}! Welcome to Crates.io. Please click the +link below to verify your email address. Thank you!\n +https://crates.io/confirm/{}", + user_name, + token + ); + + email::send_email(email, subject, &body) +} + +/// Handles the `PUT /confirm/:email_token` route +pub fn confirm_user_email(req: &mut Request) -> CargoResult { + // to confirm, we must grab the token on the request as part of the URL + // look up the token in the tokens table + // find what user the token belongs to + // on the email table, change 'verified' to true + // delete the token from the tokens table + use diesel::{update, delete}; + + let conn = req.db_conn()?; + let req_token = &req.params()["email_token"]; + + let token_info = tokens::table + .filter(tokens::token.eq(req_token)) + .first::(&*conn) + .map_err(|_| bad_request("Email token not found."))?; + + let email_info = emails::table + .filter(emails::id.eq(token_info.email_id)) + .first::(&*conn) + .map_err(|_| bad_request("Email belonging to token not found."))?; + + update(emails::table.filter(emails::id.eq(email_info.id))) + .set(emails::verified.eq(true)) + .execute(&*conn) + .map_err(|_| bad_request("Email verification could not be updated"))?; + + delete(tokens::table.filter(tokens::id.eq(token_info.id))) + .execute(&*conn) + .map_err(|_| bad_request("Email token could not be deleted"))?; + + #[derive(Serialize)] + struct R { + ok: bool, + } + Ok(req.json(&R { ok: true })) +} + +/// Handles `PUT /user/:user_id/resend` route +pub fn regenerate_token_and_send(req: &mut Request) -> CargoResult { + use diesel::pg::upsert::*; + use diesel::insert; + use time; + use self::tokens::dsl::email_id; + + let mut body = String::new(); + req.body().read_to_string(&mut body)?; + let user = req.user()?; + let name = &req.params()["user_id"].parse::().ok().unwrap(); + let conn = req.db_conn()?; + + // need to check if current user matches user to be updated + if &user.id != name { + return Err(human("current user does not match requested user")); + } + + let email_info = emails::table + .filter(emails::user_id.eq(user.id)) + .first::(&*conn) + .map_err(|_| bad_request("Email could not be found"))?; + + let token = generate_token(); + + let new_token = NewToken { + email_id: email_info.id, + token: token.clone(), + created_at: time::now_utc().to_timespec(), + }; + + insert(&new_token.on_conflict( + email_id, + do_update().set(&new_token), + )).into(tokens::table) + .execute(&*conn)?; + + let email_result = send_user_confirm_email(&email_info.email, &user.gh_login, &token); + email_result.map_err( + |_| bad_request("Error in sending email"), + )?; + + #[derive(Serialize)] + struct R { + ok: bool, + } + Ok(req.json(&R { ok: true })) +} + +fn generate_token() -> String { + let token: String = thread_rng().gen_ascii_chars().take(26).collect(); + token +}