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
 | 
						|
      }
 | 
						|
    ]
 | 
						|
  });
 | 
						|
};
 |