/** * * 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 _ from 'lodash'; import generate from 'nanoid/generate'; import { apiLocation } from '../../../config/env'; import { fixCompletedChallengeItem, getEncodedEmail, getWaitMessage, renderEmailChangeEmail, renderSignUpEmail, renderSignInEmail } from '../utils'; import { blacklistedUsernames } from '../../server/utils/constants.js'; import { wrapHandledError } from '../../server/utils/create-handled-error.js'; import { saveUser, observeMethod } from '../../server/utils/rx.js'; import { getEmailSender } from '../../server/utils/url-utils'; import { normaliseUserFields, getProgress, publicUserProps } from '../../server/utils/publicUserProps'; import { setAccessTokenToResponse, removeCookies } from '../../server/utils/getSetAccessToken'; 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 ensureLowerCaseString(maybeString) { return (maybeString && maybeString.toLowerCase()) || ''; } 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; } function getAboutProfile({ username, githubProfile: github, progressTimestamps = [], bio }) { return { username, github, browniePoints: progressTimestamps.length, bio }; } function nextTick(fn) { return process.nextTick(fn); } const getRandomNumber = () => Math.random(); function populateRequiredFields(user) { 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); } return; } export default function(User) { // set salt factor for passwords User.settings.saltWorkFactor = 5; // set user.rand to random number User.definition.rawProperties.rand.default = getRandomNumber; User.definition.properties.rand.default = getRandomNumber; // 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.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 user.username = 'fcc' + uuid(); populateRequiredFields(user); return Observable.fromPromise(User.doesExist(null, user.email)).do( exists => { if (exists) { throw wrapHandledError(new Error('user already exists'), { redirectTo: `${apiLocation}/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 them // we verify only if the email field is populated if (user.email && !isEmail(user.email)) { throw createEmailError(); } populateRequiredFields(user); }) .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 new Promise((resolve, reject) => user.updateAttributes( { email: user.newEmail, emailVerified: true, emailVerifyTTL: null, newEmail: null, verificationToken: null }, err => { if (err) { return reject(err); } return resolve(); } ) ); }); }; User.prototype.loginByRequest = function loginByRequest(req, res) { const { query: { emailChange } } = req; const createToken = this.createAccessToken$().do(accessToken => { if (accessToken && accessToken.id) { setAccessTokenToResponse({ accessToken }, req, res); } }); let data = { emailVerified: true, emailAuthLinkTTL: null, emailVerifyTTL: null }; if (emailChange && this.newEmail) { data = { ...data, email: this.newEmail, newEmail: null }; } const updateUser = new Promise((resolve, reject) => this.updateAttributes(data, err => { if (err) { return reject(err); } return resolve(); }) ); return Observable.combineLatest( createToken, Observable.fromPromise(updateUser), req.logIn(this), accessToken => accessToken ); }; User.afterRemote('logout', function({ req, res }, result, next) { removeCookies(req, res); 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.updateAttributes({ isDonating: true, donationEmails: [...(this.donationEmails || []), donation.email] }) ); }; function requestCompletedChallenges() { return this.getCompletedChallenges$(); } User.prototype.requestCompletedChallenges = requestCompletedChallenges; 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 = 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 }) }; const userUpdate = new Promise((resolve, reject) => this.updateAttributes({ emailAuthLinkTTL }, err => { if (err) { return reject(err); } return resolve(); }) ); return Observable.forkJoin( User.email.send$(mailOptions), Observable.fromPromise(userUpdate) ); }) .map( () => 'Check your email and click the link we sent you to confirm' + ' your new email address.' ); } User.prototype.requestAuthEmail = requestAuthEmail; function requestUpdateEmail(requestedEmail) { const newEmail = ensureLowerCaseString(requestedEmail); const currentEmail = ensureLowerCaseString(this.email); const isOwnEmail = isTheSame(newEmail, currentEmail); const isResendUpdateToSameEmail = isTheSame( newEmail, ensureLowerCaseString(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 while updating your email.'; } } User.prototype.requestUpdateEmail = requestUpdateEmail; User.prototype.requestUpdateFlags = async function requestUpdateFlags( values ) { const flagsToCheck = Object.keys(values); const valuesToCheck = _.pick({ ...this }, flagsToCheck); const flagsToUpdate = flagsToCheck.filter( flag => !isTheSame(values[flag], valuesToCheck[flag]) ); if (!flagsToUpdate.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.`); } const userUpdateData = flagsToUpdate.reduce((data, currentFlag) => { data[currentFlag] = values[currentFlag]; return data; }, {}); log(userUpdateData); const userUpdate = new Promise((resolve, reject) => this.updateAttributes(userUpdateData, err => { if (err) { return reject(err); } return resolve(); }) ); return Observable.fromPromise(userUpdate).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 }; } const userUpdate = new Promise((resolve, reject) => this.updateAttribute('portfolio', updatedPortfolio, err => { if (err) { return reject(err); } return resolve(); }) ); return Observable.fromPromise(userUpdate).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()) }; } const updatePromise = new Promise((resolve, reject) => this.updateAttributes(updateData, err => { if (err) { return reject(err); } return resolve(); }) ); return Observable.fromPromise(updatePromise); }) .map( () => dedent` Your projects have been updated. ` ); }; User.prototype.updateMyProfileUI = function updateMyProfileUI(profileUI) { const newProfileUI = { ...this.profileUI, ...profileUI }; const profileUIUpdate = new Promise((resolve, reject) => this.updateAttribute('profileUI', newProfileUI, err => { if (err) { return reject(err); } return resolve(); }) ); return Observable.fromPromise(profileUIUpdate).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. `); } const usernameUpdate = new Promise((resolve, reject) => this.updateAttribute('username', newUsername, err => { if (err) { return reject(err); } return resolve(); }) ); return Observable.fromPromise(usernameUpdate).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') // filter out timestamps older than one 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.prototype.getPoints$ = function getPoints$() { if ( Array.isArray(this.progressTimestamps) && this.progressTimestamps.length ) { return Observable.of(this.progressTimestamps); } 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$() { if ( Array.isArray(this.completedChallenges) && this.completedChallenges.length ) { return Observable.of(this.completedChallenges); } 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 } ] }); }