diff --git a/common/models/User-Credential.js b/common/models/User-Credential.js new file mode 100644 index 0000000000..a43fc9a38a --- /dev/null +++ b/common/models/User-Credential.js @@ -0,0 +1,88 @@ +import { Observable } from 'rx'; +import debug from 'debug'; + +import { observeMethod, observeQuery } from '../../server/utils/rx'; +import { + createUserUpdatesFromProfile, + getSocialProvider +} from '../../server/utils/auth'; + +const log = debug('fcc:models:UserCredential'); +module.exports = function(UserCredential) { + UserCredential.link = function( + userId, + _provider, + authScheme, + profile, + credentials, + options = {}, + cb + ) { + if (typeof options === 'function' && !cb) { + cb = options; + options = {}; + } + const User = UserCredential.app.models.User; + const findCred = observeMethod(UserCredential, 'findOne'); + const createCred = observeMethod(UserCredential, 'create'); + + const provider = getSocialProvider(_provider); + const query = { + where: { + provider: provider, + externalId: profile.id + } + }; + + // find createCred if they exist + // if not create it + // if yes, update credentials + // also if github + // update profile + // update username + // update picture + log('link query', query); + return findCred(query) + .flatMap(_credentials => { + const modified = new Date(); + const updateUser = User.update$( + { id: userId }, + createUserUpdatesFromProfile(provider, profile) + ); + let updateCredentials; + if (!_credentials) { + updateCredentials = createCred({ + provider, + externalId: profile.id, + authScheme, + // we no longer want to keep the profile + // this is information we do not need or use + profile: null, + credentials, + userId, + created: modified, + modified + }); + } + _credentials.credentials = credentials; + updateCredentials = observeQuery( + _credentials, + 'updateAttributes', + { + profile: null, + credentials, + modified + } + ); + return Observable.combineLatest( + updateUser, + updateCredentials, + (_, credentials) => credentials + ); + }) + .subscribe( + credentials => cb(null, credentials), + cb + ); + }; +}; diff --git a/common/models/User-Identity.js b/common/models/User-Identity.js index 962fe0dc16..3fb7c30a40 100644 --- a/common/models/User-Identity.js +++ b/common/models/User-Identity.js @@ -1,172 +1,131 @@ -import loopback from 'loopback'; -import debugFactory from 'debug'; +import { Observable } from 'rx'; +// import debug from 'debug'; +import dedent from 'dedent'; import { - setProfileFromGithub, - getFirstImageFromProfile, + getSocialProvider, getUsernameFromProvider, - getSocialProvider + createUserUpdatesFromProfile } from '../../server/utils/auth'; -import { defaultProfileImage } from '../utils/constantStrings.json'; +import { observeMethod, observeQuery } from '../../server/utils/rx'; +import { wrapHandledError } from '../../server/utils/create-handled-error.js'; -const githubRegex = (/github/i); -const debug = debugFactory('fcc:models:userIdent'); +// const log = debug('fcc:models:userIdent'); export default function(UserIdent) { + UserIdent.on('dataSourceAttached', () => { + UserIdent.findOne$ = observeMethod(UserIdent, 'findOne'); + }); // original source // github.com/strongloop/loopback-component-passport - const createAccountMessage = - 'Accounts can only be created using GitHub or though email'; + // find identity if it exist + // if not redirect to email signup + // if yes and github + // update profile + // update username + // update picture UserIdent.login = function( - provider, + _provider, authScheme, profile, credentials, options, cb ) { + const User = UserIdent.app.models.User; + const AccessToken = UserIdent.app.models.AccessToken; + const provider = getSocialProvider(_provider); options = options || {}; if (typeof options === 'function' && !cb) { cb = options; options = {}; } - const userIdentityModel = UserIdent; profile.id = profile.id || profile.openid; - const filter = { + const query = { where: { - provider: getSocialProvider(provider), + provider: provider, externalId: profile.id - } + }, + include: 'user' }; - return userIdentityModel.findOne(filter) - .then(identity => { + return UserIdent.findOne$(query) + .flatMap(identity => { + if (!identity) { + throw wrapHandledError( + new Error('user identity account not found'), + { + message: dedent` + New accounts can only be created using an email address. + Please create an account below + `, + type: 'info', + redirectTo: '/signup' + } + ); + } + const modified = new Date(); + const user = identity.user(); + if (!user) { + const username = getUsernameFromProvider(provider, profile); + return observeQuery( + identity, + 'updateAttributes', + { + isOrphaned: username || true + } + ) + .do(() => { + throw wrapHandledError( + new Error('user identity is not associated with a user'), + { + type: 'info', + redirectTo: '/signup', + message: dedent` + The user account associated with the ${provider} user ${username || 'Anon'} + no longer exists. + ` + } + ); + }); + } + const updateUser = User.update$( + { id: user.id }, + createUserUpdatesFromProfile(provider, profile) + ).map(() => user); // identity already exists // find user and log them in - if (identity) { - identity.credentials = credentials; - const options = { - profile: profile, - credentials: credentials, - modified: new Date() - }; - return identity.updateAttributes(options) - // grab user associated with identity - .then(() => identity.user()) - .then(user => { - // Create access token for user - const options = { - created: new Date(), - ttl: user.constructor.settings.ttl - }; - return user.accessTokens.create(options) - .then(token => ({ user, token })); - }) - .then(({ token, user })=> { - cb(null, user, identity, token); - }) - .catch(err => cb(err)); - } - // Find the user model - const userModel = userIdentityModel.relations.user && - userIdentityModel.relations.user.modelTo || - loopback.getModelByType(loopback.User); - - const userObj = options.profileToUser(provider, profile, options); - if (getSocialProvider(provider) !== 'github') { - const err = new Error(createAccountMessage); - err.userMessage = createAccountMessage; - err.messageType = 'info'; - err.redirectTo = '/signin'; - return process.nextTick(() => cb(err)); - } - - let query; - if (userObj.email) { - query = { or: [ - { username: userObj.username }, - { email: userObj.email } - ]}; - } else { - query = { username: userObj.username }; - } - return userModel.findOrCreate({ where: query }, userObj) - .then(([ user ]) => { - const promises = [ - userIdentityModel.create({ - provider: getSocialProvider(provider), - externalId: profile.id, - authScheme: authScheme, - profile: profile, - credentials: credentials, - userId: user.id, - created: new Date(), - modified: new Date() - }), - user.accessTokens.create({ - created: new Date(), - ttl: user.constructor.settings.ttl - }) - ]; - return Promise.all(promises) - .then(([ identity, token ]) => ({ user, identity, token })); - }) - .then(({ user, token, identity }) => cb(null, user, identity, token)) - .catch(err => cb(err)); - }); + identity.credentials = credentials; + const attributes = { + // we no longer want to keep the profile + // this is information we do not need or use + profile: null, + credentials: credentials, + modified + }; + const updateIdentity = observeQuery( + identity, + 'updateAttributes', + attributes + ); + const createToken = observeQuery( + AccessToken, + 'create', + { + userId: user.id, + created: new Date(), + ttl: user.constructor.settings.ttl + } + ); + return Observable.combineLatest( + updateUser, + updateIdentity, + createToken, + (user, identity, token) => ({ user, identity, token }) + ); + }) + .subscribe( + ({ user, identity, token }) => cb(null, user, identity, token), + cb + ); }; - - UserIdent.observe('before save', function(ctx, next) { - const userIdent = ctx.currentInstance || ctx.instance; - if (!userIdent) { - debug('no user identity instance found'); - return next(); - } - return userIdent.user(function(err, user) { - let userChanged = false; - if (err) { return next(err); } - if (!user) { - debug('no user attached to identity!'); - return next(); - } - - const { profile, provider } = userIdent; - const picture = getFirstImageFromProfile(profile); - - debug('picture', picture, user.picture); - // check if picture was found - // check if user has no picture - // check if user has default picture - // set user.picture from oauth provider - if ( - picture && - (!user.picture || user.picture === defaultProfileImage) - ) { - debug('setting user picture'); - user.picture = picture; - userChanged = true; - } - - if (!githubRegex.test(provider) && profile) { - user[provider] = getUsernameFromProvider(provider, profile); - userChanged = true; - } - - // if user signed in with github refresh their info - if (githubRegex.test(provider) && profile && profile._json) { - debug("user isn't github cool or username from github is different"); - setProfileFromGithub(user, profile, profile._json); - userChanged = true; - } - - - if (userChanged) { - return user.save(function(err) { - if (err) { return next(err); } - return next(); - }); - } - debug('exiting after user identity before save'); - return next(); - }); - }); } diff --git a/common/models/user.js b/common/models/user.js index 7ada5854fd..4586bd4207 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -6,14 +6,26 @@ import debugFactory from 'debug'; import { isEmail } from 'validator'; import path from 'path'; -import { saveUser, observeMethod } from '../../server/utils/rx'; -import { blacklistedUsernames } from '../../server/utils/constants'; +import { saveUser, observeMethod } from '../../server/utils/rx.js'; +import { blacklistedUsernames } from '../../server/utils/constants.js'; +import { wrapHandledError } from '../../server/utils/create-handled-error.js'; const debug = debugFactory('fcc:user:remote'); const BROWNIEPOINTS_TIMEOUT = [1, 'hour']; const isDev = process.env.NODE_ENV !== 'production'; const devHost = process.env.HOST || 'localhost'; +const createEmailError = () => new Error( + 'Please check to make sure the email is a valid email address.' +); + +function destroyAll(id, Model) { + return Observable.fromNodeCallback( + Model.destroyAll, + Model + )({ userId: id }); +} + function getAboutProfile({ username, githubProfile: github, @@ -64,10 +76,99 @@ module.exports = function(User) { User.count$ = Observable.fromNodeCallback(User.count, User); }); + User.beforeRemote('create', function({ req }) { + const body = req.body; + // note(berks): we now require all new users to supply an email + // this was not always the case + if ( + typeof body.email !== 'string' || + !isEmail(body.email) + ) { + return Promise.reject(createEmailError()); + } + // assign random username to new users + // actual usernames will come from github + body.username = 'fcc' + uuid.v4(); + if (body) { + // this is workaround for preventing a server crash + // we do this on create and on save + // refer strongloop/loopback/#1364 + if (body.password === '') { + body.password = null; + } + // set email verified false on user email signup + // should not be set with oauth signin methods + body.emailVerified = false; + } + return User.doesExist(null, body.email) + .catch(err => { + throw wrapHandledError(err, { redirectTo: '/email-signup' }); + }) + .then(exists => { + if (!exists) { + return null; + } + const err = wrapHandledError( + new Error('user already exists'), + { + redirectTo: '/email-signin', + message: dedent` + The ${body.email} email address is already associated with an account. + Try signing in with it here instead. + ` + } + ); + throw err; + }); + }); + + // send welcome email to new camper + User.afterRemote('create', function({ req, res }, user, next) { + debug('user created, sending email'); + if (!user.email || !isEmail(user.email)) { return next(); } + const redirect = req.session && req.session.returnTo ? + req.session.returnTo : + '/'; + + var mailOptions = { + type: 'email', + to: user.email, + from: 'team@freecodecamp.com', + subject: 'Welcome to freeCodeCamp!', + protocol: isDev ? null : 'https', + host: isDev ? devHost : 'freecodecamp.com', + port: isDev ? null : 443, + template: path.join( + __dirname, + '..', + '..', + 'server', + 'views', + 'emails', + 'a-extend-user-welcome.ejs' + ), + redirect: '/email-signin' + }; + + debug('sending welcome email'); + return user.verify(mailOptions, function(err) { + if (err) { return next(err); } + req.flash('success', { + msg: [ 'Congratulations ! We\'ve created your account. ', + 'Please check your email. We sent you a link that you can ', + 'click to verify your email address and then login.' + ].join('') + }); + return res.redirect(redirect); + }); + }); + User.observe('before save', function({ instance: user }, next) { if (user) { + // Some old accounts will not have emails associated with theme + // we verify only if the email field is populated if (user.email && !isEmail(user.email)) { - return next(new Error('Email format is not valid')); + return next(createEmailError()); } user.username = user.username.trim().toLowerCase(); user.email = typeof user.email === 'string' ? @@ -82,6 +183,7 @@ module.exports = function(User) { user.progressTimestamps.push({ timestamp: Date.now() }); } // this is workaround for preventing a server crash + // we do this on save and on create // refer strongloop/loopback/#1364 if (user.password === '') { user.password = null; @@ -90,6 +192,40 @@ module.exports = function(User) { return next(); }); + // remove lingering user identities before deleting user + User.observe('before delete', function(ctx, next) { + const UserIdentity = User.app.models.UserIdentity; + const UserCredential = User.app.models.UserCredential; + debug('removing user', ctx.where); + var id = ctx.where && ctx.where.id ? ctx.where.id : null; + if (!id) { + return next(); + } + return Observable.combineLatest( + destroyAll(id, UserIdentity), + destroyAll(id, UserCredential), + function(identData, credData) { + return { + identData: identData, + credData: credData + }; + } + ) + .subscribe( + function(data) { + debug('deleted', data); + }, + function(err) { + debug('error deleting user %s stuff', id, err); + next(err); + }, + function() { + debug('user stuff deleted for user %s', id); + next(); + } + ); + }); + debug('setting up user hooks'); User.beforeRemote('confirm', function(ctx, _, next) { @@ -153,41 +289,9 @@ module.exports = function(User) { return ctx.res.redirect(redirect); }); - User.beforeRemote('create', function({ req, res }, _, next) { - req.body.username = 'fcc' + uuid.v4().slice(0, 8); - if (!req.body.email) { - return next(); - } - if (!isEmail(req.body.email)) { - return next(new Error('Email format is not valid')); - } - return User.doesExist(null, req.body.email) - .then(exists => { - if (!exists) { - return next(); - } - - req.flash('error', { - msg: dedent` - The ${req.body.email} email address is already associated with an account. - Try signing in with it here instead. - ` - }); - - return res.redirect('/email-signin'); - }) - .catch(err => { - console.error(err); - req.flash('error', { - msg: 'Oops, something went wrong, please try again later' - }); - return res.redirect('/email-signup'); - }); - }); - User.on('resetPasswordRequest', function(info) { if (!isEmail(info.email)) { - console.error(new Error('Email format is not valid')); + console.error(createEmailError()); return null; } let url; @@ -232,7 +336,7 @@ module.exports = function(User) { const { body } = ctx.req; if (body && typeof body.email === 'string') { if (!isEmail(body.email)) { - return next(new Error('Email format is not valid')); + return next(createEmailError()); } body.email = body.email.toLowerCase(); } @@ -392,9 +496,7 @@ module.exports = function(User) { true; if (!isEmail('' + email)) { - return Observable.throw( - new Error('The submitted email not valid.') - ); + return Observable.throw(createEmailError()); } // email is already associated and verified with this account if (ownEmail && this.emailVerified) { @@ -588,11 +690,13 @@ module.exports = function(User) { User.prototype.updateTheme = function updateTheme(theme) { if (!this.constructor.themes[theme]) { - const err = new Error( - 'Theme is not valid.' + const err = wrapHandledError( + new Error('Theme is not valid.'), + { + Type: 'info', + message: err.message + } ); - err.messageType = 'info'; - err.userMessage = err.message; return Promise.reject(err); } return this.update$({ theme }) diff --git a/package-lock.json b/package-lock.json index 662ed5a6f4..dc18611561 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,6 +124,11 @@ "integrity": "sha1-q11PuIP1loFtNRX495HAr0ht1ic=", "dev": true }, + "after-all-results": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/after-all-results/-/after-all-results-2.0.0.tgz", + "integrity": "sha1-asL8ICtQD4jaj09VMM+hAPTGotA=" + }, "ajv": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", @@ -303,6 +308,11 @@ "resolved": "https://registry.npmjs.org/async/-/async-2.1.5.tgz", "integrity": "sha1-5YfGhYCZSsZ/xW/4bTrFa9voELw=" }, + "async-cache": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/async-cache/-/async-cache-1.1.0.tgz", + "integrity": "sha1-SppaidBl7F2OUlS9nulrp2xTK1o=" + }, "async-each": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", @@ -1737,6 +1747,11 @@ "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=" }, + "console-log-level": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/console-log-level/-/console-log-level-1.4.0.tgz", + "integrity": "sha1-QDWBi+6jflhQoMA8jUUMpfLNEhc=" + }, "constantinople": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-3.0.2.tgz", @@ -2442,6 +2457,11 @@ "integrity": "sha1-uJbiOp5ei6M4cfyZar02NfyaHH0=", "dev": true }, + "error-callsites": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/error-callsites/-/error-callsites-1.0.1.tgz", + "integrity": "sha1-QoYWmt+PwSSC9VYRFyTFrthzppI=" + }, "error-ex": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", @@ -2875,6 +2895,11 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fast-safe-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-1.2.0.tgz", + "integrity": "sha1-69QmZv0Y/k8rpPDSlQZfP4XK3pY=" + }, "fbjs": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.12.tgz", @@ -4395,6 +4420,11 @@ } } }, + "hashlru": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hashlru/-/hashlru-2.2.0.tgz", + "integrity": "sha1-eTpYlD+QKupXgXfXsDNfE/JpS3E=" + }, "hawk": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", @@ -4578,6 +4608,11 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" }, + "in-publish": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", + "integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=" + }, "indent-string": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", @@ -4752,12 +4787,27 @@ "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "dev": true }, + "is-integer": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-integer/-/is-integer-1.0.7.tgz", + "integrity": "sha1-a96Bqs3feLZZtmKdYpytxRqIbVw=" + }, "is-my-json-valid": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.16.0.tgz", "integrity": "sha1-8Hndm/2uZe4gOKrorLyGqxCeNpM=", "dev": true }, + "is-native": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-native/-/is-native-1.0.1.tgz", + "integrity": "sha1-zRjMFi6EUNaDtbq+eayZwUVElnU=" + }, + "is-nil": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-nil/-/is-nil-1.0.1.tgz", + "integrity": "sha1-LauingtYUGOHXntTnQcfWxWTeWk=" + }, "is-npm": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-1.0.0.tgz", @@ -4865,6 +4915,11 @@ "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz", "integrity": "sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=" }, + "is-secret": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-secret/-/is-secret-1.1.1.tgz", + "integrity": "sha1-KYig6bOU41YM1IBAbWHKz9dPH/k=" + }, "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -5265,6 +5320,11 @@ "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz", "integrity": "sha1-BJGTngvuVkPuSUp+PaPSuscMbKQ=" }, + "load-source-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/load-source-map/-/load-source-map-1.0.0.tgz", + "integrity": "sha1-MY9JkFzopwnft8w/FvPv47zx3QU=" + }, "loader-utils": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.17.tgz", @@ -6071,6 +6131,11 @@ "integrity": "sha1-mi3sg4Bvuy2XXyK+7IWcoms5OqE=", "dev": true }, + "module-details-from-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", + "integrity": "sha1-EUyUlnPiqKNenTV4hSeqN7Z52is=" + }, "module-not-found-error": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", @@ -6441,6 +6506,11 @@ "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", "dev": true }, + "normalize-bool": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/normalize-bool/-/normalize-bool-1.0.0.tgz", + "integrity": "sha1-RqVx7ZPqWrM3IfrM/FpZuGiQ2Fg=" + }, "normalize-package-data": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", @@ -6569,6 +6639,28 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" }, + "opbeat": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/opbeat/-/opbeat-4.14.0.tgz", + "integrity": "sha1-rpB3qvqRS3KkSAGQWjK8tT1+dd8=", + "dependencies": { + "end-of-stream": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.0.tgz", + "integrity": "sha1-epDYM+/abPpurA9JSduw+tOmMgY=" + } + } + }, + "opbeat-http-client": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/opbeat-http-client/-/opbeat-http-client-1.2.2.tgz", + "integrity": "sha1-itOZlp1QglTazi0IU5gTaBF9oz8=" + }, + "opbeat-release-tracker": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/opbeat-release-tracker/-/opbeat-release-tracker-1.1.1.tgz", + "integrity": "sha1-L2V2clC5Va6YjtyodazYNhIOfgo=" + }, "open": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/open/-/open-0.0.5.tgz", @@ -6613,7 +6705,8 @@ "options": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", - "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=" + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=", + "dev": true }, "orchestrator": { "version": "0.3.8", @@ -6869,8 +6962,7 @@ "path-parse": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", - "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", - "dev": true + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=" }, "path-root": { "version": "0.1.1", @@ -7364,6 +7456,11 @@ "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", "dev": true }, + "redact-secrets": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redact-secrets/-/redact-secrets-1.0.0.tgz", + "integrity": "sha1-YPHbVpJP6QogO6jMs5KDzbsNkHw=" + }, "redent": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", @@ -7512,6 +7609,11 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, + "require-in-the-middle": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-2.1.2.tgz", + "integrity": "sha1-vduJMW1FvNsI4sYYa9Lm6Bmo7q4=" + }, "require-main-filename": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", @@ -7545,8 +7647,7 @@ "resolve": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.3.3.tgz", - "integrity": "sha1-ZVkHw0aahoDcLeOidaj91paR8OU=", - "dev": true + "integrity": "sha1-ZVkHw0aahoDcLeOidaj91paR8OU=" }, "resolve-dir": { "version": "0.1.1", @@ -8259,6 +8360,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "sql-summary": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/sql-summary/-/sql-summary-1.0.0.tgz", + "integrity": "sha1-OeOlHY2F5Gc5g2/H1n0GVLFzo58=" + }, "sse": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/sse/-/sse-0.0.6.tgz", @@ -8281,6 +8387,11 @@ "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.6.tgz", "integrity": "sha1-kQ9dKu17Ugxud3SZwfMuE5/eyxA=" }, + "stackman": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stackman/-/stackman-2.0.1.tgz", + "integrity": "sha1-ztMJxmLpubZn79cYOxrjDFF8uqM=" + }, "statuses": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", @@ -8669,6 +8780,11 @@ "integrity": "sha1-TcGeZk38y+Jb2NtQiwDG2hWCVdE=", "dev": true }, + "to-source-code": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/to-source-code/-/to-source-code-1.0.2.tgz", + "integrity": "sha1-3RNr2x4dvYC76s8IiZJnjpBwv+o=" + }, "to-utf8": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/to-utf8/-/to-utf8-0.0.1.tgz", @@ -8870,6 +8986,16 @@ "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.4.tgz", "integrity": "sha1-LCo/n4PmR2L9xF5s6sZRQoZCE9s=" }, + "unicode-byte-truncate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-byte-truncate/-/unicode-byte-truncate-1.0.0.tgz", + "integrity": "sha1-qm8PNHUZP+IMMgrJIT425i6HZKc=" + }, + "unicode-substring": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicode-substring/-/unicode-substring-0.1.0.tgz", + "integrity": "sha1-YSDOPDkDhdvND2DDK5BlxBgdSzY=" + }, "unique-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-1.0.0.tgz", diff --git a/package.json b/package.json index 06b3df5276..d20c1726d5 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "normalize-url": "^1.3.1", "normalizr": "2.2.1", "object.assign": "^4.0.3", + "opbeat": "^4.14.0", "passport": "^0.2.1", "passport-facebook": "^2.0.0", "passport-github": "^1.0.0", diff --git a/server/boot/a-extendUser.js b/server/boot/a-extendUser.js deleted file mode 100644 index 507384b473..0000000000 --- a/server/boot/a-extendUser.js +++ /dev/null @@ -1,105 +0,0 @@ -import { Observable } from 'rx'; -import debugFactory from 'debug'; -import { isEmail } from 'validator'; -import path from 'path'; - -const debug = debugFactory('fcc:user:remote'); -const isDev = process.env.NODE_ENV !== 'production'; -const devHost = process.env.HOST || 'localhost'; - -function destroyAllRelated(id, Model) { - return Observable.fromNodeCallback( - Model.destroyAll, - Model - )({ userId: id }); -} - -module.exports = function(app) { - var User = app.models.User; - var UserIdentity = app.models.UserIdentity; - var UserCredential = app.models.UserCredential; - User.observe('before delete', function(ctx, next) { - debug('removing user', ctx.where); - var id = ctx.where && ctx.where.id ? ctx.where.id : null; - if (!id) { - return next(); - } - return Observable.combineLatest( - destroyAllRelated(id, UserIdentity), - destroyAllRelated(id, UserCredential), - function(identData, credData) { - return { - identData: identData, - credData: credData - }; - } - ) - .subscribe( - function(data) { - debug('deleted', data); - }, - function(err) { - debug('error deleting user %s stuff', id, err); - next(err); - }, - function() { - debug('user stuff deleted for user %s', id); - next(); - } - ); - }); - - // set email varified false on user email signup - // should not be set with oauth signin methods - User.beforeRemote('create', function(ctx, user, next) { - var body = ctx.req.body; - if (body) { - // this is workaround for preventing a server crash - // refer strongloop/loopback/#1364 - if (body.password === '') { - body.password = null; - } - body.emailVerified = false; - } - next(); - }); - - // send welcome email to new camper - User.afterRemote('create', function({ req, res }, user, next) { - debug('user created, sending email'); - if (!user.email || !isEmail(user.email)) { return next(); } - const redirect = req.session && req.session.returnTo ? - req.session.returnTo : - '/'; - - var mailOptions = { - type: 'email', - to: user.email, - from: 'Team@freecodecamp.com', - subject: 'Welcome to freeCodeCamp!', - protocol: isDev ? null : 'https', - host: isDev ? devHost : 'freecodecamp.com', - port: isDev ? null : 443, - template: path.join( - __dirname, - '..', - 'views', - 'emails', - 'a-extend-user-welcome.ejs' - ), - redirect: '/email-signin' - }; - - debug('sending welcome email'); - return user.verify(mailOptions, function(err) { - if (err) { return next(err); } - req.flash('success', { - msg: [ 'Congratulations ! We\'ve created your account. ', - 'Please check your email. We sent you a link that you can ', - 'click to verify your email address and then login.' - ].join('') - }); - return res.redirect(redirect); - }); - }); -}; diff --git a/server/boot/a-extendUserIdent.js b/server/boot/a-extendUserIdent.js deleted file mode 100644 index 3b8cbfea54..0000000000 --- a/server/boot/a-extendUserIdent.js +++ /dev/null @@ -1,81 +0,0 @@ -import { Observable } from 'rx'; -import debugFactory from 'debug'; -import dedent from 'dedent'; - -import { observeMethod, observeQuery } from '../utils/rx'; -import { getSocialProvider } from '../utils/auth'; - -const debug = debugFactory('fcc:userIdent'); - -export default function({ models }) { - const { User, UserIdentity, UserCredential } = models; - const findUserById = observeMethod(User, 'findById'); - const findIdent = observeMethod(UserIdentity, 'findOne'); - - UserIdentity.link = function( - userId, - provider, - authScheme, - profile, - credentials, - options = {}, - cb - ) { - if (typeof options === 'function' && !cb) { - cb = options; - options = {}; - } - const user$ = findUserById(userId); - const query = { - where: { - provider: getSocialProvider(provider), - externalId: profile.id - } - }; - - debug('link identity query', query); - findIdent(query) - .flatMap(identity => { - const modified = new Date(); - if (!identity) { - return observeQuery(UserIdentity, 'create', { - provider: getSocialProvider(provider), - externalId: profile.id, - authScheme, - profile, - credentials, - userId, - created: modified, - modified - }); - } - if (identity.userId.toString() !== userId.toString()) { - return Observable.throw( - new Error( - dedent` -Your GitHub is already associated with another account. -You may have accidentally created a duplicate account. -No worries, though. We can fix this real quick. -Please email us with your GitHub username: team@freecodecamp.com. - `.split('/n').join(' ') - ) - ); - } - identity.credentials = credentials; - return observeQuery(identity, 'updateAttributes', { - profile, - credentials, - modified - }); - }) - .withLatestFrom(user$, (identity, user) => ({ identity, user })) - .subscribe( - ({ identity, user }) => { - cb(null, user, identity); - }, - cb - ); - }; - - UserCredential.link = UserIdentity.link.bind(UserIdentity); -} diff --git a/server/component-passport.js b/server/component-passport.js index b857b4b1fd..f5c925f344 100644 --- a/server/component-passport.js +++ b/server/component-passport.js @@ -1,48 +1,10 @@ import passport from 'passport'; import { PassportConfigurator } from 'loopback-component-passport'; import passportProviders from './passport-providers'; -import uuid from 'uuid'; -import { generateKey } from 'loopback-component-passport/lib/models/utils'; - -import { - setProfileFromGithub, - getSocialProvider, - getUsernameFromProvider -} from './utils/auth'; const passportOptions = { emailOptional: true, - profileToUser(provider, profile) { - const emails = profile.emails; - // NOTE(berks): get email or set to null. - // MongoDB indexs email but can be sparse(blank) - const email = emails && emails[0] && emails[0].value ? - emails[0].value : - null; - - // create random username - // username will be assigned when camper signups for Github - const username = 'fcc' + uuid.v4().slice(0, 8); - const password = generateKey('password'); - let userObj = { - username: username, - password: password - }; - - if (email) { - userObj.email = email; - } - - if (!(/github/).test(provider)) { - userObj[getSocialProvider(provider)] = getUsernameFromProvider( - getSocialProvider(provider), - profile - ); - } else { - userObj = setProfileFromGithub(userObj, profile, profile._json); - } - return userObj; - } + profileToUser: null }; const fields = { diff --git a/server/config.json b/server/config.json index fb2f957d78..02cb729409 100644 --- a/server/config.json +++ b/server/config.json @@ -8,6 +8,7 @@ "enableHttpContext": false }, "rest": { + "handleErrors": false, "normalizeHttpPath": false, "xml": false }, @@ -22,9 +23,6 @@ "cors": { "origin": true, "credentials": true - }, - "errorHandler": { - "disableStackTrace": false } } } diff --git a/server/middleware.json b/server/middleware.json index 83a62c1401..18f7aaa5cc 100644 --- a/server/middleware.json +++ b/server/middleware.json @@ -53,7 +53,7 @@ }, "files": {}, "final:after": { - "./middlewares/keymetrics": {}, + "./middlewares/error-reporter": {}, "./middlewares/error-handlers": {} } } diff --git a/server/middlewares/error-handlers.js b/server/middlewares/error-handlers.js index cf4d4b042f..5e11a76dfa 100644 --- a/server/middlewares/error-handlers.js +++ b/server/middlewares/error-handlers.js @@ -1,5 +1,6 @@ import errorHandler from 'errorhandler'; import accepts from 'accepts'; +import { unwrapHandledError } from '../utils/create-handled-error.js'; export default function prodErrorHandler() { if (process.env.NODE_ENV === 'development') { @@ -21,20 +22,24 @@ export default function prodErrorHandler() { // parse res type const accept = accepts(req); const type = accept.type('html', 'json', 'text'); + const handled = unwrapHandledError(err); - const message = 'Oops! Something went wrong. Please try again later'; + const redirectTo = handled.redirectTo || '/map'; + const message = handled.message || + 'Oops! Something went wrong. Please try again later'; if (type === 'html') { if (typeof req.flash === 'function') { - req.flash(err.messageType || 'errors', { - msg: err.userMessage || message - }); + req.flash( + handled.type || 'errors', + { msg: message } + ); } - return res.redirect(err.redirectTo || '/map'); + return res.redirect(redirectTo); // json } else if (type === 'json') { res.setHeader('Content-Type', 'application/json'); return res.send({ - message: message + message }); // plain text } else { diff --git a/server/middlewares/error-reporter.js b/server/middlewares/error-reporter.js new file mode 100644 index 0000000000..61f0432048 --- /dev/null +++ b/server/middlewares/error-reporter.js @@ -0,0 +1,30 @@ +import opbeat from 'opbeat'; +import debug from 'debug'; + +import { + isHandledError, + unwrapHandledError +} from '../utils/create-handled-error.js'; + +const log = debug('fcc:middlewares:error-reporter'); + +export default function keymetrics() { + if (process.env.NODE_ENV !== 'production') { + return (err, req, res, next) => { + if (isHandledError(err)) { + // log out user messages in development + const handled = unwrapHandledError(err); + log(handled.message); + } + next(err); + }; + } + return (err, req, res, next) => { + // handled errors do not need to be reported + // the report a message and redirect the user + if (isHandledError(err)) { + return next(err); + } + return opbeat.captureError(err, { request: req }, () => next(err)); + }; +} diff --git a/server/middlewares/keymetrics.js b/server/middlewares/keymetrics.js deleted file mode 100644 index 183f670862..0000000000 --- a/server/middlewares/keymetrics.js +++ /dev/null @@ -1,20 +0,0 @@ -import pmx from 'pmx'; - -export default function keymetrics() { - if (process.env.NODE_ENV !== 'production') { - return (err, req, res, next) => next(err); - } - return (err, req, res, next) => { - if (res.statusCode < 400) { res.statusCode = 500; } - - err.url = req.url; - err.component = req.url; - err.action = req.method; - err.params = req.body; - err.session = req.session; - err.username = req.user ? req.user.username : 'anonymous'; - err.userId = req.user ? req.user.id : 'anonymous'; - - return next(pmx.notify(err)); - }; -} diff --git a/server/passport-providers.js b/server/passport-providers.js index 2b8aebfc1d..28d97a4ce7 100644 --- a/server/passport-providers.js +++ b/server/passport-providers.js @@ -1,7 +1,7 @@ -var successRedirect = '/'; -var failureRedirect = '/signin'; -var linkFailureRedirect = '/account'; -var githubProfileSuccessRedirect = '/settings'; +const successRedirect = '/'; +const failureRedirect = '/signin'; +const linkSuccessRedirect = '/settings'; +const linkFailureRedirect = '/settings'; export default { local: { @@ -36,7 +36,7 @@ export default { authPath: '/link/facebook', callbackURL: '/link/facebook/callback', callbackPath: '/link/facebook/callback', - successRedirect: successRedirect, + successRedirect: linkSuccessRedirect, failureRedirect: linkFailureRedirect, scope: ['email', 'user_likes'], link: true, @@ -65,7 +65,7 @@ export default { authPath: '/link/google', callbackURL: '/link/google/callback', callbackPath: '/link/google/callback', - successRedirect: successRedirect, + successRedirect: linkSuccessRedirect, failureRedirect: linkFailureRedirect, scope: ['email', 'profile'], link: true, @@ -91,7 +91,7 @@ export default { authPath: '/link/twitter', callbackURL: '/link/twitter/callback', callbackPath: '/link/twitter/callback', - successRedirect: successRedirect, + successRedirect: linkSuccessRedirect, failureRedirect: linkFailureRedirect, consumerKey: process.env.TWITTER_KEY, consumerSecret: process.env.TWITTER_SECRET, @@ -122,7 +122,7 @@ export default { authPath: '/link/linkedin', callbackURL: '/link/linkedin/callback', callbackPath: '/link/linkedin/callback', - successRedirect: successRedirect, + successRedirect: linkSuccessRedirect, failureRedirect: linkFailureRedirect, clientID: process.env.LINKEDIN_ID, clientSecret: process.env.LINKEDIN_SECRET, @@ -153,7 +153,7 @@ export default { authPath: '/link/github', callbackURL: '/auth/github/callback/link', callbackPath: '/auth/github/callback/link', - successRedirect: githubProfileSuccessRedirect, + successRedirect: linkSuccessRedirect, failureRedirect: linkFailureRedirect, clientID: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, diff --git a/server/server.js b/server/server.js index 1f71e292f8..137003deb3 100755 --- a/server/server.js +++ b/server/server.js @@ -1,7 +1,13 @@ require('dotenv').load(); -var pmx = require('pmx'); -pmx.init(); +if (process.env.OPBEAT_ID) { + console.log('loading opbeat'); + require('opbeat').start({ + appId: process.env.OPBEAT_ID, + organizationId: process.env.OPBEAT_ORG_ID, + secretToken: process.env.OPBEAT_SECRET + }); +} var _ = require('lodash'), Rx = require('rx'), diff --git a/server/utils/auth.js b/server/utils/auth.js index 38b480f0b4..22c8cbb302 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -1,5 +1,7 @@ +const githubRegex = (/github/i); const providerHash = { facebook: ({ id }) => id, + github: ({ username }) => username, twitter: ({ username }) => username, linkedin({ _json }) { return _json && _json.publicProfileUrl || null; @@ -13,48 +15,49 @@ export function getUsernameFromProvider(provider, profile) { null; } -// using es6 argument destructing -export function setProfileFromGithub( - user, - { - profileUrl: githubURL, - username - }, - { - id: githubId, - avatar_url: picture, - email: githubEmail, - created_at: joinedGithubOn, - blog: website, - location, - bio, - name +// createProfileAttributes(provider: String, profile: {}) => Object +export function createUserUpdatesFromProfile(provider, profile) { + if (githubRegex.test(provider)) { + return createProfileAttributesFromGithub(profile); } -) { - return Object.assign( - user, - { - name, - email: user.email || githubEmail, - username: username.toLowerCase(), + return { + [getSocialProvider(provider)]: getUsernameFromProvider( + getSocialProvider(provider), + profile + ) + }; +} +// using es6 argument destructing +// createProfileAttributes(profile) => profileUpdate +function createProfileAttributesFromGithub(profile) { + const { + profileUrl: githubURL, + username, + _json: { + id: githubId, + avatar_url: picture, + email: githubEmail, + created_at: joinedGithubOn, + blog: website, location, bio, - joinedGithubOn, - website, - isGithubCool: true, - picture, - githubId, - githubURL, - githubEmail, - githubProfile: githubURL - } - ); -} - -export function getFirstImageFromProfile(profile) { - return profile && profile.photos && profile.photos[0] ? - profile.photos[0].value : - null; + name + } = {} + } = profile; + return { + name, + username: username.toLowerCase(), + location, + bio, + joinedGithubOn, + website, + isGithubCool: true, + picture, + githubId, + githubURL, + githubEmail, + githubProfile: githubURL + }; } export function getSocialProvider(provider) { diff --git a/server/utils/create-handled-error.js b/server/utils/create-handled-error.js new file mode 100644 index 0000000000..f691b0abfb --- /dev/null +++ b/server/utils/create-handled-error.js @@ -0,0 +1,18 @@ +const _handledError = Symbol('handledError'); + +export function isHandledError(err) { + return !!err[_handledError]; +} + +export function unwrapHandledError(err) { + return err[_handledError] || {}; +} + +export function wrapHandledError(err, { + type, + message, + redirectTo +}) { + err[_handledError] = { type, message, redirectTo }; + return err; +}