1184 lines
		
	
	
		
			31 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1184 lines
		
	
	
		
			31 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  *
 | |
|  * Any ref to fixCompletedChallengesItem should be removed post
 | |
|  * a db migration to fix all completedChallenges
 | |
|  *
 | |
|  */
 | |
| 
 | |
| import { Observable } from 'rx';
 | |
| import uuid from 'uuid/v4';
 | |
| import moment from 'moment';
 | |
| import dedent from 'dedent';
 | |
| import debugFactory from 'debug';
 | |
| import { isEmail } from 'validator';
 | |
| import path from 'path';
 | |
| import loopback from 'loopback';
 | |
| import _ from 'lodash';
 | |
| import jwt from 'jsonwebtoken';
 | |
| import generate from 'nanoid/generate';
 | |
| 
 | |
| import { homeLocation, apiLocation } from '../../../config/env';
 | |
| 
 | |
| import { fixCompletedChallengeItem } from '../utils';
 | |
| import { themes } from '../utils/themes';
 | |
| import { saveUser, observeMethod } from '../../server/utils/rx.js';
 | |
| import { blacklistedUsernames } from '../../server/utils/constants.js';
 | |
| import { wrapHandledError } from '../../server/utils/create-handled-error.js';
 | |
| import {
 | |
|   getEmailSender
 | |
| } from '../../server/utils/url-utils.js';
 | |
| import {
 | |
|   normaliseUserFields,
 | |
|   getProgress,
 | |
|   publicUserProps
 | |
| } from '../../server/utils/publicUserProps';
 | |
| 
 | |
| const log = debugFactory('fcc:models:user');
 | |
| const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
 | |
| const nanoidCharSet =
 | |
|   '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
 | |
| 
 | |
| const createEmailError = redirectTo => wrapHandledError(
 | |
|   new Error('email format is invalid'),
 | |
|   {
 | |
|     type: 'info',
 | |
|     message: 'Please check to make sure the email is a valid email address.',
 | |
|     redirectTo
 | |
|   }
 | |
| );
 | |
| 
 | |
| function destroyAll(id, Model) {
 | |
|   return Observable.fromNodeCallback(
 | |
|     Model.destroyAll,
 | |
|     Model
 | |
|   )({ userId: id });
 | |
| }
 | |
| 
 | |
| function buildCompletedChallengesUpdate(completedChallenges, project) {
 | |
|   const key = Object.keys(project)[0];
 | |
|   const solutions = project[key];
 | |
|   const solutionKeys = Object.keys(solutions);
 | |
|   const currentCompletedChallenges = [
 | |
|     ...completedChallenges.map(fixCompletedChallengeItem)
 | |
|   ];
 | |
|   const currentCompletedProjects = currentCompletedChallenges
 | |
|     .filter(({id}) => solutionKeys.includes(id));
 | |
|   const now = Date.now();
 | |
|   const update = solutionKeys.reduce((update, currentId) => {
 | |
|     const indexOfCurrentId = _.findIndex(
 | |
|       update,
 | |
|       ({id}) => id === currentId
 | |
|     );
 | |
|     const isCurrentlyCompleted = indexOfCurrentId !== -1;
 | |
|     if (isCurrentlyCompleted) {
 | |
|       update[indexOfCurrentId] = {
 | |
|         ..._.find(update, ({id}) => id === currentId),
 | |
|         solution: solutions[currentId]
 | |
|       };
 | |
|     }
 | |
|     if (!isCurrentlyCompleted) {
 | |
|       return [
 | |
|         ...update,
 | |
|         {
 | |
|           id: currentId,
 | |
|           solution: solutions[currentId],
 | |
|           challengeType: 3,
 | |
|           completedDate: now
 | |
|         }
 | |
|       ];
 | |
|     }
 | |
|     return update;
 | |
|   }, currentCompletedProjects);
 | |
|   const updatedExisting = _.uniqBy(
 | |
|     [
 | |
|       ...update,
 | |
|       ...currentCompletedChallenges
 | |
|     ],
 | |
|     'id'
 | |
|   );
 | |
|   return {
 | |
|     updated: updatedExisting,
 | |
|     isNewCompletionCount:
 | |
|       updatedExisting.length - completedChallenges.length
 | |
|   };
 | |
| }
 | |
| 
 | |
| function isTheSame(val1, val2) {
 | |
|   return val1 === val2;
 | |
| }
 | |
| 
 | |
| const renderSignUpEmail = loopback.template(path.join(
 | |
|   __dirname,
 | |
|   '..',
 | |
|   '..',
 | |
|   'server',
 | |
|   'views',
 | |
|   'emails',
 | |
|   'user-request-sign-up.ejs'
 | |
| ));
 | |
| 
 | |
| const renderSignInEmail = loopback.template(path.join(
 | |
|   __dirname,
 | |
|   '..',
 | |
|   '..',
 | |
|   'server',
 | |
|   'views',
 | |
|   'emails',
 | |
|   'user-request-sign-in.ejs'
 | |
| ));
 | |
| 
 | |
| const renderEmailChangeEmail = loopback.template(path.join(
 | |
|   __dirname,
 | |
|   '..',
 | |
|   '..',
 | |
|   'server',
 | |
|   'views',
 | |
|   'emails',
 | |
|   'user-request-update-email.ejs'
 | |
| ));
 | |
| 
 | |
