Files
freeCodeCamp/common/models/user.js
Berkeley Martinez 94c4c846e9 Feature(theme): add nightmode react logic
We wait to load the user before applying the theme
as we will begin aggressively caching most of the react
app routes. This means we can not depend on user data to
determine.
2016-08-06 07:24:10 -07:00

671 lines
18 KiB
JavaScript

import { Observable } from 'rx';
import uuid from 'node-uuid';
import moment from 'moment';
import dedent from 'dedent';
import debugFactory from 'debug';
import { isEmail } from 'validator';
import path from 'path';
import { saveUser, observeMethod } from '../../server/utils/rx';
import { blacklistedUsernames } from '../../server/utils/constants';
const debug = debugFactory('fcc:user:remote');
const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
const isDev = process.env.NODE_ENV !== 'production';
function getAboutProfile({
username,
githubProfile: github,
progressTimestamps = [],
bio
}) {
return {
username,
github,
browniePoints: progressTimestamps.length,
bio
};
}
function nextTick(fn) {
return process.nextTick(fn);
}
module.exports = function(User) {
// NOTE(berks): user email validation currently not needed but build in. This
// work around should let us sneak by
// see:
// https://github.com/strongloop/loopback/issues/1137#issuecomment-109200135
delete User.validations.email;
// 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.observe('before save', function({ instance: user }, next) {
if (user) {
if (user.email && !isEmail(user.email)) {
return next(new Error('Email format is not valid'));
}
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({ timestamp: Date.now() });
}
// this is workaround for preventing a server crash
// refer strongloop/loopback/#1364
if (user.password === '') {
user.password = null;
}
}
return next();
});
debug('setting up user hooks');
User.beforeRemote('confirm', function(ctx, _, next) {
if (!ctx.req.query) {
return ctx.res.redirect('/');
}
const uid = ctx.req.query.uid;
const token = ctx.req.query.token;
const redirect = ctx.req.query.redirect;
return User.findById(uid, (err, user) => {
if (err || !user) {
ctx.req.flash('error', {
msg: dedent`Oops, something went wrong, please try again later`
});
return ctx.res.redirect('/');
}
if (!user.verificationToken && !user.emailVerified) {
ctx.req.flash('info', {
msg: dedent`Looks like we have your email. But you haven't
verified it yet, please login and request a fresh verification
link.`
});
return ctx.res.redirect(redirect);
}
if (!user.verificationToken && user.emailVerified) {
ctx.req.flash('info', {
msg: dedent`Looks like you have already verified your email.
Please login to continue.`
});
return ctx.res.redirect(redirect);
}
if (user.verificationToken && user.verificationToken !== token) {
ctx.req.flash('info', {
msg: dedent`Looks like you have clicked an invalid link.
Please login and request a fresh one.`
});
return ctx.res.redirect(redirect);
}
return next();
});
});
User.afterRemote('confirm', function(ctx) {
if (!ctx.req.query) {
return ctx.res.redirect('/');
}
const redirect = ctx.req.query.redirect;
ctx.req.flash('success', {
msg: [
'Your email has been confirmed!'
]
});
return ctx.res.redirect(redirect);
});
User.beforeRemote('create', function({ req, res }, _, next) {
req.body.username = 'fcc' + uuid.v4().slice(0, 8);
if (!req.body.email) {
return next();
}
if (!isEmail(req.body.email)) {
return next(new Error('Email format is not valid'));
}
return User.doesExist(null, req.body.email)
.then(exists => {
if (!exists) {
return next();
}
req.flash('error', {
msg: dedent`
The ${req.body.email} email address is already associated with an account.
Try signing in with it here instead.
`
});
return res.redirect('/email-signin');
})
.catch(err => {
console.error(err);
req.flash('error', {
msg: 'Oops, something went wrong, please try again later'
});
return res.redirect('/email-signup');
});
});
User.on('resetPasswordRequest', function(info) {
if (!isEmail(info.email)) {
console.error(new Error('Email format is not valid'));
return null;
}
let url;
const host = User.app.get('host');
const { id: token } = info.accessToken;
if (process.env.NODE_ENV === 'development') {
const port = User.app.get('port');
url = `http://${host}:${port}/reset-password?access_token=${token}`;
} else {
url =
`http://freecodecamp.com/reset-password?access_token=${token}`;
}
// the email of the requested user
debug(info.email);
// the temp access token to allow password reset
debug(info.accessToken.id);
// requires AccessToken.belongsTo(User)
var mailOptions = {
to: info.email,
from: 'Team@freecodecamp.com',
subject: 'Password Reset Request',
text: `
Hello,\n\n
This email is confirming that you requested to
reset your password for your Free Code Camp account.
This is your email: ${ info.email }.
Go to ${ url } to reset your password.
\n
Happy Coding!
\n
`
};
return User.app.models.Email.send(mailOptions, function(err) {
if (err) { console.error(err); }
debug('email reset sent');
});
});
User.beforeRemote('login', function(ctx, notUsed, next) {
const { body } = ctx.req;
if (body && typeof body.email === 'string') {
if (!isEmail(body.email)) {
return next(new Error('Email format is not valid'));
}
body.email = body.email.toLowerCase();
}
return next();
});
User.afterRemote('login', function(ctx, accessToken, next) {
var res = ctx.res;
var req = ctx.req;
// var args = ctx.args;
var config = {
signed: !!req.signedCookies,
maxAge: accessToken.ttl
};
if (accessToken && accessToken.id) {
debug('setting cookies');
res.cookie('access_token', accessToken.id, config);
res.cookie('userId', accessToken.userId, config);
}
return req.logIn({ id: accessToken.userId.toString() }, function(err) {
if (err) { return next(err); }
debug('user logged in');
if (req.session && req.session.returnTo) {
var redirectTo = req.session.returnTo;
if (redirectTo === '/map-aside') {
redirectTo = '/map';
}
return res.redirect(redirectTo);
}
req.flash('success', { msg: 'Success! You are now logged in.' });
return res.redirect('/');
});
});
User.afterRemoteError('login', function(ctx) {
var res = ctx.res;
var req = ctx.req;
req.flash('errors', {
msg: 'Invalid username or password.'
});
return res.redirect('/email-signin');
});
User.afterRemote('logout', function(ctx, result, next) {
var res = ctx.res;
res.clearCookie('access_token');
res.clearCookie('userId');
next();
});
User.doesExist = function doesExist(username, email) {
if (!username && (!email || !isEmail(email))) {
return Promise.resolve(false);
}
debug('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;
}
debug('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(new TypeError(
`username should be a string but got ${ username }`
));
});
}
return User.findOne({ where: { username } }, (err, user) => {
if (err) {
return cb(err);
}
if (!user || user.username !== username) {
return cb(new Error(`no user found for ${ username }`));
}
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.updateEmail = function updateEmail(email) {
const fiveMinutesAgo = moment().subtract(5, 'minutes');
const lastEmailSentAt = moment(new Date(this.emailVerifyTTL || null));
const ownEmail = email === this.email;
const isWaitPeriodOver = this.emailVerifyTTL ?
lastEmailSentAt.isBefore(fiveMinutesAgo) :
true;
if (!isEmail('' + email)) {
return Observable.throw(
new Error('The submitted email not valid.')
);
}
// email is already associated and verified with this account
if (ownEmail && this.emailVerified) {
return Observable.throw(new Error(
`${email} is already associated with this account.`
));
}
if (ownEmail && !isWaitPeriodOver) {
const minutesLeft = 5 -
(moment().minutes() - lastEmailSentAt.minutes());
const timeToWait = minutesLeft ?
`${minutesLeft} minute${minutesLeft > 1 ? 's' : ''}` :
'a few seconds';
return Observable.throw(new Error(
`Please wait ${timeToWait} to resend email verification.`
));
}
return Observable.fromPromise(User.doesExist(null, email))
.flatMap(exists => {
// not associated with this account, but is associated with another
if (!ownEmail && exists) {
return Promise.reject(
new Error(`${email} is already associated with another account.`)
);
}
const emailVerified = false;
return this.update$({
email,
emailVerified,
emailVerifyTTL: new Date()
})
.do(() => {
this.email = email;
this.emailVerified = emailVerified;
this.emailVerifyTTL = new Date();
});
})
.flatMap(() => {
const mailOptions = {
type: 'email',
to: email,
from: 'Team@freecodecamp.com',
subject: 'Welcome to Free Code Camp!',
protocol: isDev ? null : 'https',
host: isDev ? 'localhost' : 'freecodecamp.com',
port: isDev ? null : 443,
template: path.join(
__dirname,
'..',
'..',
'server',
'views',
'emails',
'user-email-verify.ejs'
)
};
return this.verify(mailOptions);
})
.map(() => dedent`
Please check your email.
We sent you a link that you can click to verify your email address.
`);
};
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),
() => {
debug('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 = {
night: true,
default: true
};
User.prototype.updateTheme = function updateTheme(theme) {
if (!this.constructor.themes[theme]) {
const err = new Error(
'Theme is not valid.'
);
err.messageType = 'info';
err.userMessage = err.message;
return Promise.reject(err);
}
return this.update$({ theme })
.map({ updatedTo: theme })
.toPromise();
};
// deprecated. remove once live
User.remoteMethod(
'updateTheme',
{
isStatic: false,
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.getChallengeMap$ = function getChallengeMap$() {
const id = this.getId();
const filter = {
where: { id },
fields: { challengeMap: true }
};
return this.constructor.findOne$(filter)
.map(user => {
this.challengeMap = user.challengeMap;
return user.challengeMap;
});
};
};