| function getAboutProfile({
 | |
|   username,
 | |
|   githubProfile: github,
 | |
|   progressTimestamps = [],
 | |
|   bio
 | |
| }) {
 | |
|   return {
 | |
|     username,
 | |
|     github,
 | |
|     browniePoints: progressTimestamps.length,
 | |
|     bio
 | |
|   };
 | |
| }
 | |
| 
 | |
| function nextTick(fn) {
 | |
|   return process.nextTick(fn);
 | |
| }
 | |
| 
 | |
| function getWaitPeriod(ttl) {
 | |
|   const fiveMinutesAgo = moment().subtract(5, 'minutes');
 | |
|   const lastEmailSentAt = moment(new Date(ttl || null));
 | |
|   const isWaitPeriodOver = ttl ?
 | |
|     lastEmailSentAt.isBefore(fiveMinutesAgo) : true;
 | |
| 
 | |
|   if (!isWaitPeriodOver) {
 | |
|     const minutesLeft = 5 -
 | |
|       (moment().minutes() - lastEmailSentAt.minutes());
 | |
|     return minutesLeft;
 | |
|   }
 | |
| 
 | |
|   return 0;
 | |
| }
 | |
| 
 | |
| function getWaitMessage(ttl) {
 | |
|   const minutesLeft = getWaitPeriod(ttl);
 | |
|   if (minutesLeft <= 0) {
 | |
|     return null;
 | |
|   }
 | |
|   const timeToWait = minutesLeft ?
 | |
|     `${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` :
 | |
|     'a few seconds';
 | |
| 
 | |
|   return dedent`
 | |
|     Please wait ${timeToWait} to resend an authentication link.
 | |
|   `;
 | |
| }
 | |
| 
 | |
| module.exports = function(User) {
 | |
|   // set salt factor for passwords
 | |
|   User.settings.saltWorkFactor = 5;
 | |
|   // set user.rand to random number
 | |
|   User.definition.rawProperties.rand.default =
 | |
|     User.definition.properties.rand.default = function() {
 | |
|       return Math.random();
 | |
|     };
 | |
|   // increase user accessToken ttl to 900 days
 | |
|   User.settings.ttl = 900 * 24 * 60 * 60 * 1000;
 | |
| 
 | |
|   // username should not be in blacklist
 | |
|   User.validatesExclusionOf('username', {
 | |
|     in: blacklistedUsernames,
 | |
|     message: 'is taken'
 | |
|   });
 | |
| 
 | |
|   // username should be unique
 | |
|   User.validatesUniquenessOf('username');
 | |
|   User.settings.emailVerificationRequired = false;
 | |
| 
 | |
|   User.on('dataSourceAttached', () => {
 | |
|     User.findOne$ = Observable.fromNodeCallback(User.findOne, User);
 | |
|     User.update$ = Observable.fromNodeCallback(User.updateAll, User);
 | |
|     User.count$ = Observable.fromNodeCallback(User.count, User);
 | |
|     User.create$ = Observable.fromNodeCallback(
 | |
|       User.create.bind(User)
 | |
|     );
 | |
|     User.prototype.createAccessToken$ = Observable.fromNodeCallback(
 | |
|       User.prototype.createAccessToken
 | |
|     );
 | |
|   });
 | |
| 
 | |
|   User.observe('before save', function(ctx) {
 | |
|     const beforeCreate = Observable.of(ctx)
 | |
|       .filter(({ isNewInstance }) => isNewInstance)
 | |
|       // User.create
 | |
|       .map(({ instance }) => instance)
 | |
|       .flatMap(user => {
 | |
|         // note(berks): we now require all new users to supply an email
 | |
|         // this was not always the case
 | |
|         if (
 | |
|           typeof user.email !== 'string' ||
 | |
|           !isEmail(user.email)
 | |
|         ) {
 | |
|           throw createEmailError();
 | |
|         }
 | |
|         // assign random username to new users
 | |
|         // actual usernames will come from github
 | |
|         // use full uuid to ensure uniqueness
 | |
|         user.username = 'fcc' + uuid();
 | |
| 
 | |
|         if (!user.externalId) {
 | |
|           user.externalId = uuid();
 | |
|         }
 | |
|         if (!user.unsubscribeId) {
 | |
|           user.unsubscribeId = generate(nanoidCharSet, 20);
 | |
|         }
 | |
| 
 | |
|         if (!user.progressTimestamps) {
 | |
|           user.progressTimestamps = [];
 | |
|         }
 | |
| 
 | |
|         if (user.progressTimestamps.length === 0) {
 | |
|           user.progressTimestamps.push(Date.now());
 | |
|         }
 | |
|         return Observable.fromPromise(User.doesExist(null, user.email))
 | |
|           .do(exists => {
 | |
|             if (exists) {
 | |
|               throw wrapHandledError(
 | |
|                 new Error('user already exists'),
 | |
|                 {
 | |
|                   redirectTo: `${homeLocation}/signin`,
 | |
|                   message: dedent`
 | |
|         The ${user.email} email address is already associated with an account.
 | |
|         Try signing in with it here instead.
 | |
|                   `
 | |
|                 }
 | |
|               );
 | |
|             }
 | |
|           });
 | |
|       })
 | |
|       .ignoreElements();
 | |
| 
 | |
|     const updateOrSave = Observable.of(ctx)
 | |
|       // not new
 | |
|       .filter(({ isNewInstance }) => !isNewInstance)
 | |
|       .map(({ instance }) => instance)
 | |
|       // is update or save user
 | |
|       .filter(Boolean)
 | |
|       .do(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)) {
 | |
|           throw createEmailError();
 | |
|         }
 | |
| 
 | |
|         user.username = user.username.trim().toLowerCase();
 | |
|         user.email = typeof user.email === 'string' ?
 | |
|           user.email.trim().toLowerCase() :
 | |
|           user.email;
 | |
| 
 | |
|         if (!user.progressTimestamps) {
 | |
|           user.progressTimestamps = [];
 | |
|         }
 | |
| 
 | |
|         if (user.progressTimestamps.length === 0) {
 | |
|           user.progressTimestamps.push(Date.now());
 | |
|         }
 | |
| 
 | |
|         if (!user.externalId) {
 | |
|           user.externalId = uuid();
 | |
|         }
 | |
| 
 | |
|         if (!user.unsubscribeId) {
 | |
|           user.unsubscribeId = generate(nanoidCharSet, 20);
 | |
|         }
 | |
|       })
 | |
|       .ignoreElements();
 | |
|     return Observable.merge(beforeCreate, updateOrSave)
 | |
|       .toPromise();
 | |
|   });
 | |
| 
 | |
|   // 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;
 | |
|     log('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) {
 | |
|           log('deleted', data);
 | |
|         },
 | |
|         function(err) {
 | |
|           log('error deleting user %s stuff', id, err);
 | |
|           next(err);
 | |
|         },
 | |
|         function() {
 | |
|           log('user stuff deleted for user %s', id);
 | |
|           next();
 | |
|         }
 | |
|       );
 | |
|   });
 | |
| 
 | |
|   log('setting up user hooks');
 | |
|   // overwrite lb confirm
 | |
|   User.confirm = function(uid, token, redirectTo) {
 | |
|     return this.findById(uid)
 | |
|       .then(user => {
 | |
|         if (!user) {
 | |
|           throw wrapHandledError(
 | |
|             new Error(`User not found: ${uid}`),
 | |
|             {
 | |
|               // standard oops
 | |
|               type: 'info',
 | |
|               redirectTo
 | |
|             }
 | |
|           );
 | |
|         }
 | |
|         if (user.verificationToken !== token) {
 | |
|           throw wrapHandledError(
 | |
|             new Error(`Invalid token: ${token}`),
 | |
|             {
 | |
|               type: 'info',
 | |
|               message: dedent`
 | |
|                 Looks like you have clicked an invalid link.
 | |
|                 Please sign in and request a fresh one.
 | |
|               `,
 | |
|               redirectTo
 | |
|             }
 | |
|           );
 | |
|         }
 | |
|         return user.update$({
 | |
|           email: user.newEmail,
 | |
|           emailVerified: true,
 | |
|           emailVerifyTTL: null,
 | |
|           newEmail: null,
 | |
|           verificationToken: null
 | |
|         }).toPromise();
 | |
|       });
 | |
|   };
 | |
| 
 | |
|   function manualReload() {
 | |
|     this.reload((err, instance) => {
 | |
|       if (err) {
 | |
|         throw Error('failed to reload user instance');
 | |
|       }
 | |
|       Object.assign(this, instance);
 | |
|       log('user reloaded from db');
 | |
|     });
 | |
|   }
 | |
|   User.prototype.manualReload = manualReload;
 | |
| 
 | |
|   User.prototype.loginByRequest = function loginByRequest(req, res) {
 | |
|     const {
 | |
|       query: {
 | |
|         emailChange
 | |
|       }
 | |
|     } = req;
 | |
|     const createToken = this.createAccessToken$()
 | |
|       .do(accessToken => {
 | |
|         const config = {
 | |
|           signed: !!req.signedCookies,
 | |
|           maxAge: accessToken.ttl,
 | |
|           domain: process.env.COOKIE_DOMAIN || 'localhost'
 | |
|         };
 | |
|         if (accessToken && accessToken.id) {
 | |
|           const jwtAccess = jwt.sign({accessToken}, process.env.JWT_SECRET);
 | |
|           res.cookie('jwt_access_token', jwtAccess, config);
 | |
|           res.cookie('access_token', accessToken.id, config);
 | |
|           res.cookie('userId', accessToken.userId, config);
 | |
|         }
 | |
|       });
 | |
|     let data = {
 | |
|       emailVerified: true,
 | |
|       emailAuthLinkTTL: null,
 | |
|       emailVerifyTTL: null
 | |
|     };
 | |
|     if (emailChange && this.newEmail) {
 | |
|       data = {
 | |
|         ...data,
 | |
|         email: this.newEmail,
 | |
|         newEmail: null
 | |
|       };
 | |
|     }
 | |
|     const updateUser = this.update$(data);
 | |
|     return Observable.combineLatest(
 | |
|       createToken,
 | |
|       updateUser,
 | |
|       req.logIn(this),
 | |
|       (accessToken) => accessToken,
 | |
|     );
 | |
|   };
 | |
| 
 | |
|   User.afterRemote('logout', function({req, res}, result, next) {
 | |
|     const config = {
 | |
|       signed: !!req.signedCookies,
 | |
|       domain: process.env.COOKIE_DOMAIN || 'localhost'
 | |
|     };
 | |
|     res.clearCookie('jwt_access_token', config);
 | |
|     res.clearCookie('access_token', config);
 | |
|     res.clearCookie('userId', config);
 | |
|     res.clearCookie('_csrf', config);
 | |
|     next();
 | |
|   });
 | |
| 
 | |
|   User.doesExist = function doesExist(username, email) {
 | |
|     if (!username && (!email || !isEmail(email))) {
 | |
|       return Promise.resolve(false);
 | |
|     }
 | |
|     log('checking existence');
 | |
| 
 | |
|     // check to see if username is on blacklist
 | |
|     if (username && blacklistedUsernames.indexOf(username) !== -1) {
 | |
|       return Promise.resolve(true);
 | |
|     }
 | |
| 
 | |
|     var where = {};
 | |
|     if (username) {
 | |
|       where.username = username.toLowerCase();
 | |
|     } else {
 | |
|       where.email = email ? email.toLowerCase() : email;
 | |
|     }
 | |
|     log('where', where);
 | |
|     return User.count(where)
 | |
|     .then(count => count > 0);
 | |
|   };
 | |
| 
 | |
|   User.remoteMethod(
 | |
|     'doesExist',
 | |
|     {
 | |
|       description: 'checks whether a user exists using email or username',
 | |
|       accepts: [
 | |
|         {
 | |
|           arg: 'username',
 | |
|           type: 'string'
 | |
|         },
 | |
|         {
 | |
|           arg: 'email',
 | |
|           type: 'string'
 | |
|         }
 | |
|       ],
 | |
|       returns: [
 | |
|         {
 | |
|           arg: 'exists',
 | |
|           type: 'boolean'
 | |
|         }
 | |
|       ],
 | |
|       http: {
 | |
|         path: '/exists',
 | |
|         verb: 'get'
 | |
|       }
 | |
|     }
 | |
|   );
 | |
| 
 | |
|   User.about = function about(username, cb) {
 | |
|     if (!username) {
 | |
|       // Zalgo!!
 | |
|       return nextTick(() => {
 | |
|         cb(null, {});
 | |
|       });
 | |
|     }
 | |
|     return User.findOne({ where: { username } }, (err, user) => {
 | |
|       if (err) {
 | |
|         return cb(err);
 | |
|       }
 | |
|       if (!user || user.username !== username) {
 | |
|         return cb(null, {});
 | |
|       }
 | |
|       const aboutUser = getAboutProfile(user);
 | |
|       return cb(null, aboutUser);
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   User.remoteMethod(
 | |
|     'about',
 | |
|     {
 | |
|       description: 'get public info about user',
 | |
|       accepts: [
 | |
|         {
 | |
|           arg: 'username',
 | |
|           type: 'string'
 | |
|         }
 | |
|       ],
 | |
|       returns: [
 | |
|         {
 | |
|           arg: 'about',
 | |
|           type: 'object'
 | |
|         }
 | |
|       ],
 | |
|       http: {
 | |
|         path: '/about',
 | |
|         verb: 'get'
 | |
|       }
 | |
|     }
 | |
|   );
 | |
| 
 | |
|   User.prototype.createAuthToken = function createAuthToken({ ttl } = {}) {
 | |
|     return Observable.fromNodeCallback(
 | |
|       this.authTokens.create.bind(this.authTokens)
 | |
|     )({ ttl });
 | |
|   };
 | |
| 
 | |
|   User.prototype.createDonation = function createDonation(donation = {}) {
 | |
|     return Observable.fromNodeCallback(
 | |
|       this.donations.create.bind(this.donations)
 | |
|     )(donation)
 | |
|     .do(() => this.update$({
 | |
|       $set: {
 | |
|         isDonating: true
 | |
|       },
 | |
|       $push: {
 | |
|         donationEmails: donation.email
 | |
|         }
 | |
|       })
 | |
|     )
 | |
|     .do(() => this.manualReload());
 | |
|   };
 | |
| 
 | |
|   User.prototype.getEncodedEmail = function getEncodedEmail(email) {
 | |
|     if (!email) {
 | |
|       return null;
 | |
|     }
 | |
|     return Buffer(email).toString('base64');
 | |
|   };
 | |
| 
 | |
|   User.decodeEmail = email => Buffer(email, 'base64').toString();
 | |
| 
 | |
|   function requestAuthEmail(isSignUp, newEmail) {
 | |
|     return Observable.defer(() => {
 | |
|       const messageOrNull = getWaitMessage(this.emailAuthLinkTTL);
 | |
|       if (messageOrNull) {
 | |
|         throw wrapHandledError(
 | |
|           new Error('request is throttled'),
 | |
|           {
 | |
|             type: 'info',
 | |
|             message: messageOrNull
 | |
|           }
 | |
|         );
 | |
|       }
 | |
| 
 | |
|       // create a temporary access token with ttl for 15 minutes
 | |
|       return this.createAuthToken({ ttl: 15 * 60 * 1000 });
 | |
|     })
 | |
|       .flatMap(token => {
 | |
|         let renderAuthEmail = renderSignInEmail;
 | |
|         let subject = 'Your sign in link for freeCodeCamp.org';
 | |
|         if (isSignUp) {
 | |
|           renderAuthEmail = renderSignUpEmail;
 | |
|           subject = 'Your sign in link for your new freeCodeCamp.org account';
 | |
|         }
 | |
|         if (newEmail) {
 | |
|           renderAuthEmail = renderEmailChangeEmail;
 | |
|           subject = dedent`
 | |
|             Please confirm your updated email address for freeCodeCamp.org
 | |
|           `;
 | |
|         }
 | |
|         const { id: loginToken, created: emailAuthLinkTTL } = token;
 | |
|         const loginEmail = this.getEncodedEmail(newEmail ? newEmail : null);
 | |
|         const host = apiLocation;
 | |
|         const mailOptions = {
 | |
|           type: 'email',
 | |
|           to: newEmail ? newEmail : this.email,
 | |
|           from: getEmailSender(),
 | |
|           subject,
 | |
|           text: renderAuthEmail({
 | |
|             host,
 | |
|             loginEmail,
 | |
|             loginToken,
 | |
|             emailChange: !!newEmail
 | |
|           })
 | |
|         };
 | |
|         return Observable.forkJoin(
 | |
|           User.email.send$(mailOptions),
 | |
|           this.update$({ emailAuthLinkTTL })
 | |
|         );
 | |
|       })
 | |
|       .map(() => 'Check your email and click the link we sent you to confirm' +
 | |
|         ' your new email address.'
 | |
|       );
 | |
|   }
 | |
| 
 | |
|   User.prototype.requestAuthEmail = requestAuthEmail;
 | |
| 
 | |
|   User.prototype.requestUpdateEmail = function requestUpdateEmail(newEmail) {
 | |
| 
 | |
|     const currentEmail = this.email;
 | |
|     const isOwnEmail = isTheSame(newEmail, currentEmail);
 | |
|     const isResendUpdateToSameEmail = isTheSame(newEmail, this.newEmail);
 | |
|     const isLinkSentWithinLimit = getWaitMessage(this.emailVerifyTTL);
 | |
|     const isVerifiedEmail = this.emailVerified;
 | |
| 
 | |
|     if (isOwnEmail && isVerifiedEmail) {
 | |
|       // email is already associated and verified with this account
 | |
|       throw wrapHandledError(
 | |
|         new Error('email is already verified'),
 | |
|         {
 | |
|           type: 'info',
 | |
|           message: `
 | |
|             ${newEmail} is already associated with this account.
 | |
|             You can update a new email address instead.`
 | |
|         }
 | |
|       );
 | |
|     }
 | |
|     if (isResendUpdateToSameEmail && isLinkSentWithinLimit) {
 | |
|       // trying to update with the same newEmail and
 | |
|       // confirmation email is still valid
 | |
|       throw wrapHandledError(
 | |
|         new Error(),
 | |
|         {
 | |
|           type: 'info',
 | |
|           message: dedent`
 | |
|           We have already sent an email confirmation request to ${newEmail}.
 | |
|           ${isLinkSentWithinLimit}`
 | |
|         }
 | |
|       );
 | |
|     }
 | |
|     if (!isEmail('' + newEmail)) {
 | |
|       throw createEmailError();
 | |
|     }
 | |
| 
 | |
|     // newEmail is not associated with this user, and
 | |
|     // this attempt to change email is the first or
 | |
|     // previous attempts have expired
 | |
|     if (
 | |
|         !isOwnEmail ||
 | |
|         (isOwnEmail && !isVerifiedEmail) ||
 | |
|         (isResendUpdateToSameEmail && !isLinkSentWithinLimit)
 | |
|       ) {
 | |
|       const updateConfig = {
 | |
|         newEmail,
 | |
|         emailVerified: false,
 | |
|         emailVerifyTTL: new Date()
 | |
|       };
 | |
| 
 | |
|       // defer prevents the promise from firing prematurely (before subscribe)
 | |
|       return Observable.defer(() => User.doesExist(null, newEmail))
 | |
|       .do(exists => {
 | |
|         if (exists && !isOwnEmail) {
 | |
|           // newEmail is not associated with this account,
 | |
|           // but is associated with different account
 | |
|           throw wrapHandledError(
 | |
|             new Error('email already in use'),
 | |
|             {
 | |
|               type: 'info',
 | |
|               message:
 | |
|               `${newEmail} is already associated with another account.`
 | |
|             }
 | |
|           );
 | |
|         }
 | |
|       })
 | |
|       .flatMap(()=>{
 | |
|         const updatePromise = new Promise((resolve, reject) =>
 | |
|         this.updateAttributes(updateConfig, err => {
 | |
|           if (err) {
 | |
|             return reject(err);
 | |
|           }
 | |
|           return resolve();
 | |
|         }));
 | |
|         return Observable.forkJoin(
 | |
|           Observable.fromPromise(updatePromise),
 | |
|           this.requestAuthEmail(false, newEmail),
 | |
|           (_, message) => message
 | |
|         );
 | |
|       });
 | |
| 
 | |
|     } else {
 | |
|       return 'Something unexpected happened whilst updating your email.';
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   function requestCompletedChallenges() {
 | |
|     return this.getCompletedChallenges$();
 | |
|   }
 | |
| 
 | |
|   User.prototype.requestCompletedChallenges = requestCompletedChallenges;
 | |
| 
 | |
|   User.prototype.requestUpdateFlags = function requestUpdateFlags(values) {
 | |
|     const flagsToCheck = Object.keys(values);
 | |
|     const valuesToCheck = _.pick({ ...this }, flagsToCheck);
 | |
|     const valuesToUpdate = flagsToCheck
 | |
|       .filter(flag => !isTheSame(values[flag], valuesToCheck[flag]));
 | |
|     if (!valuesToUpdate.length) {
 | |
|       return Observable.of(dedent`
 | |
|         No property in
 | |
|         ${JSON.stringify(flagsToCheck, null, 2)}
 | |
|         will introduce a change in this user.
 | |
|         `
 | |
|       )
 | |
|        .map(() => dedent`Your settings have not been updated.`);
 | |
|     }
 | |
|     return Observable.from(valuesToUpdate)
 | |
|       .flatMap(flag => Observable.of({ flag, newValue: values[flag] }))
 | |
|       .toArray()
 | |
|       .flatMap(updates => {
 | |
|         return Observable.forkJoin(
 | |
|           Observable.from(updates)
 | |
|             .flatMap(({ flag, newValue }) => {
 | |
|               return Observable.fromPromise(User.doesExist(null, this.email))
 | |
|                 .flatMap(() => this.update$({ [flag]: newValue }));
 | |
|             })
 | |
|         );
 | |
|       })
 | |
|       .doOnNext(() => this.manualReload())
 | |
|       .map(() => dedent`
 | |
|         We have successfully updated your account.
 | |
|       `);
 | |
|   };
 | |
| 
 | |
|   User.prototype.updateMyPortfolio =
 | |
|     function updateMyPortfolio(portfolioItem, deleteRequest) {
 | |
|       const currentPortfolio = this.portfolio.slice(0);
 | |
|       const pIndex = _.findIndex(
 | |
|         currentPortfolio,
 | |
|         p => p.id === portfolioItem.id
 | |
|       );
 | |
|       let updatedPortfolio = [];
 | |
|       if (deleteRequest) {
 | |
|         updatedPortfolio = currentPortfolio.filter(
 | |
|           p => p.id !== portfolioItem.id
 | |
|         );
 | |
|       } else if (pIndex === -1) {
 | |
|         updatedPortfolio = currentPortfolio.concat([ portfolioItem ]);
 | |
|       } else {
 | |
|         updatedPortfolio = [ ...currentPortfolio ];
 | |
|         updatedPortfolio[pIndex] = { ...portfolioItem };
 | |
|       }
 | |
|       return this.update$({ portfolio: updatedPortfolio })
 | |
|         .do(() => this.manualReload())
 | |
|         .map(() => dedent`
 | |
|           Your portfolio has been updated.
 | |
|         `);
 | |
|     };
 | |
| 
 | |
|   User.prototype.updateMyProjects = function updateMyProjects(project) {
 | |
|     const updateData = { $set: {} };
 | |
|     return this.getCompletedChallenges$()
 | |
|       .flatMap(() => {
 | |
|         const {
 | |
|           updated,
 | |
|           isNewCompletionCount
 | |
|         } = buildCompletedChallengesUpdate(
 | |
|           this.completedChallenges,
 | |
|           project
 | |
|         );
 | |
|         updateData.$set.completedChallenges = updated;
 | |
|         if (isNewCompletionCount) {
 | |
|           let points = [];
 | |
|           // give points a length of isNewCompletionCount
 | |
|           points[isNewCompletionCount - 1] = true;
 | |
|           updateData.$push = {};
 | |
|           updateData.$push.progressTimestamps = {
 | |
|             $each: points.map(() => Date.now())
 | |
|           };
 | |
|         }
 | |
|         return this.update$(updateData);
 | |
|       })
 | |
|       .doOnNext(() => this.manualReload() )
 | |
|       .map(() => dedent`
 | |
|         Your projects have been updated.
 | |
|       `);
 | |
|   };
 | |
| 
 | |
|   User.prototype.updateMyProfileUI = function updateMyProfileUI(profileUI) {
 | |
|     const oldUI = { ...this.profileUI };
 | |
|     const update = {
 | |
|       profileUI: {
 | |
|         ...oldUI,
 | |
|         ...profileUI
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     return this.update$(update)
 | |
|       .doOnNext(() => this.manualReload())
 | |
|       .map(() => dedent`
 | |
|         Your privacy settings have been updated.
 | |
|       `);
 | |
|   };
 | |
| 
 | |
|   User.prototype.updateMyUsername = function updateMyUsername(newUsername) {
 | |
|     return Observable.defer(
 | |
|       () => {
 | |
|         const isOwnUsername = isTheSame(newUsername, this.username);
 | |
|         if (isOwnUsername) {
 | |
|           return Observable.of(dedent`
 | |
|           ${newUsername} is already associated with this account.
 | |
|           `);
 | |
|         }
 | |
|         return Observable.fromPromise(User.doesExist(newUsername));
 | |
|       }
 | |
|     )
 | |
|     .flatMap(boolOrMessage => {
 | |
|       if (typeof boolOrMessage === 'string') {
 | |
|         return Observable.of(boolOrMessage);
 | |
|       }
 | |
|       if (boolOrMessage) {
 | |
|         return Observable.of(dedent`
 | |
|         ${newUsername} is already associated with a different account.
 | |
|         `);
 | |
|       }
 | |
| 
 | |
|       return this.update$({ username: newUsername })
 | |
|         .do(() => this.manualReload())
 | |
|         .map(() => dedent`
 | |
|         Your username has been updated successfully.
 | |
|         `);
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   function prepUserForPublish(user, profileUI) {
 | |
|     const {
 | |
|       about,
 | |
|       calendar,
 | |
|       completedChallenges,
 | |
|       isDonating,
 | |
|       location,
 | |
|       name,
 | |
|       points,
 | |
|       portfolio,
 | |
|       streak,
 | |
|       username,
 | |
|       yearsTopContributor
 | |
|     } = user;
 | |
|     const {
 | |
|       isLocked = true,
 | |
|       showAbout = false,
 | |
|       showCerts = false,
 | |
|       showDonation = false,
 | |
|       showHeatMap = false,
 | |
|       showLocation = false,
 | |
|       showName = false,
 | |
|       showPoints = false,
 | |
|       showPortfolio = false,
 | |
|       showTimeLine = false
 | |
|     } = profileUI;
 | |
| 
 | |
|     if (isLocked) {
 | |
|       return {
 | |
|         isLocked,
 | |
|         profileUI,
 | |
|         username
 | |
|       };
 | |
|     }
 | |
|     return {
 | |
|       ...user,
 | |
|       about: showAbout ? about : '',
 | |
|       calendar: showHeatMap ? calendar : {},
 | |
|       completedChallenges: showCerts && showTimeLine ? completedChallenges : [],
 | |
|       isDonating: showDonation ? isDonating : null,
 | |
|       location: showLocation ? location : '',
 | |
|       name: showName ? name : '',
 | |
|       points: showPoints ? points : null,
 | |
|       portfolio: showPortfolio ? portfolio : [],
 | |
|       streak: showHeatMap ? streak : {},
 | |
|       yearsTopContributor: yearsTopContributor
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   User.getPublicProfile = function getPublicProfile(username, cb) {
 | |
|     return User.findOne$({ where: { username }})
 | |
|       .flatMap(user => {
 | |
|         if (!user) {
 | |
|           return Observable.of({});
 | |
|         }
 | |
|         const {
 | |
|           completedChallenges,
 | |
|           progressTimestamps,
 | |
|           timezone,
 | |
|           profileUI
 | |
|         } = user;
 | |
|         const allUser = {
 | |
|           ..._.pick(user, publicUserProps),
 | |
|           isGithub: !!user.githubProfile,
 | |
|           isLinkedIn: !!user.linkedIn,
 | |
|           isTwitter: !!user.twitter,
 | |
|           isWebsite: !!user.website,
 | |
|           points: progressTimestamps.length,
 | |
|           completedChallenges,
 | |
|           ...getProgress(progressTimestamps, timezone),
 | |
|           ...normaliseUserFields(user)
 | |
|         };
 | |
| 
 | |
|         const publicUser = prepUserForPublish(allUser, profileUI);
 | |
| 
 | |
|         return Observable.of({
 | |
|           entities: {
 | |
|             user: {
 | |
|               [user.username]: {
 | |
|                 ...publicUser
 | |
|               }
 | |
|             }
 | |
|           },
 | |
|           result: user.username
 | |
|         });
 | |
|       })
 | |
|       .subscribe(
 | |
|         user => cb(null, user),
 | |
|         cb
 | |
|       );
 | |
|   };
 | |
| 
 | |
|   User.remoteMethod('getPublicProfile', {
 | |
|     accepts: {
 | |
|       arg: 'username',
 | |
|       type: 'string',
 | |
|       required: true
 | |
|     },
 | |
|     returns: [
 | |
|       {
 | |
|         arg: 'user',
 | |
|         type: 'object',
 | |
|         root: true
 | |
|       }
 | |
|     ],
 | |
|     http: {
 | |
|       path: '/get-public-profile',
 | |
|       verb: 'GET'
 | |
|     }
 | |
|   });
 | |
| 
 | |
|   User.giveBrowniePoints =
 | |
|     function giveBrowniePoints(receiver, giver, data = {}, dev = false, cb) {
 | |
|       const findUser = observeMethod(User, 'findOne');
 | |
|       if (!receiver) {
 | |
|         return nextTick(() => {
 | |
|           cb(
 | |
|             new TypeError(`receiver should be a string but got ${ receiver }`)
 | |
|           );
 | |
|         });
 | |
|       }
 | |
|       if (!giver) {
 | |
|         return nextTick(() => {
 | |
|           cb(new TypeError(`giver should be a string but got ${ giver }`));
 | |
|         });
 | |
|       }
 | |
|       let temp = moment();
 | |
|       const browniePoints = temp
 | |
|         .subtract.apply(temp, BROWNIEPOINTS_TIMEOUT)
 | |
|         .valueOf();
 | |
|       const user$ = findUser({ where: { username: receiver }});
 | |
| 
 | |
|       return user$
 | |
|         .tapOnNext((user) => {
 | |
|           if (!user) {
 | |
|             throw new Error(`could not find receiver for ${ receiver }`);
 | |
|           }
 | |
|         })
 | |
|         .flatMap(({ progressTimestamps = [] }) => {
 | |
|           return Observable.from(progressTimestamps);
 | |
|         })
 | |
|         // filter out non objects
 | |
|         .filter((timestamp) => !!timestamp || typeof timestamp === 'object')
 | |
|         // filterout timestamps older then an hour
 | |
|         .filter(({ timestamp = 0 }) => {
 | |
|           return timestamp >= browniePoints;
 | |
|         })
 | |
|         // filter out brownie points given by giver
 | |
|         .filter((browniePoint) => {
 | |
|           return browniePoint.giver === giver;
 | |
|         })
 | |
|         // no results means this is the first brownie point given by giver
 | |
|         // so return -1 to indicate receiver should receive point
 | |
|         .first({ defaultValue: -1 })
 | |
|         .flatMap((browniePointsFromGiver) => {
 | |
|           if (browniePointsFromGiver === -1) {
 | |
| 
 | |
|             return user$.flatMap((user) => {
 | |
|               user.progressTimestamps.push({
 | |
|                 giver,
 | |
|                 timestamp: Date.now(),
 | |
|                 ...data
 | |
|               });
 | |
|               return saveUser(user);
 | |
|             });
 | |
|           }
 | |
|           return Observable.throw(
 | |
|             new Error(`${ giver } already gave ${ receiver } points`)
 | |
|           );
 | |
|         })
 | |
|         .subscribe(
 | |
|           (user) => {
 | |
|             return cb(
 | |
|               null,
 | |
|               getAboutProfile(user),
 | |
|               dev ?
 | |
|                 { giver, receiver, data } :
 | |
|                 null
 | |
|             );
 | |
|           },
 | |
|           (e) => cb(e, null, dev ? { giver, receiver, data } : null),
 | |
|           () => {
 | |
|             log('brownie points assigned completed');
 | |
|           }
 | |
|         );
 | |
|     };
 | |
| 
 | |
|   User.remoteMethod(
 | |
|     'giveBrowniePoints',
 | |
|     {
 | |
|       description: 'Give this user brownie points',
 | |
|       accepts: [
 | |
|         {
 | |
|           arg: 'receiver',
 | |
|           type: 'string',
 | |
|           required: true
 | |
|         },
 | |
|         {
 | |
|           arg: 'giver',
 | |
|           type: 'string',
 | |
|           required: true
 | |
|         },
 | |
|         {
 | |
|           arg: 'data',
 | |
|           type: 'object'
 | |
|         },
 | |
|         {
 | |
|           arg: 'debug',
 | |
|           type: 'boolean'
 | |
|         }
 | |
|       ],
 | |
|       returns: [
 | |
|         {
 | |
|           arg: 'about',
 | |
|           type: 'object'
 | |
|         },
 | |
|         {
 | |
|           arg: 'debug',
 | |
|           type: 'object'
 | |
|         }
 | |
|       ],
 | |
|       http: {
 | |
|         path: '/give-brownie-points',
 | |
|         verb: 'POST'
 | |
|       }
 | |
|     }
 | |
|   );
 | |
| 
 | |
|   User.themes = themes;
 | |
| 
 | |
|   User.prototype.updateTheme = function updateTheme(theme) {
 | |
|     if (!this.constructor.themes[theme]) {
 | |
|       const err = wrapHandledError(
 | |
|         new Error('Theme is not valid.'),
 | |
|         {
 | |
|           Type: 'info',
 | |
|           message: err.message
 | |
|         }
 | |
|       );
 | |
|       return Promise.reject(err);
 | |
|     }
 | |
|     return this.update$({ theme })
 | |
|       .doOnNext(() => this.manualReload())
 | |
|       .toPromise();
 | |
|   };
 | |
| 
 | |
|   // deprecated. remove once live
 | |
|   User.remoteMethod(
 | |
|     'updateTheme',
 | |
|     {
 | |
|       description: 'updates the users chosen theme',
 | |
|       accepts: [
 | |
|         {
 | |
|           arg: 'theme',
 | |
|           type: 'string',
 | |
|           required: true
 | |
|         }
 | |
|       ],
 | |
|       returns: [
 | |
|         {
 | |
|           arg: 'status',
 | |
|           type: 'object'
 | |
|         }
 | |
|       ],
 | |
|       http: {
 | |
|         path: '/update-theme',
 | |
|         verb: 'POST'
 | |
|       }
 | |
|     }
 | |
|   );
 | |
| 
 | |
|   // user.updateTo$(updateData: Object) => Observable[Number]
 | |
|   User.prototype.update$ = function update$(updateData) {
 | |
|     const id = this.getId();
 | |
|     const updateOptions = { allowExtendedOperators: true };
 | |
|     if (
 | |
|         !updateData ||
 | |
|         typeof updateData !== 'object' ||
 | |
|         !Object.keys(updateData).length
 | |
|     ) {
 | |
|       return Observable.throw(new Error(
 | |
|         dedent`
 | |
|           updateData must be an object with at least one key,
 | |
|           but got ${updateData} with ${Object.keys(updateData).length}
 | |
|         `.split('\n').join(' ')
 | |
|       ));
 | |
|     }
 | |
|     return this.constructor.update$({ id }, updateData, updateOptions);
 | |
|   };
 | |
|   User.prototype.getPoints$ = function getPoints$() {
 | |
|     const id = this.getId();
 | |
|     const filter = {
 | |
|       where: { id },
 | |
|       fields: { progressTimestamps: true }
 | |
|     };
 | |
|     return this.constructor.findOne$(filter)
 | |
|       .map(user => {
 | |
|         this.progressTimestamps = user.progressTimestamps;
 | |
|         return user.progressTimestamps;
 | |
|       });
 | |
|   };
 | |
|   User.prototype.getCompletedChallenges$ = function getCompletedChallenges$() {
 | |
|     const id = this.getId();
 | |
|     const filter = {
 | |
|       where: { id },
 | |
|       fields: { completedChallenges: true }
 | |
|     };
 | |
|     return this.constructor.findOne$(filter)
 | |
|       .map(user => {
 | |
|         this.completedChallenges = user.completedChallenges;
 | |
|         return user.completedChallenges;
 | |
|       });
 | |
|   };
 | |
| 
 | |
|   User.getMessages = messages => Promise.resolve(messages);
 | |
| 
 | |
|   User.remoteMethod('getMessages', {
 | |
|     http: {
 | |
|       verb: 'get',
 | |
|       path: '/get-messages'
 | |
|     },
 | |
|     accepts: {
 | |
|       arg: 'messages',
 | |
|       type: 'object',
 | |
|       http: ctx => ctx.req.flash()
 | |
|     },
 | |
|     returns: [
 | |
|       {
 | |
|         arg: 'messages',
 | |
|         type: 'object',
 | |
|         root: true
 | |
|       }
 | |
|     ]
 | |
|   });
 | |
| };
 |