feat(api): decouple api from curriculum (#40703)
This commit is contained in:
committed by
GitHub
parent
f4bbe3f34c
commit
c077ffe4b9
@ -1,5 +0,0 @@
|
||||
Everything to do with the server.
|
||||
|
||||
One file that is not tracked here is `rev-manifest.json`.
|
||||
It is generated at runtime and its contents change as the contents
|
||||
of client side files change.
|
@ -1,15 +0,0 @@
|
||||
import { Observable } from 'rx';
|
||||
|
||||
export default function extendEmail(app) {
|
||||
const { AccessToken, Email } = app.models;
|
||||
Email.send$ = Observable.fromNodeCallback(Email.send, Email);
|
||||
AccessToken.findOne$ = Observable.fromNodeCallback(
|
||||
AccessToken.findOne.bind(AccessToken)
|
||||
);
|
||||
AccessToken.prototype.validate$ = Observable.fromNodeCallback(
|
||||
AccessToken.prototype.validate
|
||||
);
|
||||
AccessToken.prototype.destroy$ = Observable.fromNodeCallback(
|
||||
AccessToken.prototype.destroy
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
module.exports = function increaseListers(app) {
|
||||
// increase loopback database ODM max listeners
|
||||
// this is a EventEmitter method
|
||||
app.dataSources.db.setMaxListeners(32);
|
||||
};
|
@ -1,189 +0,0 @@
|
||||
import passport from 'passport';
|
||||
import dedent from 'dedent';
|
||||
import { check } from 'express-validator';
|
||||
import { isEmail } from 'validator';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { jwtSecret } from '../../../config/secrets';
|
||||
|
||||
import {
|
||||
createPassportCallbackAuthenticator,
|
||||
devSaveResponseAuthCookies,
|
||||
devLoginRedirect
|
||||
} from '../component-passport';
|
||||
import { ifUserRedirectTo, ifNoUserRedirectHome } from '../utils/middleware';
|
||||
import { wrapHandledError } from '../utils/create-handled-error.js';
|
||||
import { removeCookies } from '../utils/getSetAccessToken';
|
||||
import { decodeEmail } from '../../common/utils';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
const isSignUpDisabled = !!process.env.DISABLE_SIGNUP;
|
||||
if (isSignUpDisabled) {
|
||||
console.log('fcc:boot:auth - Sign up is disabled');
|
||||
}
|
||||
|
||||
const passwordlessGetValidators = [
|
||||
check('email')
|
||||
.isBase64()
|
||||
.withMessage('Email should be a base64 encoded string.'),
|
||||
check('token')
|
||||
.exists()
|
||||
.withMessage('Token should exist.')
|
||||
// based on strongloop/loopback/common/models/access-token.js#L15
|
||||
.isLength({ min: 64, max: 64 })
|
||||
.withMessage('Token is not the right length.')
|
||||
];
|
||||
|
||||
module.exports = function enableAuthentication(app) {
|
||||
// enable loopback access control authentication. see:
|
||||
// loopback.io/doc/en/lb2/Authentication-authorization-and-permissions.html
|
||||
app.enableAuth();
|
||||
const ifUserRedirect = ifUserRedirectTo();
|
||||
const ifNoUserRedirect = ifNoUserRedirectHome();
|
||||
const devSaveAuthCookies = devSaveResponseAuthCookies();
|
||||
const devLoginSuccessRedirect = devLoginRedirect();
|
||||
const api = app.loopback.Router();
|
||||
|
||||
// Use a local mock strategy for signing in if we are in dev mode.
|
||||
// Otherwise we use auth0 login. We use a string for 'true' because values
|
||||
// set in the env file will always be strings and never boolean.
|
||||
if (process.env.LOCAL_MOCK_AUTH === 'true') {
|
||||
api.get(
|
||||
'/signin',
|
||||
passport.authenticate('devlogin'),
|
||||
devSaveAuthCookies,
|
||||
devLoginSuccessRedirect
|
||||
);
|
||||
} else {
|
||||
api.get('/signin', ifUserRedirect, (req, res, next) => {
|
||||
const { returnTo, origin, pathPrefix } = getRedirectParams(req);
|
||||
const state = jwt.sign({ returnTo, origin, pathPrefix }, jwtSecret);
|
||||
return passport.authenticate('auth0-login', { state })(req, res, next);
|
||||
});
|
||||
|
||||
api.get(
|
||||
'/auth/auth0/callback',
|
||||
createPassportCallbackAuthenticator('auth0-login', { provider: 'auth0' })
|
||||
);
|
||||
}
|
||||
|
||||
api.get('/signout', (req, res) => {
|
||||
const { origin } = getRedirectParams(req);
|
||||
req.logout();
|
||||
req.session.destroy(err => {
|
||||
if (err) {
|
||||
throw wrapHandledError(new Error('could not destroy session'), {
|
||||
type: 'info',
|
||||
message: 'We could not log you out, please try again in a moment.',
|
||||
redirectTo: origin
|
||||
});
|
||||
}
|
||||
removeCookies(req, res);
|
||||
res.redirect(origin);
|
||||
});
|
||||
});
|
||||
|
||||
api.get(
|
||||
'/confirm-email',
|
||||
ifNoUserRedirect,
|
||||
passwordlessGetValidators,
|
||||
createGetPasswordlessAuth(app)
|
||||
);
|
||||
|
||||
app.use(api);
|
||||
};
|
||||
|
||||
const defaultErrorMsg = dedent`
|
||||
Oops, something is not right,
|
||||
please request a fresh link to sign in / sign up.
|
||||
`;
|
||||
|
||||
function createGetPasswordlessAuth(app) {
|
||||
const {
|
||||
models: { AuthToken, User }
|
||||
} = app;
|
||||
return function getPasswordlessAuth(req, res, next) {
|
||||
const {
|
||||
query: { email: encodedEmail, token: authTokenId, emailChange } = {}
|
||||
} = req;
|
||||
const { origin } = getRedirectParams(req);
|
||||
const email = decodeEmail(encodedEmail);
|
||||
if (!isEmail(email)) {
|
||||
return next(
|
||||
wrapHandledError(new TypeError('decoded email is invalid'), {
|
||||
type: 'info',
|
||||
message: 'The email encoded in the link is incorrectly formatted',
|
||||
redirectTo: `${origin}/signin`
|
||||
})
|
||||
);
|
||||
}
|
||||
// first find
|
||||
return (
|
||||
AuthToken.findOne$({ where: { id: authTokenId } })
|
||||
.flatMap(authToken => {
|
||||
if (!authToken) {
|
||||
throw wrapHandledError(
|
||||
new Error(`no token found for id: ${authTokenId}`),
|
||||
{
|
||||
type: 'info',
|
||||
message: defaultErrorMsg,
|
||||
redirectTo: `${origin}/signin`
|
||||
}
|
||||
);
|
||||
}
|
||||
// find user then validate and destroy email validation token
|
||||
// finally retun user instance
|
||||
return User.findOne$({ where: { id: authToken.userId } }).flatMap(
|
||||
user => {
|
||||
if (!user) {
|
||||
throw wrapHandledError(
|
||||
new Error(`no user found for token: ${authTokenId}`),
|
||||
{
|
||||
type: 'info',
|
||||
message: defaultErrorMsg,
|
||||
redirectTo: `${origin}/signin`
|
||||
}
|
||||
);
|
||||
}
|
||||
if (user.email !== email) {
|
||||
if (!emailChange || (emailChange && user.newEmail !== email)) {
|
||||
throw wrapHandledError(
|
||||
new Error('user email does not match'),
|
||||
{
|
||||
type: 'info',
|
||||
message: defaultErrorMsg,
|
||||
redirectTo: `${origin}/signin`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return authToken
|
||||
.validate$()
|
||||
.map(isValid => {
|
||||
if (!isValid) {
|
||||
throw wrapHandledError(new Error('token is invalid'), {
|
||||
type: 'info',
|
||||
message: `
|
||||
Looks like the link you clicked has expired,
|
||||
please request a fresh link, to sign in.
|
||||
`,
|
||||
redirectTo: `${origin}/signin`
|
||||
});
|
||||
}
|
||||
return authToken.destroy$();
|
||||
})
|
||||
.map(() => user);
|
||||
}
|
||||
);
|
||||
})
|
||||
// at this point token has been validated and destroyed
|
||||
// update user and log them in
|
||||
.map(user => user.loginByRequest(req, res))
|
||||
.do(() => {
|
||||
req.flash('success', 'flash.signin-success');
|
||||
return res.redirectWithFlash(`${origin}/learn`);
|
||||
})
|
||||
.subscribe(() => {}, next)
|
||||
);
|
||||
};
|
||||
}
|
@ -1,544 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import loopback from 'loopback';
|
||||
import path from 'path';
|
||||
import dedent from 'dedent';
|
||||
import { Observable } from 'rx';
|
||||
import debug from 'debug';
|
||||
import { isEmail } from 'validator';
|
||||
import { reportError } from '../middlewares/sentry-error-handler.js';
|
||||
|
||||
import { ifNoUser401 } from '../utils/middleware';
|
||||
import { observeQuery } from '../utils/rx';
|
||||
import {
|
||||
legacyFrontEndChallengeId,
|
||||
legacyBackEndChallengeId,
|
||||
legacyDataVisId,
|
||||
legacyInfosecQaId,
|
||||
legacyFullStackId,
|
||||
respWebDesignId,
|
||||
frontEndLibsId,
|
||||
jsAlgoDataStructId,
|
||||
dataVis2018Id,
|
||||
apisMicroservicesId,
|
||||
qaV7Id,
|
||||
infosecV7Id,
|
||||
sciCompPyV7Id,
|
||||
dataAnalysisPyV7Id,
|
||||
machineLearningPyV7Id
|
||||
} from '../utils/constantStrings.json';
|
||||
import { oldDataVizId } from '../../../config/misc';
|
||||
import certTypes from '../utils/certTypes.json';
|
||||
import superBlockCertTypeMap from '../utils/superBlockCertTypeMap';
|
||||
import { getChallenges } from '../utils/get-curriculum';
|
||||
|
||||
const log = debug('fcc:certification');
|
||||
|
||||
export default function bootCertificate(app, done) {
|
||||
const api = app.loopback.Router();
|
||||
// TODO: rather than getting all the challenges, then grabbing the certs,
|
||||
// consider just getting the certs.
|
||||
getChallenges().then(allChallenges => {
|
||||
const certTypeIds = createCertTypeIds(allChallenges);
|
||||
const showCert = createShowCert(app);
|
||||
const verifyCert = createVerifyCert(certTypeIds, app);
|
||||
|
||||
api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert);
|
||||
api.get('/certificate/showCert/:username/:cert', showCert);
|
||||
|
||||
app.use(api);
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
export function getFallbackFrontEndDate(completedChallenges, completedDate) {
|
||||
var chalIds = [...Object.values(certIds), oldDataVizId];
|
||||
|
||||
const latestCertDate = completedChallenges
|
||||
.filter(chal => chalIds.includes(chal.id))
|
||||
.sort((a, b) => b.completedDate - a.completedDate)[0].completedDate;
|
||||
|
||||
return latestCertDate ? latestCertDate : completedDate;
|
||||
}
|
||||
|
||||
function ifNoSuperBlock404(req, res, next) {
|
||||
const { superBlock } = req.body;
|
||||
if (superBlock && superBlocks.includes(superBlock)) {
|
||||
return next();
|
||||
}
|
||||
return res.status(404).end();
|
||||
}
|
||||
|
||||
const renderCertifiedEmail = loopback.template(
|
||||
path.join(__dirname, '..', 'views', 'emails', 'certified.ejs')
|
||||
);
|
||||
|
||||
function createCertTypeIds(allChallenges) {
|
||||
return {
|
||||
// legacy
|
||||
[certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, allChallenges),
|
||||
[certTypes.backEnd]: getCertById(legacyBackEndChallengeId, allChallenges),
|
||||
[certTypes.dataVis]: getCertById(legacyDataVisId, allChallenges),
|
||||
[certTypes.infosecQa]: getCertById(legacyInfosecQaId, allChallenges),
|
||||
[certTypes.fullStack]: getCertById(legacyFullStackId, allChallenges),
|
||||
|
||||
// modern
|
||||
[certTypes.respWebDesign]: getCertById(respWebDesignId, allChallenges),
|
||||
[certTypes.frontEndLibs]: getCertById(frontEndLibsId, allChallenges),
|
||||
[certTypes.dataVis2018]: getCertById(dataVis2018Id, allChallenges),
|
||||
[certTypes.jsAlgoDataStruct]: getCertById(
|
||||
jsAlgoDataStructId,
|
||||
allChallenges
|
||||
),
|
||||
[certTypes.apisMicroservices]: getCertById(
|
||||
apisMicroservicesId,
|
||||
allChallenges
|
||||
),
|
||||
[certTypes.qaV7]: getCertById(qaV7Id, allChallenges),
|
||||
[certTypes.infosecV7]: getCertById(infosecV7Id, allChallenges),
|
||||
[certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, allChallenges),
|
||||
[certTypes.dataAnalysisPyV7]: getCertById(
|
||||
dataAnalysisPyV7Id,
|
||||
allChallenges
|
||||
),
|
||||
[certTypes.machineLearningPyV7]: getCertById(
|
||||
machineLearningPyV7Id,
|
||||
allChallenges
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function canClaim(ids, completedChallenges = []) {
|
||||
return _.every(ids, ({ id }) =>
|
||||
_.find(completedChallenges, ({ id: completedId }) => completedId === id)
|
||||
);
|
||||
}
|
||||
|
||||
const certIds = {
|
||||
[certTypes.frontEnd]: legacyFrontEndChallengeId,
|
||||
[certTypes.backEnd]: legacyBackEndChallengeId,
|
||||
[certTypes.dataVis]: legacyDataVisId,
|
||||
[certTypes.infosecQa]: legacyInfosecQaId,
|
||||
[certTypes.fullStack]: legacyFullStackId,
|
||||
[certTypes.respWebDesign]: respWebDesignId,
|
||||
[certTypes.frontEndLibs]: frontEndLibsId,
|
||||
[certTypes.jsAlgoDataStruct]: jsAlgoDataStructId,
|
||||
[certTypes.dataVis2018]: dataVis2018Id,
|
||||
[certTypes.apisMicroservices]: apisMicroservicesId,
|
||||
[certTypes.qaV7]: qaV7Id,
|
||||
[certTypes.infosecV7]: infosecV7Id,
|
||||
[certTypes.sciCompPyV7]: sciCompPyV7Id,
|
||||
[certTypes.dataAnalysisPyV7]: dataAnalysisPyV7Id,
|
||||
[certTypes.machineLearningPyV7]: machineLearningPyV7Id
|
||||
};
|
||||
|
||||
const certText = {
|
||||
[certTypes.frontEnd]: 'Legacy Front End',
|
||||
[certTypes.backEnd]: 'Legacy Back End',
|
||||
[certTypes.dataVis]: 'Legacy Data Visualization',
|
||||
[certTypes.infosecQa]: 'Legacy Information Security and Quality Assurance',
|
||||
[certTypes.fullStack]: 'Legacy Full Stack',
|
||||
[certTypes.respWebDesign]: 'Responsive Web Design',
|
||||
[certTypes.frontEndLibs]: 'Front End Libraries',
|
||||
[certTypes.jsAlgoDataStruct]: 'JavaScript Algorithms and Data Structures',
|
||||
[certTypes.dataVis2018]: 'Data Visualization',
|
||||
[certTypes.apisMicroservices]: 'APIs and Microservices',
|
||||
[certTypes.qaV7]: 'Quality Assurance',
|
||||
[certTypes.infosecV7]: 'Information Security',
|
||||
[certTypes.sciCompPyV7]: 'Scientific Computing with Python',
|
||||
[certTypes.dataAnalysisPyV7]: 'Data Analysis with Python',
|
||||
[certTypes.machineLearningPyV7]: 'Machine Learning with Python'
|
||||
};
|
||||
|
||||
const completionHours = {
|
||||
[certTypes.frontEnd]: 400,
|
||||
[certTypes.backEnd]: 400,
|
||||
[certTypes.dataVis]: 400,
|
||||
[certTypes.infosecQa]: 300,
|
||||
[certTypes.fullStack]: 1800,
|
||||
[certTypes.respWebDesign]: 300,
|
||||
[certTypes.frontEndLibs]: 300,
|
||||
[certTypes.jsAlgoDataStruct]: 300,
|
||||
[certTypes.dataVis2018]: 300,
|
||||
[certTypes.apisMicroservices]: 300,
|
||||
[certTypes.qaV7]: 300,
|
||||
[certTypes.infosecV7]: 300,
|
||||
[certTypes.sciCompPyV7]: 300,
|
||||
[certTypes.dataAnalysisPyV7]: 300,
|
||||
[certTypes.machineLearningPyV7]: 300
|
||||
};
|
||||
|
||||
function getCertById(anId, allChallenges) {
|
||||
return allChallenges
|
||||
.filter(({ id }) => id === anId)
|
||||
.map(({ id, tests, name, challengeType }) => ({
|
||||
id,
|
||||
tests,
|
||||
name,
|
||||
challengeType
|
||||
}))[0];
|
||||
}
|
||||
|
||||
const superBlocks = Object.keys(superBlockCertTypeMap);
|
||||
|
||||
function sendCertifiedEmail(
|
||||
{
|
||||
email = '',
|
||||
name,
|
||||
username,
|
||||
isRespWebDesignCert,
|
||||
isFrontEndLibsCert,
|
||||
isJsAlgoDataStructCert,
|
||||
isDataVisCert,
|
||||
isApisMicroservicesCert,
|
||||
isQaCertV7,
|
||||
isInfosecCertV7,
|
||||
isSciCompPyCertV7,
|
||||
isDataAnalysisPyCertV7,
|
||||
isMachineLearningPyCertV7
|
||||
},
|
||||
send$
|
||||
) {
|
||||
if (
|
||||
!isEmail(email) ||
|
||||
!isRespWebDesignCert ||
|
||||
!isFrontEndLibsCert ||
|
||||
!isJsAlgoDataStructCert ||
|
||||
!isDataVisCert ||
|
||||
!isApisMicroservicesCert ||
|
||||
!isQaCertV7 ||
|
||||
!isInfosecCertV7 ||
|
||||
!isSciCompPyCertV7 ||
|
||||
!isDataAnalysisPyCertV7 ||
|
||||
!isMachineLearningPyCertV7
|
||||
) {
|
||||
return Observable.just(false);
|
||||
}
|
||||
const notifyUser = {
|
||||
type: 'email',
|
||||
to: email,
|
||||
from: 'quincy@freecodecamp.org',
|
||||
subject: dedent`
|
||||
Congratulations on completing all of the
|
||||
freeCodeCamp certifications!
|
||||
`,
|
||||
text: renderCertifiedEmail({
|
||||
username,
|
||||
name
|
||||
})
|
||||
};
|
||||
return send$(notifyUser).map(() => true);
|
||||
}
|
||||
|
||||
function getUserIsCertMap(user) {
|
||||
const {
|
||||
isRespWebDesignCert = false,
|
||||
isJsAlgoDataStructCert = false,
|
||||
isFrontEndLibsCert = false,
|
||||
is2018DataVisCert = false,
|
||||
isApisMicroservicesCert = false,
|
||||
isInfosecQaCert = false,
|
||||
isQaCertV7 = false,
|
||||
isInfosecCertV7 = false,
|
||||
isFrontEndCert = false,
|
||||
isBackEndCert = false,
|
||||
isDataVisCert = false,
|
||||
isFullStackCert = false,
|
||||
isSciCompPyCertV7 = false,
|
||||
isDataAnalysisPyCertV7 = false,
|
||||
isMachineLearningPyCertV7 = false
|
||||
} = user;
|
||||
|
||||
return {
|
||||
isRespWebDesignCert,
|
||||
isJsAlgoDataStructCert,
|
||||
isFrontEndLibsCert,
|
||||
is2018DataVisCert,
|
||||
isApisMicroservicesCert,
|
||||
isInfosecQaCert,
|
||||
isQaCertV7,
|
||||
isInfosecCertV7,
|
||||
isFrontEndCert,
|
||||
isBackEndCert,
|
||||
isDataVisCert,
|
||||
isFullStackCert,
|
||||
isSciCompPyCertV7,
|
||||
isDataAnalysisPyCertV7,
|
||||
isMachineLearningPyCertV7
|
||||
};
|
||||
}
|
||||
|
||||
function createVerifyCert(certTypeIds, app) {
|
||||
const { Email } = app.models;
|
||||
return function verifyCert(req, res, next) {
|
||||
const {
|
||||
body: { superBlock },
|
||||
user
|
||||
} = req;
|
||||
log(superBlock);
|
||||
let certType = superBlockCertTypeMap[superBlock];
|
||||
log(certType);
|
||||
return Observable.of(certTypeIds[certType])
|
||||
.flatMap(challenge => {
|
||||
const certName = certText[certType];
|
||||
if (user[certType]) {
|
||||
return Observable.just({
|
||||
type: 'info',
|
||||
message: 'flash.already-claimed',
|
||||
variables: { name: certName }
|
||||
});
|
||||
}
|
||||
|
||||
// certificate doesn't exist or
|
||||
// connection error
|
||||
if (!challenge) {
|
||||
reportError(`Error claiming ${certName}`);
|
||||
return Observable.just({
|
||||
type: 'danger',
|
||||
message: 'flash.wrong-name',
|
||||
variables: { name: certName }
|
||||
});
|
||||
}
|
||||
|
||||
const { id, tests, challengeType } = challenge;
|
||||
if (!canClaim(tests, user.completedChallenges)) {
|
||||
return Observable.just({
|
||||
type: 'info',
|
||||
message: 'flash.incomplete-steps',
|
||||
variables: { name: certName }
|
||||
});
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
[certType]: true,
|
||||
completedChallenges: [
|
||||
...user.completedChallenges,
|
||||
{
|
||||
id,
|
||||
completedDate: new Date(),
|
||||
challengeType
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (!user.name) {
|
||||
return Observable.just({
|
||||
type: 'info',
|
||||
message: 'flash.name-needed'
|
||||
});
|
||||
}
|
||||
// set here so sendCertifiedEmail works properly
|
||||
// not used otherwise
|
||||
user[certType] = true;
|
||||
const updatePromise = new Promise((resolve, reject) =>
|
||||
user.updateAttributes(updateData, err => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve();
|
||||
})
|
||||
);
|
||||
return Observable.combineLatest(
|
||||
// update user data
|
||||
Observable.fromPromise(updatePromise),
|
||||
// sends notification email is user has all 6 certs
|
||||
// if not it noop
|
||||
sendCertifiedEmail(user, Email.send$),
|
||||
(_, pledgeOrMessage) => ({ pledgeOrMessage })
|
||||
).map(({ pledgeOrMessage }) => {
|
||||
if (typeof pledgeOrMessage === 'string') {
|
||||
log(pledgeOrMessage);
|
||||
}
|
||||
log('Certificates updated');
|
||||
return {
|
||||
type: 'success',
|
||||
message: 'flash.cert-claim-success',
|
||||
variables: {
|
||||
username: user.username,
|
||||
name: certName
|
||||
}
|
||||
};
|
||||
});
|
||||
})
|
||||
.subscribe(message => {
|
||||
return res.status(200).json({
|
||||
response: message,
|
||||
isCertMap: getUserIsCertMap(user),
|
||||
// send back the completed challenges
|
||||
// NOTE: we could just send back the latest challenge, but this
|
||||
// ensures the challenges are synced.
|
||||
completedChallenges: user.completedChallenges
|
||||
});
|
||||
}, next);
|
||||
};
|
||||
}
|
||||
|
||||
function createShowCert(app) {
|
||||
const { User } = app.models;
|
||||
|
||||
function findUserByUsername$(username, fields) {
|
||||
return observeQuery(User, 'findOne', {
|
||||
where: { username },
|
||||
fields
|
||||
});
|
||||
}
|
||||
|
||||
return function showCert(req, res, next) {
|
||||
let { username, cert } = req.params;
|
||||
username = username.toLowerCase();
|
||||
const certType = superBlockCertTypeMap[cert];
|
||||
const certId = certIds[certType];
|
||||
const certTitle = certText[certType];
|
||||
const completionTime = completionHours[certType] || 300;
|
||||
return findUserByUsername$(username, {
|
||||
isCheater: true,
|
||||
isFrontEndCert: true,
|
||||
isBackEndCert: true,
|
||||
isFullStackCert: true,
|
||||
isRespWebDesignCert: true,
|
||||
isFrontEndLibsCert: true,
|
||||
isJsAlgoDataStructCert: true,
|
||||
isDataVisCert: true,
|
||||
is2018DataVisCert: true,
|
||||
isApisMicroservicesCert: true,
|
||||
isInfosecQaCert: true,
|
||||
isQaCertV7: true,
|
||||
isInfosecCertV7: true,
|
||||
isSciCompPyCertV7: true,
|
||||
isDataAnalysisPyCertV7: true,
|
||||
isMachineLearningPyCertV7: true,
|
||||
isHonest: true,
|
||||
username: true,
|
||||
name: true,
|
||||
completedChallenges: true,
|
||||
profileUI: true
|
||||
}).subscribe(user => {
|
||||
if (!user) {
|
||||
return res.json({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.username-not-found',
|
||||
variables: { username: username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
const { isLocked, showCerts, showName } = user.profileUI;
|
||||
|
||||
if (!user.name) {
|
||||
return res.json({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.add-name'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isCheater) {
|
||||
return res.json({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.not-eligible'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
return res.json({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.profile-private',
|
||||
variables: { username: username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (!showCerts) {
|
||||
return res.json({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.certs-private',
|
||||
variables: { username: username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.isHonest) {
|
||||
return res.json({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.not-honest',
|
||||
variables: { username: username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (user[certType]) {
|
||||
const { completedChallenges = [] } = user;
|
||||
const certChallenge = _.find(
|
||||
completedChallenges,
|
||||
({ id }) => certId === id
|
||||
);
|
||||
let { completedDate = new Date() } = certChallenge || {};
|
||||
|
||||
// the challenge id has been rotated for isDataVisCert
|
||||
if (certType === 'isDataVisCert' && !certChallenge) {
|
||||
let oldDataVisIdChall = _.find(
|
||||
completedChallenges,
|
||||
({ id }) => oldDataVizId === id
|
||||
);
|
||||
|
||||
if (oldDataVisIdChall) {
|
||||
completedDate = oldDataVisIdChall.completedDate || completedDate;
|
||||
}
|
||||
}
|
||||
|
||||
// if fullcert is not found, return the latest completedDate
|
||||
if (certType === 'isFullStackCert' && !certChallenge) {
|
||||
completedDate = getFallbackFrontEndDate(
|
||||
completedChallenges,
|
||||
completedDate
|
||||
);
|
||||
}
|
||||
|
||||
const { username, name } = user;
|
||||
|
||||
if (!showName) {
|
||||
return res.json({
|
||||
certTitle,
|
||||
username,
|
||||
date: completedDate,
|
||||
completionTime
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
certTitle,
|
||||
username,
|
||||
name,
|
||||
date: completedDate,
|
||||
completionTime
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.user-not-certified',
|
||||
variables: { username: username, cert: certText[certType] }
|
||||
}
|
||||
]
|
||||
});
|
||||
}, next);
|
||||
};
|
||||
}
|
@ -1,362 +0,0 @@
|
||||
/**
|
||||
*
|
||||
* Any ref to fixCompletedChallengesItem should be removed post
|
||||
* a db migration to fix all completedChallenges
|
||||
*
|
||||
*/
|
||||
import { Observable } from 'rx';
|
||||
import { isEmpty, pick, omit, find, uniqBy } from 'lodash';
|
||||
import debug from 'debug';
|
||||
import dedent from 'dedent';
|
||||
import { ObjectID } from 'mongodb';
|
||||
import isNumeric from 'validator/lib/isNumeric';
|
||||
import isURL from 'validator/lib/isURL';
|
||||
|
||||
import { ifNoUserSend } from '../utils/middleware';
|
||||
import { dasherize } from '../../../utils/slugs';
|
||||
import { fixCompletedChallengeItem } from '../../common/utils';
|
||||
import { getChallenges } from '../utils/get-curriculum';
|
||||
import {
|
||||
getRedirectParams,
|
||||
getRedirectBase,
|
||||
normalizeParams
|
||||
} from '../utils/redirection';
|
||||
|
||||
const log = debug('fcc:boot:challenges');
|
||||
|
||||
export default async function bootChallenge(app, done) {
|
||||
const send200toNonUser = ifNoUserSend(true);
|
||||
const api = app.loopback.Router();
|
||||
const router = app.loopback.Router();
|
||||
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||
await getChallenges()
|
||||
);
|
||||
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
||||
challengeUrlResolver,
|
||||
normalizeParams,
|
||||
getRedirectParams
|
||||
);
|
||||
|
||||
api.post(
|
||||
'/modern-challenge-completed',
|
||||
send200toNonUser,
|
||||
isValidChallengeCompletion,
|
||||
modernChallengeCompleted
|
||||
);
|
||||
|
||||
api.post(
|
||||
'/project-completed',
|
||||
send200toNonUser,
|
||||
isValidChallengeCompletion,
|
||||
projectCompleted
|
||||
);
|
||||
|
||||
api.post(
|
||||
'/backend-challenge-completed',
|
||||
send200toNonUser,
|
||||
isValidChallengeCompletion,
|
||||
backendChallengeCompleted
|
||||
);
|
||||
|
||||
router.get('/challenges/current-challenge', redirectToCurrentChallenge);
|
||||
|
||||
app.use(api);
|
||||
app.use(router);
|
||||
done();
|
||||
}
|
||||
|
||||
const jsProjects = [
|
||||
'aaa48de84e1ecc7c742e1124',
|
||||
'a7f4d8f2483413a6ce226cac',
|
||||
'56533eb9ac21ba0edf2244e2',
|
||||
'aff0395860f5d3034dc0bfc9',
|
||||
'aa2e6f85cab2ab736c9a9b24'
|
||||
];
|
||||
|
||||
export function buildUserUpdate(
|
||||
user,
|
||||
challengeId,
|
||||
_completedChallenge,
|
||||
timezone
|
||||
) {
|
||||
const { files } = _completedChallenge;
|
||||
let completedChallenge = {};
|
||||
if (jsProjects.includes(challengeId)) {
|
||||
completedChallenge = {
|
||||
..._completedChallenge,
|
||||
files: Object.keys(files)
|
||||
.map(key => files[key])
|
||||
.map(file =>
|
||||
pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext'])
|
||||
)
|
||||
};
|
||||
} else {
|
||||
completedChallenge = omit(_completedChallenge, ['files']);
|
||||
}
|
||||
let finalChallenge;
|
||||
const updateData = {};
|
||||
const { timezone: userTimezone, completedChallenges = [] } = user;
|
||||
|
||||
const oldChallenge = find(
|
||||
completedChallenges,
|
||||
({ id }) => challengeId === id
|
||||
);
|
||||
const alreadyCompleted = !!oldChallenge;
|
||||
|
||||
if (alreadyCompleted) {
|
||||
finalChallenge = {
|
||||
...completedChallenge,
|
||||
completedDate: oldChallenge.completedDate
|
||||
};
|
||||
} else {
|
||||
updateData.$push = {
|
||||
...updateData.$push,
|
||||
progressTimestamps: Date.now()
|
||||
};
|
||||
finalChallenge = {
|
||||
...completedChallenge
|
||||
};
|
||||
}
|
||||
|
||||
updateData.$set = {
|
||||
completedChallenges: uniqBy(
|
||||
[finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)],
|
||||
'id'
|
||||
)
|
||||
};
|
||||
|
||||
if (
|
||||
timezone &&
|
||||
timezone !== 'UTC' &&
|
||||
(!userTimezone || userTimezone === 'UTC')
|
||||
) {
|
||||
updateData.$set = {
|
||||
...updateData.$set,
|
||||
timezone: userTimezone
|
||||
};
|
||||
}
|
||||
return {
|
||||
alreadyCompleted,
|
||||
updateData,
|
||||
completedDate: finalChallenge.completedDate
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChallengeUrl(challenge) {
|
||||
const { superBlock, block, dashedName } = challenge;
|
||||
return `/learn/${dasherize(superBlock)}/${dasherize(block)}/${dashedName}`;
|
||||
}
|
||||
|
||||
// this is only called once during boot, so it can be slow.
|
||||
export function getFirstChallenge(allChallenges) {
|
||||
const first = allChallenges.find(
|
||||
({ challengeOrder, superOrder, order }) =>
|
||||
challengeOrder === 0 && superOrder === 1 && order === 0
|
||||
);
|
||||
|
||||
return first ? buildChallengeUrl(first) : '/learn';
|
||||
}
|
||||
|
||||
function getChallengeById(allChallenges, targetId) {
|
||||
return allChallenges.find(({ id }) => id === targetId);
|
||||
}
|
||||
|
||||
export async function createChallengeUrlResolver(
|
||||
allChallenges,
|
||||
{ _getFirstChallenge = getFirstChallenge } = {}
|
||||
) {
|
||||
const cache = new Map();
|
||||
const firstChallenge = _getFirstChallenge(allChallenges);
|
||||
|
||||
return function resolveChallengeUrl(id) {
|
||||
if (isEmpty(id)) {
|
||||
return Promise.resolve(firstChallenge);
|
||||
} else {
|
||||
return new Promise(resolve => {
|
||||
if (cache.has(id)) {
|
||||
resolve(cache.get(id));
|
||||
}
|
||||
|
||||
const challenge = getChallengeById(allChallenges, id);
|
||||
if (isEmpty(challenge)) {
|
||||
resolve(firstChallenge);
|
||||
} else {
|
||||
const challengeUrl = buildChallengeUrl(challenge);
|
||||
cache.set(id, challengeUrl);
|
||||
resolve(challengeUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidChallengeCompletion(req, res, next) {
|
||||
const {
|
||||
body: { id, challengeType, solution }
|
||||
} = req;
|
||||
|
||||
const isValidChallengeCompletionErrorMsg = {
|
||||
type: 'error',
|
||||
message: 'That does not appear to be a valid challenge submission.'
|
||||
};
|
||||
|
||||
if (!ObjectID.isValid(id)) {
|
||||
log('isObjectId', id, ObjectID.isValid(id));
|
||||
return res.status(403).json(isValidChallengeCompletionErrorMsg);
|
||||
}
|
||||
if ('challengeType' in req.body && !isNumeric(String(challengeType))) {
|
||||
log('challengeType', challengeType, isNumeric(challengeType));
|
||||
return res.status(403).json(isValidChallengeCompletionErrorMsg);
|
||||
}
|
||||
if ('solution' in req.body && !isURL(solution)) {
|
||||
log('isObjectId', id, ObjectID.isValid(id));
|
||||
return res.status(403).json(isValidChallengeCompletionErrorMsg);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
export function modernChallengeCompleted(req, res, next) {
|
||||
const user = req.user;
|
||||
return user
|
||||
.getCompletedChallenges$()
|
||||
.flatMap(() => {
|
||||
const completedDate = Date.now();
|
||||
const { id, files } = req.body;
|
||||
|
||||
const { alreadyCompleted, updateData } = buildUserUpdate(user, id, {
|
||||
id,
|
||||
files,
|
||||
completedDate
|
||||
});
|
||||
|
||||
const points = alreadyCompleted ? user.points : user.points + 1;
|
||||
const updatePromise = new Promise((resolve, reject) =>
|
||||
user.updateAttributes(updateData, err => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve();
|
||||
})
|
||||
);
|
||||
return Observable.fromPromise(updatePromise).map(() => {
|
||||
return res.json({
|
||||
points,
|
||||
alreadyCompleted,
|
||||
completedDate
|
||||
});
|
||||
});
|
||||
})
|
||||
.subscribe(() => {}, next);
|
||||
}
|
||||
|
||||
function projectCompleted(req, res, next) {
|
||||
const { user, body = {} } = req;
|
||||
|
||||
const completedChallenge = pick(body, [
|
||||
'id',
|
||||
'solution',
|
||||
'githubLink',
|
||||
'challengeType',
|
||||
'files'
|
||||
]);
|
||||
completedChallenge.completedDate = Date.now();
|
||||
|
||||
if (!completedChallenge.solution) {
|
||||
return res.status(403).json({
|
||||
type: 'error',
|
||||
message:
|
||||
'You have not provided the valid links for us to inspect your work.'
|
||||
});
|
||||
}
|
||||
|
||||
return user
|
||||
.getCompletedChallenges$()
|
||||
.flatMap(() => {
|
||||
const { alreadyCompleted, updateData } = buildUserUpdate(
|
||||
user,
|
||||
completedChallenge.id,
|
||||
completedChallenge
|
||||
);
|
||||
|
||||
const updatePromise = new Promise((resolve, reject) =>
|
||||
user.updateAttributes(updateData, err => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve();
|
||||
})
|
||||
);
|
||||
return Observable.fromPromise(updatePromise).doOnNext(() => {
|
||||
return res.send({
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? user.points : user.points + 1,
|
||||
completedDate: completedChallenge.completedDate
|
||||
});
|
||||
});
|
||||
})
|
||||
.subscribe(() => {}, next);
|
||||
}
|
||||
|
||||
function backendChallengeCompleted(req, res, next) {
|
||||
const { user, body = {} } = req;
|
||||
|
||||
const completedChallenge = pick(body, ['id', 'solution']);
|
||||
completedChallenge.completedDate = Date.now();
|
||||
|
||||
return user
|
||||
.getCompletedChallenges$()
|
||||
.flatMap(() => {
|
||||
const { alreadyCompleted, updateData } = buildUserUpdate(
|
||||
user,
|
||||
completedChallenge.id,
|
||||
completedChallenge
|
||||
);
|
||||
|
||||
const updatePromise = new Promise((resolve, reject) =>
|
||||
user.updateAttributes(updateData, err => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve();
|
||||
})
|
||||
);
|
||||
return Observable.fromPromise(updatePromise).doOnNext(() => {
|
||||
return res.send({
|
||||
alreadyCompleted,
|
||||
points: alreadyCompleted ? user.points : user.points + 1,
|
||||
completedDate: completedChallenge.completedDate
|
||||
});
|
||||
});
|
||||
})
|
||||
.subscribe(() => {}, next);
|
||||
}
|
||||
|
||||
// TODO: extend tests to cover www.freecodecamp.org/language and
|
||||
// chinese.freecodecamp.org
|
||||
export function createRedirectToCurrentChallenge(
|
||||
challengeUrlResolver,
|
||||
normalizeParams,
|
||||
getRedirectParams
|
||||
) {
|
||||
return async function redirectToCurrentChallenge(req, res, next) {
|
||||
const { user } = req;
|
||||
const { origin, pathPrefix } = getRedirectParams(req, normalizeParams);
|
||||
|
||||
const redirectBase = getRedirectBase(origin, pathPrefix);
|
||||
if (!user) {
|
||||
return res.redirect(redirectBase + '/learn');
|
||||
}
|
||||
|
||||
const challengeId = user && user.currentChallengeId;
|
||||
const challengeUrl = await challengeUrlResolver(challengeId).catch(next);
|
||||
if (challengeUrl === '/learn') {
|
||||
// this should normally not be hit if database is properly seeded
|
||||
throw new Error(dedent`
|
||||
Attempted to find the url for ${challengeId || 'Unknown ID'}'
|
||||
but came up empty.
|
||||
db may not be properly seeded.
|
||||
`);
|
||||
}
|
||||
return res.redirect(`${redirectBase}${challengeUrl}`);
|
||||
};
|
||||
}
|
@ -1,294 +0,0 @@
|
||||
import Stripe from 'stripe';
|
||||
import debug from 'debug';
|
||||
import { isEmail, isNumeric } from 'validator';
|
||||
|
||||
import {
|
||||
getAsyncPaypalToken,
|
||||
verifyWebHook,
|
||||
updateUser,
|
||||
verifyWebHookType
|
||||
} from '../utils/donation';
|
||||
import {
|
||||
durationKeysConfig,
|
||||
donationOneTimeConfig,
|
||||
donationSubscriptionConfig
|
||||
} from '../../../config/donation-settings';
|
||||
import keys from '../../../config/secrets';
|
||||
|
||||
const log = debug('fcc:boot:donate');
|
||||
|
||||
export default function donateBoot(app, done) {
|
||||
let stripe = false;
|
||||
const { User } = app.models;
|
||||
const api = app.loopback.Router();
|
||||
const hooks = app.loopback.Router();
|
||||
const donateRouter = app.loopback.Router();
|
||||
|
||||
const subscriptionPlans = Object.keys(
|
||||
donationSubscriptionConfig.plans
|
||||
).reduce(
|
||||
(prevDuration, duration) =>
|
||||
prevDuration.concat(
|
||||
donationSubscriptionConfig.plans[duration].reduce(
|
||||
(prevAmount, amount) =>
|
||||
prevAmount.concat({
|
||||
amount: amount,
|
||||
interval: duration,
|
||||
product: {
|
||||
name: `${
|
||||
donationSubscriptionConfig.duration[duration]
|
||||
} Donation to freeCodeCamp.org - Thank you ($${amount / 100})`,
|
||||
metadata: {
|
||||
/* eslint-disable camelcase */
|
||||
sb_service: `freeCodeCamp.org`,
|
||||
sb_tier: `${
|
||||
donationSubscriptionConfig.duration[duration]
|
||||
} $${amount / 100} Donation`
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
},
|
||||
currency: 'usd',
|
||||
id: `${donationSubscriptionConfig.duration[
|
||||
duration
|
||||
].toLowerCase()}-donation-${amount}`
|
||||
}),
|
||||
[]
|
||||
)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
function validStripeForm(amount, duration, email) {
|
||||
return isEmail('' + email) &&
|
||||
isNumeric('' + amount) &&
|
||||
durationKeysConfig.includes(duration) &&
|
||||
duration === 'onetime'
|
||||
? donationOneTimeConfig.includes(amount)
|
||||
: donationSubscriptionConfig.plans[duration];
|
||||
}
|
||||
|
||||
function connectToStripe() {
|
||||
return new Promise(function(resolve) {
|
||||
// connect to stripe API
|
||||
stripe = Stripe(keys.stripe.secret);
|
||||
// parse stripe plans
|
||||
stripe.plans.list({}, function(err, stripePlans) {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
const requiredPlans = subscriptionPlans.map(plan => plan.id);
|
||||
const availablePlans = stripePlans.data.map(plan => plan.id);
|
||||
if (process.env.STRIPE_CREATE_PLANS === 'true') {
|
||||
requiredPlans.forEach(requiredPlan => {
|
||||
if (!availablePlans.includes(requiredPlan)) {
|
||||
createStripePlan(
|
||||
subscriptionPlans.find(plan => plan.id === requiredPlan)
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log(`Skipping plan creation`);
|
||||
}
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function createStripePlan(plan) {
|
||||
log(`Creating subscription plan: ${plan.product.name}`);
|
||||
stripe.plans.create(plan, function(err) {
|
||||
if (err) {
|
||||
log(err);
|
||||
}
|
||||
log(`Created plan with plan id: ${plan.id}`);
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
function createStripeDonation(req, res) {
|
||||
const { user, body } = req;
|
||||
|
||||
const {
|
||||
amount,
|
||||
duration,
|
||||
token: { email, id }
|
||||
} = body;
|
||||
|
||||
if (!validStripeForm(amount, duration, email)) {
|
||||
return res.status(500).send({
|
||||
error: 'The donation form had invalid values for this submission.'
|
||||
});
|
||||
}
|
||||
|
||||
const fccUser = user
|
||||
? Promise.resolve(user)
|
||||
: new Promise((resolve, reject) =>
|
||||
User.findOrCreate(
|
||||
{ where: { email } },
|
||||
{ email },
|
||||
(err, instance, isNew) => {
|
||||
log('createing a new donating user instance: ', isNew);
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(instance);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
let donatingUser = {};
|
||||
let donation = {
|
||||
email,
|
||||
amount,
|
||||
duration,
|
||||
provider: 'stripe',
|
||||
startDate: new Date(Date.now()).toISOString()
|
||||
};
|
||||
|
||||
const createCustomer = user => {
|
||||
donatingUser = user;
|
||||
return stripe.customers.create({
|
||||
email,
|
||||
card: id
|
||||
});
|
||||
};
|
||||
|
||||
const createSubscription = customer => {
|
||||
donation.customerId = customer.id;
|
||||
return stripe.subscriptions.create({
|
||||
customer: customer.id,
|
||||
items: [
|
||||
{
|
||||
plan: `${donationSubscriptionConfig.duration[
|
||||
duration
|
||||
].toLowerCase()}-donation-${amount}`
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const createOneTimeCharge = customer => {
|
||||
donation.customerId = customer.id;
|
||||
return stripe.charges.create({
|
||||
amount: amount,
|
||||
currency: 'usd',
|
||||
customer: customer.id
|
||||
});
|
||||
};
|
||||
|
||||
const createAsyncUserDonation = () => {
|
||||
donatingUser
|
||||
.createDonation(donation)
|
||||
.toPromise()
|
||||
.catch(err => {
|
||||
throw new Error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return Promise.resolve(fccUser)
|
||||
.then(nonDonatingUser => {
|
||||
const { isDonating } = nonDonatingUser;
|
||||
if (isDonating && duration !== 'onetime') {
|
||||
throw {
|
||||
message: `User already has active recurring donation(s).`,
|
||||
type: 'AlreadyDonatingError'
|
||||
};
|
||||
}
|
||||
return nonDonatingUser;
|
||||
})
|
||||
.then(createCustomer)
|
||||
.then(customer => {
|
||||
return duration === 'onetime'
|
||||
? createOneTimeCharge(customer).then(charge => {
|
||||
donation.subscriptionId = 'one-time-charge-prefix-' + charge.id;
|
||||
return res.send(charge);
|
||||
})
|
||||
: createSubscription(customer).then(subscription => {
|
||||
donation.subscriptionId = subscription.id;
|
||||
return res.send(subscription);
|
||||
});
|
||||
})
|
||||
.then(createAsyncUserDonation)
|
||||
.catch(err => {
|
||||
if (
|
||||
err.type === 'StripeCardError' ||
|
||||
err.type === 'AlreadyDonatingError'
|
||||
) {
|
||||
return res.status(402).send({ error: err.message });
|
||||
}
|
||||
return res
|
||||
.status(500)
|
||||
.send({ error: 'Donation failed due to a server error.' });
|
||||
});
|
||||
}
|
||||
|
||||
function addDonation(req, res) {
|
||||
const { user, body } = req;
|
||||
|
||||
if (!user || !body) {
|
||||
return res
|
||||
.status(500)
|
||||
.send({ error: 'User must be signed in for this request.' });
|
||||
}
|
||||
return Promise.resolve(req)
|
||||
.then(
|
||||
user.updateAttributes({
|
||||
isDonating: true
|
||||
})
|
||||
)
|
||||
.then(() => res.status(200).json({ isDonating: true }))
|
||||
.catch(err => {
|
||||
log(err.message);
|
||||
return res.status(500).send({
|
||||
type: 'danger',
|
||||
message: 'Something went wrong.'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updatePaypal(req, res) {
|
||||
const { headers, body } = req;
|
||||
return Promise.resolve(req)
|
||||
.then(verifyWebHookType)
|
||||
.then(getAsyncPaypalToken)
|
||||
.then(token => verifyWebHook(headers, body, token, keys.paypal.webhookId))
|
||||
.then(hookBody => updateUser(hookBody, app))
|
||||
.catch(err => {
|
||||
// Todo: This probably need to be thrown and caught in error handler
|
||||
log(err.message);
|
||||
})
|
||||
.finally(() => res.status(200).json({ message: 'received paypal hook' }));
|
||||
}
|
||||
|
||||
const stripeKey = keys.stripe.public;
|
||||
const secKey = keys.stripe.secret;
|
||||
const paypalKey = keys.paypal.client;
|
||||
const paypalSec = keys.paypal.secret;
|
||||
const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
|
||||
const stripPublicInvalid =
|
||||
!stripeKey || stripeKey === 'pk_from_stripe_dashboard';
|
||||
|
||||
const paypalSecretInvalid =
|
||||
!paypalKey || paypalKey === 'id_from_paypal_dashboard';
|
||||
const paypalPublicInvalid =
|
||||
!paypalSec || paypalSec === 'secret_from_paypal_dashboard';
|
||||
|
||||
const paypalInvalid = paypalPublicInvalid || paypalSecretInvalid;
|
||||
const stripeInvalid = stripeSecretInvalid || stripPublicInvalid;
|
||||
|
||||
if (stripeInvalid || paypalInvalid) {
|
||||
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
|
||||
throw new Error('Donation API keys are required to boot the server!');
|
||||
}
|
||||
log('Donation disabled in development unless ALL test keys are provided');
|
||||
done();
|
||||
} else {
|
||||
api.post('/charge-stripe', createStripeDonation);
|
||||
api.post('/add-donation', addDonation);
|
||||
hooks.post('/update-paypal', updatePaypal);
|
||||
donateRouter.use('/donate', api);
|
||||
donateRouter.use('/hooks', hooks);
|
||||
app.use(donateRouter);
|
||||
connectToStripe().then(done);
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
const createDebugger = require('debug');
|
||||
|
||||
const log = createDebugger('fcc:boot:explorer');
|
||||
|
||||
module.exports = function mountLoopBackExplorer(app) {
|
||||
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
|
||||
return;
|
||||
}
|
||||
let explorer;
|
||||
try {
|
||||
explorer = require('loopback-component-explorer');
|
||||
} catch (err) {
|
||||
// Print the message only when the app was started via `app.listen()`.
|
||||
// Do not print any message when the project is used as a component.
|
||||
app.once('started', function() {
|
||||
log(
|
||||
'Run `npm install loopback-component-explorer` to enable ' +
|
||||
'the LoopBack explorer'
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const restApiRoot = app.get('restApiRoot');
|
||||
const mountPath = '/explorer';
|
||||
|
||||
explorer(app, { basePath: restApiRoot, mountPath });
|
||||
app.once('started', function() {
|
||||
const baseUrl = app.get('url').replace(/\/$/, '');
|
||||
|
||||
log('Browse your REST API at %s%s', baseUrl, mountPath);
|
||||
});
|
||||
};
|
@ -1,41 +0,0 @@
|
||||
import debug from 'debug';
|
||||
|
||||
const log = debug('fcc:boot:news');
|
||||
|
||||
export default function newsBoot(app) {
|
||||
const router = app.loopback.Router();
|
||||
|
||||
router.get('/n', (req, res) => res.redirect('/news'));
|
||||
router.get('/n/:shortId', createShortLinkHandler(app));
|
||||
}
|
||||
|
||||
function createShortLinkHandler(app) {
|
||||
const { Article } = app.models;
|
||||
|
||||
return function shortLinkHandler(req, res, next) {
|
||||
const { shortId } = req.params;
|
||||
|
||||
if (!shortId) {
|
||||
return res.redirect('/news');
|
||||
}
|
||||
log('shortId', shortId);
|
||||
return Article.findOne(
|
||||
{
|
||||
where: {
|
||||
or: [{ shortId }, { slugPart: shortId }]
|
||||
}
|
||||
},
|
||||
(err, article) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
}
|
||||
if (!article) {
|
||||
return res.redirect('/news');
|
||||
}
|
||||
const { slugPart } = article;
|
||||
const slug = `/news/${slugPart}`;
|
||||
return res.redirect(slug);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
@ -1,205 +0,0 @@
|
||||
import request from 'request';
|
||||
|
||||
import constantStrings from '../utils/constantStrings.json';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
const githubClient = process.env.GITHUB_ID;
|
||||
const githubSecret = process.env.GITHUB_SECRET;
|
||||
|
||||
module.exports = function(app) {
|
||||
const router = app.loopback.Router();
|
||||
const User = app.models.User;
|
||||
|
||||
router.get('/api/github', githubCalls);
|
||||
router.get('/u/:email', unsubscribeDeprecated);
|
||||
router.get('/unsubscribe/:email', unsubscribeDeprecated);
|
||||
router.get('/ue/:unsubscribeId', unsubscribeById);
|
||||
router.get(
|
||||
'/the-fastest-web-page-on-the-internet',
|
||||
theFastestWebPageOnTheInternet
|
||||
);
|
||||
router.get('/unsubscribed/:unsubscribeId', unsubscribedWithId);
|
||||
router.get('/unsubscribed', unsubscribed);
|
||||
router.get('/resubscribe/:unsubscribeId', resubscribe);
|
||||
router.get('/nonprofits', nonprofits);
|
||||
router.get('/coding-bootcamp-cost-calculator', bootcampCalculator);
|
||||
|
||||
app.use(router);
|
||||
|
||||
function theFastestWebPageOnTheInternet(req, res) {
|
||||
res.render('resources/the-fastest-web-page-on-the-internet', {
|
||||
title: 'This is the fastest web page on the internet'
|
||||
});
|
||||
}
|
||||
|
||||
function bootcampCalculator(req, res) {
|
||||
res.render('resources/calculator', {
|
||||
title: 'Coding Bootcamp Cost Calculator'
|
||||
});
|
||||
}
|
||||
|
||||
function nonprofits(req, res) {
|
||||
res.render('resources/nonprofits', {
|
||||
title: 'Your Nonprofit Can Get Pro Bono Code'
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribeDeprecated(req, res) {
|
||||
req.flash(
|
||||
'info',
|
||||
'We are no longer able to process this unsubscription request. ' +
|
||||
'Please go to your settings to update your email preferences'
|
||||
);
|
||||
const { origin } = getRedirectParams(req);
|
||||
res.redirectWithFlash(origin);
|
||||
}
|
||||
|
||||
function unsubscribeById(req, res, next) {
|
||||
const { origin } = getRedirectParams(req);
|
||||
const { unsubscribeId } = req.params;
|
||||
if (!unsubscribeId) {
|
||||
req.flash('info', 'We could not find an account to unsubscribe');
|
||||
return res.redirectWithFlash(origin);
|
||||
}
|
||||
return User.find({ where: { unsubscribeId } }, (err, users) => {
|
||||
if (err || !users.length) {
|
||||
req.flash('info', 'We could not find an account to unsubscribe');
|
||||
return res.redirectWithFlash(origin);
|
||||
}
|
||||
const updates = users.map(user => {
|
||||
return new Promise((resolve, reject) =>
|
||||
user.updateAttributes(
|
||||
{
|
||||
sendQuincyEmail: false
|
||||
},
|
||||
err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
return Promise.all(updates)
|
||||
.then(() => {
|
||||
req.flash(
|
||||
'success',
|
||||
"We've successfully updated your email preferences."
|
||||
);
|
||||
return res.redirectWithFlash(
|
||||
`${origin}/unsubscribed/${unsubscribeId}`
|
||||
);
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribed(req, res) {
|
||||
res.render('resources/unsubscribed', {
|
||||
title: 'You have been unsubscribed'
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribedWithId(req, res) {
|
||||
const { unsubscribeId } = req.params;
|
||||
return res.render('resources/unsubscribed', {
|
||||
title: 'You have been unsubscribed',
|
||||
unsubscribeId
|
||||
});
|
||||
}
|
||||
|
||||
function resubscribe(req, res, next) {
|
||||
const { unsubscribeId } = req.params;
|
||||
const { origin } = getRedirectParams(req);
|
||||
if (!unsubscribeId) {
|
||||
req.flash(
|
||||
'info',
|
||||
'We we unable to process this request, please check and try againÍ'
|
||||
);
|
||||
res.redirect(origin);
|
||||
}
|
||||
return User.find({ where: { unsubscribeId } }, (err, users) => {
|
||||
if (err || !users.length) {
|
||||
req.flash('info', 'We could not find an account to resubscribe');
|
||||
return res.redirectWithFlash(origin);
|
||||
}
|
||||
const [user] = users;
|
||||
return new Promise((resolve, reject) =>
|
||||
user.updateAttributes(
|
||||
{
|
||||
sendQuincyEmail: true
|
||||
},
|
||||
err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
req.flash(
|
||||
'success',
|
||||
"We've successfully updated your email preferences. Thank you " +
|
||||
'for resubscribing.'
|
||||
);
|
||||
return res.redirectWithFlash(origin);
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
|
||||
function githubCalls(req, res, next) {
|
||||
var githubHeaders = {
|
||||
headers: {
|
||||
'User-Agent': constantStrings.gitHubUserAgent
|
||||
},
|
||||
port: 80
|
||||
};
|
||||
request(
|
||||
[
|
||||
'https://api.github.com/repos/freecodecamp/',
|
||||
'freecodecamp/pulls?client_id=',
|
||||
githubClient,
|
||||
'&client_secret=',
|
||||
githubSecret
|
||||
].join(''),
|
||||
githubHeaders,
|
||||
function(err, status1, pulls) {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
pulls = pulls
|
||||
? Object.keys(JSON.parse(pulls)).length
|
||||
: "Can't connect to github";
|
||||
|
||||
return request(
|
||||
[
|
||||
'https://api.github.com/repos/freecodecamp/',
|
||||
'freecodecamp/issues?client_id=',
|
||||
githubClient,
|
||||
'&client_secret=',
|
||||
githubSecret
|
||||
].join(''),
|
||||
githubHeaders,
|
||||
function(err, status2, issues) {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
issues =
|
||||
pulls === parseInt(pulls, 10) && issues
|
||||
? Object.keys(JSON.parse(issues)).length - pulls
|
||||
: "Can't connect to GitHub";
|
||||
return res.send({
|
||||
issues: issues,
|
||||
pulls: pulls
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
module.exports = function mountRestApi(app) {
|
||||
const restApi = app.loopback.rest();
|
||||
const restApiRoot = app.get('restApiRoot');
|
||||
app.use(restApiRoot, restApi);
|
||||
};
|
@ -1,18 +0,0 @@
|
||||
import { wrapHandledError } from '../utils/create-handled-error';
|
||||
|
||||
export default function bootStatus(app) {
|
||||
const api = app.loopback.Router();
|
||||
|
||||
// DEBUG ROUTE
|
||||
api.get('/sentry/error', () => {
|
||||
throw Error('debugging sentry');
|
||||
});
|
||||
api.get('/sentry/wrapped', () => {
|
||||
throw wrapHandledError(Error('debugging sentry, wrapped'), {
|
||||
type: 'info',
|
||||
message: 'debugmessage',
|
||||
redirectTo: `a/page`
|
||||
});
|
||||
});
|
||||
app.use(api);
|
||||
}
|
@ -1,255 +0,0 @@
|
||||
import debug from 'debug';
|
||||
import { check } from 'express-validator';
|
||||
|
||||
import { ifNoUser401, createValidatorErrorHandler } from '../utils/middleware';
|
||||
import { themes } from '../../common/utils/themes.js';
|
||||
import { alertTypes } from '../../common/utils/flash.js';
|
||||
import { isValidUsername } from '../../../utils/validate';
|
||||
|
||||
const log = debug('fcc:boot:settings');
|
||||
|
||||
export default function settingsController(app) {
|
||||
const api = app.loopback.Router();
|
||||
|
||||
const updateMyUsername = createUpdateMyUsername(app);
|
||||
|
||||
api.put('/update-privacy-terms', ifNoUser401, updatePrivacyTerms);
|
||||
|
||||
api.post(
|
||||
'/refetch-user-completed-challenges',
|
||||
ifNoUser401,
|
||||
refetchCompletedChallenges
|
||||
);
|
||||
api.post(
|
||||
'/update-my-current-challenge',
|
||||
ifNoUser401,
|
||||
updateMyCurrentChallengeValidators,
|
||||
createValidatorErrorHandler(alertTypes.danger),
|
||||
updateMyCurrentChallenge
|
||||
);
|
||||
api.post('/update-my-portfolio', ifNoUser401, updateMyPortfolio);
|
||||
api.post('/update-my-projects', ifNoUser401, updateMyProjects);
|
||||
api.post(
|
||||
'/update-my-theme',
|
||||
ifNoUser401,
|
||||
updateMyThemeValidators,
|
||||
createValidatorErrorHandler(alertTypes.danger),
|
||||
updateMyTheme
|
||||
);
|
||||
api.put('/update-my-about', ifNoUser401, updateMyAbout);
|
||||
api.put(
|
||||
'/update-my-email',
|
||||
ifNoUser401,
|
||||
updateMyEmailValidators,
|
||||
createValidatorErrorHandler(alertTypes.danger),
|
||||
updateMyEmail
|
||||
);
|
||||
api.put('/update-my-profileui', ifNoUser401, updateMyProfileUI);
|
||||
api.put('/update-my-username', ifNoUser401, updateMyUsername);
|
||||
api.put('/update-user-flag', ifNoUser401, updateUserFlag);
|
||||
|
||||
app.use(api);
|
||||
}
|
||||
|
||||
const standardErrorMessage = {
|
||||
type: 'danger',
|
||||
message: 'flash.wrong-updating'
|
||||
};
|
||||
|
||||
const standardSuccessMessage = {
|
||||
type: 'success',
|
||||
message: 'flash.updated-preferences'
|
||||
};
|
||||
|
||||
const createStandardHandler = (req, res, next) => err => {
|
||||
if (err) {
|
||||
res.status(500).json(standardErrorMessage);
|
||||
return next(err);
|
||||
}
|
||||
return res.status(200).json(standardSuccessMessage);
|
||||
};
|
||||
|
||||
function refetchCompletedChallenges(req, res, next) {
|
||||
const { user } = req;
|
||||
return user
|
||||
.requestCompletedChallenges()
|
||||
.subscribe(completedChallenges => res.json({ completedChallenges }), next);
|
||||
}
|
||||
|
||||
const updateMyEmailValidators = [
|
||||
check('email')
|
||||
.isEmail()
|
||||
.withMessage('Email format is invalid.')
|
||||
];
|
||||
|
||||
function updateMyEmail(req, res, next) {
|
||||
const {
|
||||
user,
|
||||
body: { email }
|
||||
} = req;
|
||||
return user
|
||||
.requestUpdateEmail(email)
|
||||
.subscribe(message => res.json({ message }), next);
|
||||
}
|
||||
|
||||
const updateMyCurrentChallengeValidators = [
|
||||
check('currentChallengeId')
|
||||
.isMongoId()
|
||||
.withMessage('currentChallengeId is not a valid challenge ID')
|
||||
];
|
||||
|
||||
function updateMyCurrentChallenge(req, res, next) {
|
||||
const {
|
||||
user,
|
||||
body: { currentChallengeId }
|
||||
} = req;
|
||||
return user.updateAttribute(
|
||||
'currentChallengeId',
|
||||
currentChallengeId,
|
||||
(err, updatedUser) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
const { currentChallengeId } = updatedUser;
|
||||
return res.status(200).json(currentChallengeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const updateMyThemeValidators = [
|
||||
check('theme')
|
||||
.isIn(Object.keys(themes))
|
||||
.withMessage('Theme is invalid.')
|
||||
];
|
||||
|
||||
function updateMyTheme(req, res, next) {
|
||||
const {
|
||||
body: { theme }
|
||||
} = req;
|
||||
if (req.user.theme === theme) {
|
||||
return res.sendFlash(alertTypes.info, 'Theme already set');
|
||||
}
|
||||
return req.user
|
||||
.updateTheme(theme)
|
||||
.then(
|
||||
() => res.sendFlash(alertTypes.info, 'Your theme has been updated!'),
|
||||
next
|
||||
);
|
||||
}
|
||||
|
||||
function updateMyPortfolio(req, res, next) {
|
||||
const {
|
||||
user,
|
||||
body: { portfolio }
|
||||
} = req;
|
||||
// if we only have one key, it should be the id
|
||||
// user cannot send only one key to this route
|
||||
// other than to remove a portfolio item
|
||||
const requestDelete = Object.keys(portfolio).length === 1;
|
||||
return user
|
||||
.updateMyPortfolio(portfolio, requestDelete)
|
||||
.subscribe(message => res.json({ message }), next);
|
||||
}
|
||||
|
||||
function updateMyProfileUI(req, res, next) {
|
||||
const {
|
||||
user,
|
||||
body: { profileUI }
|
||||
} = req;
|
||||
user.updateAttribute(
|
||||
'profileUI',
|
||||
profileUI,
|
||||
createStandardHandler(req, res, next)
|
||||
);
|
||||
}
|
||||
|
||||
function updateMyProjects(req, res, next) {
|
||||
const {
|
||||
user,
|
||||
body: { projects: project }
|
||||
} = req;
|
||||
return user
|
||||
.updateMyProjects(project)
|
||||
.subscribe(message => res.json({ message }), next);
|
||||
}
|
||||
|
||||
function updateMyAbout(req, res, next) {
|
||||
const {
|
||||
user,
|
||||
body: { name, location, about, picture }
|
||||
} = req;
|
||||
log(name, location, picture, about);
|
||||
return user.updateAttributes(
|
||||
{ name, location, about, picture },
|
||||
createStandardHandler(req, res, next)
|
||||
);
|
||||
}
|
||||
|
||||
function createUpdateMyUsername(app) {
|
||||
const { User } = app.models;
|
||||
return async function updateMyUsername(req, res, next) {
|
||||
const {
|
||||
user,
|
||||
body: { username }
|
||||
} = req;
|
||||
if (username === user.username) {
|
||||
return res.json({
|
||||
type: 'info',
|
||||
message: 'flash.username-used'
|
||||
});
|
||||
}
|
||||
const validation = isValidUsername(username);
|
||||
|
||||
if (!validation.valid) {
|
||||
return res.json({
|
||||
type: 'info',
|
||||
message: `Username ${username} ${validation.error}`
|
||||
});
|
||||
}
|
||||
|
||||
const exists = await User.doesExist(username);
|
||||
|
||||
if (exists) {
|
||||
return res.json({
|
||||
type: 'info',
|
||||
message: 'flash.username-taken'
|
||||
});
|
||||
}
|
||||
|
||||
return user.updateAttribute('username', username, err => {
|
||||
if (err) {
|
||||
res.status(500).json(standardErrorMessage);
|
||||
return next(err);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
type: 'success',
|
||||
message: `flash.username-updated`,
|
||||
variables: { username: username }
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const updatePrivacyTerms = (req, res, next) => {
|
||||
const {
|
||||
user,
|
||||
body: { quincyEmails }
|
||||
} = req;
|
||||
const update = {
|
||||
acceptedPrivacyTerms: true,
|
||||
sendQuincyEmail: !!quincyEmails
|
||||
};
|
||||
return user.updateAttributes(update, err => {
|
||||
if (err) {
|
||||
res.status(500).json(standardErrorMessage);
|
||||
return next(err);
|
||||
}
|
||||
return res.status(200).json(standardSuccessMessage);
|
||||
});
|
||||
};
|
||||
|
||||
function updateUserFlag(req, res, next) {
|
||||
const { user, body: update } = req;
|
||||
return user.updateAttributes(update, createStandardHandler(req, res, next));
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export default function bootStatus(app) {
|
||||
const api = app.loopback.Router();
|
||||
|
||||
api.get('/status/ping', (req, res) => res.json({ msg: 'pong' }));
|
||||
app.use(api);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
module.exports = function(app) {
|
||||
var router = app.loopback.Router();
|
||||
router.get('/wiki/*', showForum);
|
||||
|
||||
app.use(router);
|
||||
|
||||
function showForum(req, res) {
|
||||
res.redirect('http://forum.freecodecamp.org/');
|
||||
}
|
||||
};
|
@ -1,257 +0,0 @@
|
||||
import dedent from 'dedent';
|
||||
import debugFactory from 'debug';
|
||||
import { pick } from 'lodash';
|
||||
import { Observable } from 'rx';
|
||||
import { body } from 'express-validator';
|
||||
|
||||
import {
|
||||
getProgress,
|
||||
normaliseUserFields,
|
||||
userPropsForSession
|
||||
} from '../utils/publicUserProps';
|
||||
import { fixCompletedChallengeItem } from '../../common/utils';
|
||||
import { ifNoUser401, ifNoUserRedirectHome } from '../utils/middleware';
|
||||
import { removeCookies } from '../utils/getSetAccessToken';
|
||||
import { trimTags } from '../utils/validators';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
const log = debugFactory('fcc:boot:user');
|
||||
const sendNonUserToHome = ifNoUserRedirectHome();
|
||||
|
||||
function bootUser(app) {
|
||||
const api = app.loopback.Router();
|
||||
|
||||
const getSessionUser = createReadSessionUser(app);
|
||||
const postReportUserProfile = createPostReportUserProfile(app);
|
||||
const postDeleteAccount = createPostDeleteAccount(app);
|
||||
|
||||
api.get('/account', sendNonUserToHome, getAccount);
|
||||
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
|
||||
api.get('/user/get-session-user', getSessionUser);
|
||||
|
||||
api.post('/account/delete', ifNoUser401, postDeleteAccount);
|
||||
api.post('/account/reset-progress', ifNoUser401, postResetProgress);
|
||||
api.post(
|
||||
'/user/report-user/',
|
||||
ifNoUser401,
|
||||
body('reportDescription').customSanitizer(trimTags),
|
||||
postReportUserProfile
|
||||
);
|
||||
|
||||
app.use(api);
|
||||
}
|
||||
|
||||
function createReadSessionUser(app) {
|
||||
const { Donation } = app.models;
|
||||
|
||||
return function getSessionUser(req, res, next) {
|
||||
const queryUser = req.user;
|
||||
const source =
|
||||
queryUser &&
|
||||
Observable.forkJoin(
|
||||
queryUser.getCompletedChallenges$(),
|
||||
queryUser.getPoints$(),
|
||||
Donation.getCurrentActiveDonationCount$(),
|
||||
(completedChallenges, progressTimestamps, activeDonations) => ({
|
||||
activeDonations,
|
||||
completedChallenges,
|
||||
progress: getProgress(progressTimestamps, queryUser.timezone)
|
||||
})
|
||||
);
|
||||
Observable.if(
|
||||
() => !queryUser,
|
||||
Observable.of({ user: {}, result: '' }),
|
||||
Observable.defer(() => source)
|
||||
.map(({ activeDonations, completedChallenges, progress }) => ({
|
||||
user: {
|
||||
...queryUser.toJSON(),
|
||||
...progress,
|
||||
completedChallenges: completedChallenges.map(
|
||||
fixCompletedChallengeItem
|
||||
)
|
||||
},
|
||||
sessionMeta: { activeDonations }
|
||||
}))
|
||||
.map(({ user, sessionMeta }) => ({
|
||||
user: {
|
||||
[user.username]: {
|
||||
...pick(user, userPropsForSession),
|
||||
isEmailVerified: !!user.emailVerified,
|
||||
isGithub: !!user.githubProfile,
|
||||
isLinkedIn: !!user.linkedin,
|
||||
isTwitter: !!user.twitter,
|
||||
isWebsite: !!user.website,
|
||||
...normaliseUserFields(user),
|
||||
joinDate: user.id.getTimestamp()
|
||||
}
|
||||
},
|
||||
sessionMeta,
|
||||
result: user.username
|
||||
}))
|
||||
).subscribe(user => res.json(user), next);
|
||||
};
|
||||
}
|
||||
|
||||
function getAccount(req, res) {
|
||||
const { username } = req.user;
|
||||
return res.redirect('/' + username);
|
||||
}
|
||||
|
||||
function getUnlinkSocial(req, res, next) {
|
||||
const { user } = req;
|
||||
const { username } = user;
|
||||
const { origin } = getRedirectParams(req);
|
||||
let social = req.params.social;
|
||||
if (!social) {
|
||||
req.flash('danger', 'No social account found');
|
||||
return res.redirect('/' + username);
|
||||
}
|
||||
|
||||
social = social.toLowerCase();
|
||||
const validSocialAccounts = ['twitter', 'linkedin'];
|
||||
if (validSocialAccounts.indexOf(social) === -1) {
|
||||
req.flash('danger', 'Invalid social account');
|
||||
return res.redirect('/' + username);
|
||||
}
|
||||
|
||||
if (!user[social]) {
|
||||
req.flash('danger', `No ${social} account associated`);
|
||||
return res.redirect('/' + username);
|
||||
}
|
||||
|
||||
const query = {
|
||||
where: {
|
||||
provider: social
|
||||
}
|
||||
};
|
||||
|
||||
return user.identities(query, function(err, identities) {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// assumed user identity is unique by provider
|
||||
let identity = identities.shift();
|
||||
if (!identity) {
|
||||
req.flash('danger', 'No social account found');
|
||||
return res.redirect('/' + username);
|
||||
}
|
||||
|
||||
return identity.destroy(function(err) {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
const updateData = { [social]: null };
|
||||
|
||||
return user.updateAttributes(updateData, err => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
log(`${social} has been unlinked successfully`);
|
||||
|
||||
req.flash('info', `You've successfully unlinked your ${social}.`);
|
||||
return res.redirectWithFlash(`${origin}/${username}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function postResetProgress(req, res, next) {
|
||||
const { user } = req;
|
||||
return user.updateAttributes(
|
||||
{
|
||||
progressTimestamps: [Date.now()],
|
||||
currentChallengeId: '',
|
||||
isRespWebDesignCert: false,
|
||||
is2018DataVisCert: false,
|
||||
isFrontEndLibsCert: false,
|
||||
isJsAlgoDataStructCert: false,
|
||||
isApisMicroservicesCert: false,
|
||||
isInfosecQaCert: false,
|
||||
isQaCertV7: false,
|
||||
isInfosecCertV7: false,
|
||||
is2018FullStackCert: false,
|
||||
isFrontEndCert: false,
|
||||
isBackEndCert: false,
|
||||
isDataVisCert: false,
|
||||
isFullStackCert: false,
|
||||
isSciCompPyCertV7: false,
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isMachineLearningPyCertV7: false,
|
||||
completedChallenges: []
|
||||
},
|
||||
function(err) {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createPostDeleteAccount(app) {
|
||||
const { User } = app.models;
|
||||
return function postDeleteAccount(req, res, next) {
|
||||
return User.destroyById(req.user.id, function(err) {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
req.logout();
|
||||
removeCookies(req, res);
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createPostReportUserProfile(app) {
|
||||
const { Email } = app.models;
|
||||
return function postReportUserProfile(req, res, next) {
|
||||
const { user } = req;
|
||||
const { username, reportDescription: report } = req.body;
|
||||
const { origin } = getRedirectParams(req);
|
||||
log(username);
|
||||
log(report);
|
||||
|
||||
if (!username || !report || report === '') {
|
||||
return res.json({
|
||||
type: 'danger',
|
||||
message: 'flash.provide-username'
|
||||
});
|
||||
}
|
||||
return Email.send$(
|
||||
{
|
||||
type: 'email',
|
||||
to: 'support@freecodecamp.org',
|
||||
cc: user.email,
|
||||
from: 'team@freecodecamp.org',
|
||||
subject: `Abuse Report : Reporting ${username}'s profile.`,
|
||||
text: dedent(`
|
||||
Hello Team,\n
|
||||
This is to report the profile of ${username}.\n
|
||||
Report Details:\n
|
||||
${report}\n\n
|
||||
Reported by:
|
||||
Username: ${user.username}
|
||||
Name: ${user.name}
|
||||
Email: ${user.email}\n
|
||||
Thanks and regards,
|
||||
${user.name}
|
||||
`)
|
||||
},
|
||||
err => {
|
||||
if (err) {
|
||||
err.redirectTo = `${origin}/${username}`;
|
||||
return next(err);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
type: 'info',
|
||||
message: 'flash.report-sent',
|
||||
variables: { email: user.email }
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
export default bootUser;
|
@ -1,23 +0,0 @@
|
||||
import accepts from 'accepts';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
export default function fourOhFour(app) {
|
||||
app.all('*', function(req, res) {
|
||||
const accept = accepts(req);
|
||||
const type = accept.type('html', 'json', 'text');
|
||||
const { path } = req;
|
||||
const { origin } = getRedirectParams(req);
|
||||
|
||||
if (type === 'html') {
|
||||
req.flash('danger', `We couldn't find path ${path}`);
|
||||
return res.redirectWithFlash(`${origin}/404`);
|
||||
}
|
||||
|
||||
if (type === 'json') {
|
||||
return res.status('404').json({ error: 'path not found' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
return res.send('404 path not found');
|
||||
});
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
## Test scripts for the boot directory
|
||||
|
||||
These files cannot be co-located with the files under test due to the auto-discovery the loopback-boot employs.
|
@ -1,12 +0,0 @@
|
||||
/* global it expect */
|
||||
|
||||
import { getFallbackFrontEndDate } from '../boot/certificate';
|
||||
import { fullStackChallenges } from './fixtures';
|
||||
|
||||
describe('boot/certificate', () => {
|
||||
describe('getFallbackFrontEndDate', () => {
|
||||
it('should return the date of the latest completed challenge', () => {
|
||||
expect(getFallbackFrontEndDate(fullStackChallenges)).toBe(1685210952511);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,404 +0,0 @@
|
||||
/* global describe xdescribe it expect */
|
||||
import { first, find } from 'lodash';
|
||||
import sinon from 'sinon';
|
||||
import { mockReq, mockRes } from 'sinon-express-mock';
|
||||
|
||||
import {
|
||||
buildUserUpdate,
|
||||
buildChallengeUrl,
|
||||
createChallengeUrlResolver,
|
||||
createRedirectToCurrentChallenge,
|
||||
getFirstChallenge,
|
||||
isValidChallengeCompletion
|
||||
} from '../boot/challenge';
|
||||
|
||||
import {
|
||||
firstChallengeUrl,
|
||||
requestedChallengeUrl,
|
||||
mockAllChallenges,
|
||||
mockChallenge,
|
||||
mockUser,
|
||||
mockGetFirstChallenge,
|
||||
mockCompletedChallenge,
|
||||
mockCompletedChallenges
|
||||
} from './fixtures';
|
||||
|
||||
describe('boot/challenge', () => {
|
||||
xdescribe('backendChallengeCompleted', () => {});
|
||||
|
||||
describe('buildUserUpdate', () => {
|
||||
it('returns an Object with a nested "completedChallenges" property', () => {
|
||||
const result = buildUserUpdate(
|
||||
mockUser,
|
||||
'123abc',
|
||||
mockCompletedChallenge,
|
||||
'UTC'
|
||||
);
|
||||
expect(result).toHaveProperty('updateData.$set.completedChallenges');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it('preserves file contents if the completed challenge is a JS Project', () => {
|
||||
const jsChallengeId = 'aa2e6f85cab2ab736c9a9b24';
|
||||
const completedChallenge = {
|
||||
...mockCompletedChallenge,
|
||||
completedDate: Date.now(),
|
||||
id: jsChallengeId
|
||||
};
|
||||
const result = buildUserUpdate(
|
||||
mockUser,
|
||||
jsChallengeId,
|
||||
completedChallenge,
|
||||
'UTC'
|
||||
);
|
||||
const firstCompletedChallenge = first(
|
||||
result.updateData.$set.completedChallenges
|
||||
);
|
||||
|
||||
expect(firstCompletedChallenge).toEqual(completedChallenge);
|
||||
});
|
||||
|
||||
it('preserves the original completed date of a challenge', () => {
|
||||
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
|
||||
const completedChallenge = {
|
||||
...mockCompletedChallenge,
|
||||
completedDate: Date.now(),
|
||||
id: completedChallengeId
|
||||
};
|
||||
const originalCompletion = find(
|
||||
mockCompletedChallenges,
|
||||
x => x.id === completedChallengeId
|
||||
).completedDate;
|
||||
const result = buildUserUpdate(
|
||||
mockUser,
|
||||
completedChallengeId,
|
||||
completedChallenge,
|
||||
'UTC'
|
||||
);
|
||||
|
||||
const firstCompletedChallenge = first(
|
||||
result.updateData.$set.completedChallenges
|
||||
);
|
||||
|
||||
expect(firstCompletedChallenge.completedDate).toEqual(originalCompletion);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it('does not attempt to update progressTimestamps for a previously completed challenge', () => {
|
||||
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
|
||||
const completedChallenge = {
|
||||
...mockCompletedChallenge,
|
||||
completedDate: Date.now(),
|
||||
id: completedChallengeId
|
||||
};
|
||||
const { updateData } = buildUserUpdate(
|
||||
mockUser,
|
||||
completedChallengeId,
|
||||
completedChallenge,
|
||||
'UTC'
|
||||
);
|
||||
|
||||
const hasProgressTimestamps =
|
||||
'$push' in updateData && 'progressTimestamps' in updateData.$push;
|
||||
expect(hasProgressTimestamps).toBe(false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it('provides a progressTimestamps update for new challenge completion', () => {
|
||||
expect.assertions(2);
|
||||
const { updateData } = buildUserUpdate(
|
||||
mockUser,
|
||||
'123abc',
|
||||
mockCompletedChallenge,
|
||||
'UTC'
|
||||
);
|
||||
expect(updateData).toHaveProperty('$push');
|
||||
expect(updateData.$push).toHaveProperty('progressTimestamps');
|
||||
});
|
||||
|
||||
it('removes repeat completions from the completedChallenges array', () => {
|
||||
const completedChallengeId = 'aaa48de84e1ecc7c742e1124';
|
||||
const completedChallenge = {
|
||||
...mockCompletedChallenge,
|
||||
completedDate: Date.now(),
|
||||
id: completedChallengeId
|
||||
};
|
||||
const {
|
||||
updateData: {
|
||||
$set: { completedChallenges }
|
||||
}
|
||||
} = buildUserUpdate(
|
||||
mockUser,
|
||||
completedChallengeId,
|
||||
completedChallenge,
|
||||
'UTC'
|
||||
);
|
||||
|
||||
expect(completedChallenges.length).toEqual(
|
||||
mockCompletedChallenges.length
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it('adds newly completed challenges to the completedChallenges array', () => {
|
||||
const {
|
||||
updateData: {
|
||||
$set: { completedChallenges }
|
||||
}
|
||||
} = buildUserUpdate(mockUser, '123abc', mockCompletedChallenge, 'UTC');
|
||||
|
||||
expect(completedChallenges.length).toEqual(
|
||||
mockCompletedChallenges.length + 1
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChallengeUrl', () => {
|
||||
it('resolves the correct Url for the provided challenge', () => {
|
||||
const result = buildChallengeUrl(mockChallenge);
|
||||
|
||||
expect(result).toEqual(requestedChallengeUrl);
|
||||
});
|
||||
|
||||
it('can handle non-url-compliant challenge names', () => {
|
||||
const challenge = { ...mockChallenge, superBlock: 'my awesome' };
|
||||
const expected = '/learn/my-awesome/actual/challenge';
|
||||
const result = buildChallengeUrl(challenge);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('challengeUrlResolver', () => {
|
||||
it('resolves to the first challenge url by default', async () => {
|
||||
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||
mockAllChallenges,
|
||||
{
|
||||
_getFirstChallenge: mockGetFirstChallenge
|
||||
}
|
||||
);
|
||||
|
||||
return challengeUrlResolver().then(url => {
|
||||
expect(url).toEqual(firstChallengeUrl);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it('returns the first challenge url if the provided id does not relate to a challenge', async () => {
|
||||
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||
mockAllChallenges,
|
||||
{
|
||||
_getFirstChallenge: mockGetFirstChallenge
|
||||
}
|
||||
);
|
||||
|
||||
return challengeUrlResolver('not-a-real-challenge').then(url => {
|
||||
expect(url).toEqual(firstChallengeUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the correct url for the requested challenge', async () => {
|
||||
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||
mockAllChallenges,
|
||||
{
|
||||
_getFirstChallenge: mockGetFirstChallenge
|
||||
}
|
||||
);
|
||||
|
||||
return challengeUrlResolver('123abc').then(url => {
|
||||
expect(url).toEqual(requestedChallengeUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFirstChallenge', () => {
|
||||
it('returns the correct challenge url from the model', async () => {
|
||||
const result = await getFirstChallenge(mockAllChallenges);
|
||||
|
||||
expect(result).toEqual(firstChallengeUrl);
|
||||
});
|
||||
|
||||
it('returns the learn base if no challenges found', async () => {
|
||||
const result = await getFirstChallenge([]);
|
||||
|
||||
expect(result).toEqual('/learn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidChallengeCompletion', () => {
|
||||
const validObjectId = '5c716d1801013c3ce3aa23e6';
|
||||
|
||||
it('declares a 403 for an invalid id in the body', () => {
|
||||
expect.assertions(3);
|
||||
const req = mockReq({
|
||||
body: { id: 'not-a-real-id' }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
isValidChallengeCompletion(req, res, next);
|
||||
|
||||
expect(res.status.called).toBe(true);
|
||||
expect(res.status.getCall(0).args[0]).toBe(403);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('declares a 403 for an invalid challengeType in the body', () => {
|
||||
expect.assertions(3);
|
||||
const req = mockReq({
|
||||
body: { id: validObjectId, challengeType: 'ponyfoo' }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
isValidChallengeCompletion(req, res, next);
|
||||
|
||||
expect(res.status.called).toBe(true);
|
||||
expect(res.status.getCall(0).args[0]).toBe(403);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('declares a 403 for an invalid solution in the body', () => {
|
||||
expect.assertions(3);
|
||||
const req = mockReq({
|
||||
body: {
|
||||
id: validObjectId,
|
||||
challengeType: '1',
|
||||
solution: 'https://not-a-url'
|
||||
}
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
isValidChallengeCompletion(req, res, next);
|
||||
|
||||
expect(res.status.called).toBe(true);
|
||||
expect(res.status.getCall(0).args[0]).toBe(403);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('calls next if the body is valid', () => {
|
||||
const req = mockReq({
|
||||
body: {
|
||||
id: validObjectId,
|
||||
challengeType: '1',
|
||||
solution: 'https://www.freecodecamp.org'
|
||||
}
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
isValidChallengeCompletion(req, res, next);
|
||||
|
||||
expect(next.called).toBe(true);
|
||||
});
|
||||
|
||||
it('calls next if only the id is provided', () => {
|
||||
const req = mockReq({
|
||||
body: {
|
||||
id: validObjectId
|
||||
}
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
isValidChallengeCompletion(req, res, next);
|
||||
|
||||
expect(next.called).toBe(true);
|
||||
});
|
||||
|
||||
it('can handle an "int" challengeType', () => {
|
||||
const req = mockReq({
|
||||
body: {
|
||||
id: validObjectId,
|
||||
challengeType: 1
|
||||
}
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
isValidChallengeCompletion(req, res, next);
|
||||
|
||||
expect(next.called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
xdescribe('modernChallengeCompleted', () => {});
|
||||
|
||||
xdescribe('projectCompleted', () => {});
|
||||
|
||||
describe('redirectToCurrentChallenge', () => {
|
||||
const mockHomeLocation = 'https://www.example.com';
|
||||
const mockLearnUrl = `${mockHomeLocation}/learn`;
|
||||
const mockgetParamsFromReq = () => ({
|
||||
returnTo: mockLearnUrl,
|
||||
origin: mockHomeLocation,
|
||||
pathPrefix: ''
|
||||
});
|
||||
const mockNormalizeParams = params => params;
|
||||
|
||||
it('redirects to the learn base url for non-users', async done => {
|
||||
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
||||
() => {},
|
||||
mockNormalizeParams,
|
||||
mockgetParamsFromReq
|
||||
);
|
||||
const req = mockReq();
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await redirectToCurrentChallenge(req, res, next);
|
||||
|
||||
expect(res.redirect.calledWith(mockLearnUrl));
|
||||
done();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it('redirects to the url provided by the challengeUrlResolver', async done => {
|
||||
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||
mockAllChallenges,
|
||||
{
|
||||
_getFirstChallenge: mockGetFirstChallenge
|
||||
}
|
||||
);
|
||||
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
|
||||
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
||||
challengeUrlResolver,
|
||||
mockNormalizeParams,
|
||||
mockgetParamsFromReq
|
||||
);
|
||||
const req = mockReq({
|
||||
user: mockUser
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await redirectToCurrentChallenge(req, res, next);
|
||||
|
||||
expect(res.redirect.calledWith(expectedUrl)).toBe(true);
|
||||
done();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
it('redirects to the first challenge for users without a currentChallengeId', async done => {
|
||||
const challengeUrlResolver = await createChallengeUrlResolver(
|
||||
mockAllChallenges,
|
||||
{
|
||||
_getFirstChallenge: mockGetFirstChallenge
|
||||
}
|
||||
);
|
||||
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
|
||||
challengeUrlResolver,
|
||||
mockNormalizeParams,
|
||||
mockgetParamsFromReq
|
||||
);
|
||||
const req = mockReq({
|
||||
user: { ...mockUser, currentChallengeId: '' }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await redirectToCurrentChallenge(req, res, next);
|
||||
const expectedUrl = `${mockHomeLocation}${firstChallengeUrl}`;
|
||||
expect(res.redirect.calledWith(expectedUrl)).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,209 +0,0 @@
|
||||
/* global jest*/
|
||||
import { isEqual } from 'lodash';
|
||||
import { isEmail } from 'validator';
|
||||
|
||||
export const firstChallengeUrl = '/learn/the/first/challenge';
|
||||
export const requestedChallengeUrl = '/learn/my/actual/challenge';
|
||||
|
||||
export const mockChallenge = {
|
||||
id: '123abc',
|
||||
block: 'actual',
|
||||
superBlock: 'my',
|
||||
dashedName: 'challenge'
|
||||
};
|
||||
|
||||
export const mockFirstChallenge = {
|
||||
id: '456def',
|
||||
block: 'first',
|
||||
superBlock: 'the',
|
||||
dashedName: 'challenge',
|
||||
challengeOrder: 0,
|
||||
superOrder: 1,
|
||||
order: 0
|
||||
};
|
||||
|
||||
export const mockCompletedChallenge = {
|
||||
id: '890xyz',
|
||||
challengeType: 0,
|
||||
files: [
|
||||
{
|
||||
contents: 'file contents',
|
||||
key: 'indexfile',
|
||||
name: 'index',
|
||||
path: 'index.file',
|
||||
ext: 'file'
|
||||
}
|
||||
],
|
||||
completedDate: Date.now()
|
||||
};
|
||||
|
||||
export const mockCompletedChallenges = [
|
||||
{
|
||||
id: 'bd7123c8c441eddfaeb5bdef',
|
||||
completedDate: 1538052380328.0
|
||||
},
|
||||
{
|
||||
id: '587d7dbd367417b2b2512bb4',
|
||||
completedDate: 1547472893032.0,
|
||||
files: []
|
||||
},
|
||||
{
|
||||
id: 'aaa48de84e1ecc7c742e1124',
|
||||
completedDate: 1541678430790.0,
|
||||
files: [
|
||||
{
|
||||
contents:
|
||||
// eslint-disable-next-line max-len
|
||||
"function palindrome(str) {\n const clean = str.replace(/[\\W_]/g, '').toLowerCase()\n const revStr = clean.split('').reverse().join('');\n return clean === revStr;\n}\n\n\n\npalindrome(\"eye\");\n",
|
||||
ext: 'js',
|
||||
path: 'index.js',
|
||||
name: 'index',
|
||||
key: 'indexjs'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '5a24c314108439a4d4036164',
|
||||
completedDate: 1543845124143.0,
|
||||
files: []
|
||||
}
|
||||
];
|
||||
export const mockUserID = '5c7d892aff9777c8b1c1a95e';
|
||||
|
||||
export const createUserMockFn = jest.fn();
|
||||
export const createDonationMockFn = jest.fn();
|
||||
export const updateDonationAttr = jest.fn();
|
||||
export const updateUserAttr = jest.fn();
|
||||
export const mockUser = {
|
||||
id: mockUserID,
|
||||
username: 'camperbot',
|
||||
currentChallengeId: '123abc',
|
||||
email: 'donor@freecodecamp.com',
|
||||
timezone: 'UTC',
|
||||
completedChallenges: mockCompletedChallenges,
|
||||
progressTimestamps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
isDonating: true,
|
||||
donationEmails: ['donor@freecodecamp.com', 'donor@freecodecamp.com'],
|
||||
createDonation: donation => {
|
||||
createDonationMockFn(donation);
|
||||
return mockObservable;
|
||||
},
|
||||
updateAttributes: updateUserAttr
|
||||
};
|
||||
|
||||
const mockObservable = {
|
||||
toPromise: () => Promise.resolve('result')
|
||||
};
|
||||
|
||||
export const mockDonation = {
|
||||
id: '5e5f8eda5ed7be2b54e18718',
|
||||
email: 'donor@freecodecamp.com',
|
||||
provider: 'paypal',
|
||||
amount: 500,
|
||||
duration: 'month',
|
||||
startDate: {
|
||||
_when: '2018-11-01T00:00:00.000Z',
|
||||
_date: '2018-11-01T00:00:00.000Z'
|
||||
},
|
||||
subscriptionId: 'I-BA1ATBNF8T3P',
|
||||
userId: mockUserID,
|
||||
updateAttributes: updateDonationAttr
|
||||
};
|
||||
|
||||
export function createNewUserFromEmail(email) {
|
||||
const newMockUser = mockUser;
|
||||
newMockUser.email = email;
|
||||
newMockUser.username = 'camberbot2';
|
||||
newMockUser.ID = '5c7d892aff9888c8b1c1a95e';
|
||||
return newMockUser;
|
||||
}
|
||||
|
||||
export const mockApp = {
|
||||
models: {
|
||||
Donation: {
|
||||
findOne(query, cb) {
|
||||
return isEqual(query, matchSubscriptionIdQuery)
|
||||
? cb(null, mockDonation)
|
||||
: cb(Error('No Donation'));
|
||||
}
|
||||
},
|
||||
User: {
|
||||
findById(id, cb) {
|
||||
if (id === mockUser.id) {
|
||||
return cb(null, mockUser);
|
||||
}
|
||||
return cb(Error('No user'));
|
||||
},
|
||||
findOne(query, cb) {
|
||||
if (isEqual(query, matchEmailQuery) || isEqual(query, matchUserIdQuery))
|
||||
return cb(null, mockUser);
|
||||
return cb(null, null);
|
||||
},
|
||||
create(query, cb) {
|
||||
if (!isEmail(query.email)) return cb(new Error('email not valid'));
|
||||
else if (query.email === mockUser.email)
|
||||
return cb(new Error('user exist'));
|
||||
createUserMockFn();
|
||||
return Promise.resolve(createNewUserFromEmail(query.email));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const mockAllChallenges = [mockFirstChallenge, mockChallenge];
|
||||
|
||||
export const mockGetFirstChallenge = () => firstChallengeUrl;
|
||||
|
||||
export const matchEmailQuery = {
|
||||
where: { email: mockUser.email }
|
||||
};
|
||||
export const matchSubscriptionIdQuery = {
|
||||
where: { subscriptionId: mockDonation.subscriptionId }
|
||||
};
|
||||
export const matchUserIdQuery = {
|
||||
where: { id: mockUser.id }
|
||||
};
|
||||
|
||||
export const firstChallengeQuery = {
|
||||
// first challenge of the first block of the first superBlock
|
||||
where: { challengeOrder: 0, superOrder: 1, order: 0 }
|
||||
};
|
||||
|
||||
export const fullStackChallenges = [
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '5a553ca864b52e1d8bceea14',
|
||||
challengeType: 7,
|
||||
files: []
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17513bc',
|
||||
challengeType: 7,
|
||||
files: []
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561acd10cb82ac38a17513bc',
|
||||
challengeType: 7,
|
||||
files: []
|
||||
},
|
||||
{
|
||||
completedDate: 1685210952511,
|
||||
id: '561abd10cb81ac38a17513bc',
|
||||
challengeType: 7,
|
||||
files: []
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17523bc',
|
||||
challengeType: 7,
|
||||
files: []
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561add10cb82ac38a17213bc',
|
||||
challengeType: 7,
|
||||
files: []
|
||||
}
|
||||
];
|
@ -1,153 +0,0 @@
|
||||
import passport from 'passport';
|
||||
// eslint-disable-next-line
|
||||
import {
|
||||
// prettier ignore
|
||||
PassportConfigurator
|
||||
} from '@freecodecamp/loopback-component-passport';
|
||||
import dedent from 'dedent';
|
||||
|
||||
import { getUserById } from './utils/user-stats';
|
||||
import passportProviders from './passport-providers';
|
||||
import { setAccessTokenToResponse } from './utils/getSetAccessToken';
|
||||
import { jwtSecret } from '../../config/secrets';
|
||||
import {
|
||||
getReturnTo,
|
||||
getRedirectBase,
|
||||
getRedirectParams,
|
||||
isRootPath
|
||||
} from './utils/redirection';
|
||||
|
||||
const passportOptions = {
|
||||
emailOptional: true,
|
||||
profileToUser: null
|
||||
};
|
||||
|
||||
PassportConfigurator.prototype.init = function passportInit(noSession) {
|
||||
this.app.middleware('session:after', passport.initialize());
|
||||
|
||||
if (noSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.app.middleware('session:after', passport.session());
|
||||
|
||||
// Serialization and deserialization is only required if passport session is
|
||||
// enabled
|
||||
|
||||
passport.serializeUser((user, done) => done(null, user.id));
|
||||
|
||||
passport.deserializeUser(async (id, done) => {
|
||||
const user = await getUserById(id).catch(done);
|
||||
return done(null, user);
|
||||
});
|
||||
};
|
||||
|
||||
export function setupPassport(app) {
|
||||
const configurator = new PassportConfigurator(app);
|
||||
|
||||
configurator.setupModels({
|
||||
userModel: app.models.user,
|
||||
userIdentityModel: app.models.userIdentity,
|
||||
userCredentialModel: app.models.userCredential
|
||||
});
|
||||
|
||||
configurator.init();
|
||||
|
||||
Object.keys(passportProviders).map(function(strategy) {
|
||||
let config = passportProviders[strategy];
|
||||
config.session = config.session !== false;
|
||||
|
||||
config.customCallback = !config.useCustomCallback
|
||||
? null
|
||||
: createPassportCallbackAuthenticator(strategy, config);
|
||||
|
||||
configurator.configureProvider(strategy, {
|
||||
...config,
|
||||
...passportOptions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const devSaveResponseAuthCookies = () => {
|
||||
return (req, res, next) => {
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
return res.redirect('/signin');
|
||||
}
|
||||
|
||||
const { accessToken } = user;
|
||||
|
||||
setAccessTokenToResponse({ accessToken }, req, res);
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const devLoginRedirect = () => {
|
||||
return (req, res) => {
|
||||
// this mirrors the production approach, but without any validation
|
||||
let { returnTo, origin, pathPrefix } = getRedirectParams(
|
||||
req,
|
||||
params => params
|
||||
);
|
||||
returnTo += isRootPath(getRedirectBase(origin, pathPrefix), returnTo)
|
||||
? 'learn'
|
||||
: '';
|
||||
return res.redirect(returnTo);
|
||||
};
|
||||
};
|
||||
|
||||
export const createPassportCallbackAuthenticator = (strategy, config) => (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
return passport.authenticate(
|
||||
strategy,
|
||||
{ session: false },
|
||||
(err, user, userInfo) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!user || !userInfo) {
|
||||
return res.redirect('/signin');
|
||||
}
|
||||
|
||||
const { accessToken } = userInfo;
|
||||
const { provider } = config;
|
||||
if (accessToken && accessToken.id) {
|
||||
if (provider === 'auth0') {
|
||||
req.flash('success', 'flash.signin-success');
|
||||
} else if (user.email) {
|
||||
req.flash(
|
||||
'info',
|
||||
dedent`
|
||||
We are moving away from social authentication for privacy reasons. Next time
|
||||
we recommend using your email address: ${user.email} to sign in instead.
|
||||
`
|
||||
);
|
||||
}
|
||||
setAccessTokenToResponse({ accessToken }, req, res);
|
||||
req.login(user);
|
||||
}
|
||||
|
||||
const state = req && req.query && req.query.state;
|
||||
// returnTo, origin and pathPrefix are audited by getReturnTo
|
||||
let { returnTo, origin, pathPrefix } = getReturnTo(state, jwtSecret);
|
||||
const redirectBase = getRedirectBase(origin, pathPrefix);
|
||||
|
||||
// TODO: getReturnTo could return a success flag to show a flash message,
|
||||
// but currently it immediately gets overwritten by a second message. We
|
||||
// should either change the message if the flag is present or allow
|
||||
// multiple messages to appear at once.
|
||||
|
||||
if (user.acceptedPrivacyTerms) {
|
||||
returnTo += isRootPath(redirectBase, returnTo) ? '/learn' : '';
|
||||
return res.redirectWithFlash(returnTo);
|
||||
} else {
|
||||
return res.redirectWithFlash(`${redirectBase}/email-sign-up`);
|
||||
}
|
||||
}
|
||||
)(req, res, next);
|
||||
};
|
@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
host: '127.0.0.1',
|
||||
sessionSecret: process.env.SESSION_SECRET,
|
||||
|
||||
github: {
|
||||
clientID: process.env.GITHUB_ID,
|
||||
clientSecret: process.env.GITHUB_SECRET
|
||||
}
|
||||
};
|
@ -1,22 +0,0 @@
|
||||
{
|
||||
"restApiRoot": "/api",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3000,
|
||||
"legacyExplorer": false,
|
||||
"remoting": {
|
||||
"rest": {
|
||||
"handleErrors": false,
|
||||
"normalizeHttpPath": false,
|
||||
"xml": false
|
||||
},
|
||||
"json": {
|
||||
"strict": false,
|
||||
"limit": "100kb"
|
||||
},
|
||||
"urlencoded": {
|
||||
"extended": true,
|
||||
"limit": "100kb"
|
||||
},
|
||||
"cors": false
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
var globalConfig = require('../common/config.global');
|
||||
|
||||
module.exports = {
|
||||
restApiRoot: globalConfig.restApi,
|
||||
sessionSecret: process.env.SESSION_SECRET,
|
||||
|
||||
github: {
|
||||
clientID: process.env.GITHUB_ID,
|
||||
clientSecret: process.env.GITHUB_SECRET
|
||||
}
|
||||
};
|
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
host: process.env.HOST || 'localhost'
|
||||
};
|
@ -1,30 +0,0 @@
|
||||
const debug = require('debug')('fcc:server:datasources');
|
||||
const dsLocal = require('./datasources.production.js');
|
||||
|
||||
const ds = {
|
||||
...dsLocal
|
||||
};
|
||||
// use [MailHog](https://github.com/mailhog/MailHog) if no SES keys are found
|
||||
if (!process.env.SES_ID) {
|
||||
ds.mail = {
|
||||
connector: 'mail',
|
||||
transport: {
|
||||
type: 'smtp',
|
||||
host: process.env.MAILHOG_HOST || 'localhost',
|
||||
secure: false,
|
||||
port: 1025,
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
},
|
||||
auth: {
|
||||
user: 'test',
|
||||
pass: 'test'
|
||||
}
|
||||
};
|
||||
debug(`using MailHog server on port ${ds.mail.transport.port}`);
|
||||
} else {
|
||||
debug('using AWS SES to deliver emails');
|
||||
}
|
||||
|
||||
module.exports = ds;
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"db": {
|
||||
"name": "db",
|
||||
"connector": "mongodb",
|
||||
"allowExtendedOperators": true
|
||||
},
|
||||
"mail": {
|
||||
"name": "mail",
|
||||
"connector": "mail"
|
||||
}
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
var secrets = require('../../config/secrets');
|
||||
|
||||
module.exports = {
|
||||
db: {
|
||||
connector: 'mongodb',
|
||||
protocol: 'mongodb+srv',
|
||||
connectionTimeout: 10000,
|
||||
url: secrets.db,
|
||||
useNewUrlParser: true,
|
||||
allowExtendedOperators: true
|
||||
},
|
||||
mail: {
|
||||
connector: 'mail',
|
||||
transport: {
|
||||
type: 'ses',
|
||||
accessKeyId: process.env.SES_ID,
|
||||
secretAccessKey: process.env.SES_SECRET
|
||||
}
|
||||
}
|
||||
};
|
@ -1,100 +0,0 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
const _ = require('lodash');
|
||||
const Rx = require('rx');
|
||||
const loopback = require('loopback');
|
||||
const boot = require('loopback-boot');
|
||||
const createDebugger = require('debug');
|
||||
const morgan = require('morgan');
|
||||
const Sentry = require('@sentry/node');
|
||||
|
||||
const { sentry } = require('../../config/secrets');
|
||||
const { setupPassport } = require('./component-passport');
|
||||
|
||||
const log = createDebugger('fcc:server');
|
||||
const reqLogFormat = ':date[iso] :status :method :response-time ms - :url';
|
||||
|
||||
// force logger to always output
|
||||
// this may be brittle
|
||||
log.enabled = true;
|
||||
|
||||
if (sentry.dns === 'dsn_from_sentry_dashboard') {
|
||||
log('Sentry reporting disabled unless DSN is provided.');
|
||||
} else {
|
||||
Sentry.init({
|
||||
dsn: sentry.dns
|
||||
});
|
||||
log('Sentry initialized');
|
||||
}
|
||||
|
||||
Rx.config.longStackSupport = process.env.NODE_DEBUG !== 'production';
|
||||
const app = loopback();
|
||||
|
||||
app.set('state namespace', '__fcc__');
|
||||
app.set('port', process.env.PORT || 3000);
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'jade');
|
||||
app.use(loopback.token());
|
||||
app.use(
|
||||
morgan(reqLogFormat, { stream: { write: msg => log(_.split(msg, '\n')[0]) } })
|
||||
);
|
||||
app.disable('x-powered-by');
|
||||
|
||||
const createLogOnce = () => {
|
||||
let called = false;
|
||||
return str => {
|
||||
if (called) {
|
||||
return null;
|
||||
}
|
||||
called = true;
|
||||
return log(str);
|
||||
};
|
||||
};
|
||||
const logOnce = createLogOnce();
|
||||
|
||||
boot(app, __dirname, err => {
|
||||
if (err) {
|
||||
// rethrowing the error here because any error thrown in the boot stage
|
||||
// is silent
|
||||
logOnce('The below error was thrown in the boot stage');
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setupPassport(app);
|
||||
|
||||
const { db } = app.datasources;
|
||||
db.on('connected', _.once(() => log('db connected')));
|
||||
app.start = _.once(function() {
|
||||
const server = app.listen(app.get('port'), function() {
|
||||
app.emit('started');
|
||||
log(
|
||||
'freeCodeCamp server listening on port %d in %s',
|
||||
app.get('port'),
|
||||
app.get('env')
|
||||
);
|
||||
log(`connecting to db at ${db.settings.url}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
log('Shutting down server');
|
||||
server.close(() => {
|
||||
log('Server is closed');
|
||||
});
|
||||
log('closing db connection');
|
||||
db.disconnect().then(() => {
|
||||
log('DB connection closed');
|
||||
// exit process
|
||||
// this may close kept alive sockets
|
||||
// eslint-disable-next-line no-process-exit
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
if (require.main === module) {
|
||||
app.start();
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
This folder contains a list of json files representing the name
|
||||
of revisioned client files. It is empty due to the fact that the
|
||||
files are generated at runtime and their content is determined by
|
||||
the content of the files they are derived from.
|
||||
|
||||
Since the build process is not exactly the same on every machine,
|
||||
these files are not tracked in github otherwise conflicts arise when
|
||||
building on our servers.
|
@ -1,65 +0,0 @@
|
||||
{
|
||||
"initial:before": {
|
||||
"./middlewares/sentry-request-handler": {},
|
||||
"loopback#favicon": {
|
||||
"params": "$!../public/favicon.ico"
|
||||
},
|
||||
"loopback#static": {
|
||||
"params": [
|
||||
"$!../public",
|
||||
{
|
||||
"maxAge": "86400000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"initial": {
|
||||
"compression": {},
|
||||
"cors": {
|
||||
"params": {
|
||||
"origin": true,
|
||||
"credentials": true,
|
||||
"maxAge": 86400
|
||||
}
|
||||
}
|
||||
},
|
||||
"session": {
|
||||
"./middlewares/sessions.js": {}
|
||||
},
|
||||
"auth:before": {
|
||||
"express-flash": {},
|
||||
"./middlewares/express-extensions": {},
|
||||
"./middlewares/cookie-parser": {},
|
||||
"./middlewares/request-authorization": {}
|
||||
},
|
||||
"parse": {
|
||||
"body-parser#json": {},
|
||||
"body-parser#urlencoded": {
|
||||
"params": {
|
||||
"extended": true
|
||||
}
|
||||
},
|
||||
"method-override": {}
|
||||
},
|
||||
"routes:before": {
|
||||
"helmet#xssFilter": {},
|
||||
"helmet#noSniff": {},
|
||||
"helmet#frameguard": {},
|
||||
"./middlewares/csurf": {},
|
||||
"./middlewares/constant-headers": {},
|
||||
"./middlewares/csp": {},
|
||||
"./middlewares/flash-cheaters": {},
|
||||
"./middlewares/passport-login": {}
|
||||
},
|
||||
"files": {},
|
||||
"final:after": {
|
||||
"./middlewares/sentry-error-handler": {},
|
||||
"./middlewares/error-handlers": {},
|
||||
"strong-error-handler": {
|
||||
"params": {
|
||||
"debug": false,
|
||||
"log": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import { homeLocation } from '../../../config/env';
|
||||
import { allowedOrigins } from '../../../config/cors-settings';
|
||||
|
||||
export default function constantHeaders() {
|
||||
return function(req, res, next) {
|
||||
if (
|
||||
req.headers &&
|
||||
req.headers.origin &&
|
||||
allowedOrigins.includes(req.headers.origin)
|
||||
) {
|
||||
res.header('Access-Control-Allow-Origin', req.headers.origin);
|
||||
} else {
|
||||
res.header('Access-Control-Allow-Origin', homeLocation);
|
||||
}
|
||||
res.header('Access-Control-Allow-Credentials', true);
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept'
|
||||
);
|
||||
next();
|
||||
};
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
export default cookieParser.bind(cookieParser, cookieSecret);
|
@ -1,95 +0,0 @@
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { homeLocation } from '../../../config/env';
|
||||
|
||||
let trusted = [
|
||||
"'self'",
|
||||
'https://search.freecodecamp.org',
|
||||
homeLocation,
|
||||
'https://' + process.env.AUTH0_DOMAIN
|
||||
];
|
||||
|
||||
const host = process.env.HOST || 'localhost';
|
||||
const port = process.env.SYNC_PORT || '3000';
|
||||
|
||||
if (process.env.FREECODECAMP_NODE_ENV !== 'production') {
|
||||
trusted = trusted.concat([`ws://${host}:${port}`, 'http://localhost:8000']);
|
||||
}
|
||||
|
||||
export default function csp() {
|
||||
return helmet.contentSecurityPolicy({
|
||||
directives: {
|
||||
defaultSrc: trusted.concat([
|
||||
'https://*.cloudflare.com',
|
||||
'*.cloudflare.com'
|
||||
]),
|
||||
connectSrc: trusted.concat([
|
||||
'https://glitch.com',
|
||||
'https://*.glitch.com',
|
||||
'https://*.glitch.me',
|
||||
'https://*.cloudflare.com',
|
||||
'https://*.algolia.net'
|
||||
]),
|
||||
scriptSrc: [
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
'*.google-analytics.com',
|
||||
'*.gstatic.com',
|
||||
'https://*.cloudflare.com',
|
||||
'*.cloudflare.com',
|
||||
'https://*.gitter.im',
|
||||
'https://*.cdnjs.com',
|
||||
'*.cdnjs.com',
|
||||
'https://*.jsdelivr.com',
|
||||
'*.jsdelivr.com',
|
||||
'*.twimg.com',
|
||||
'https://*.twimg.com',
|
||||
'*.youtube.com',
|
||||
'*.ytimg.com'
|
||||
].concat(trusted),
|
||||
styleSrc: [
|
||||
"'unsafe-inline'",
|
||||
'*.gstatic.com',
|
||||
'*.googleapis.com',
|
||||
'*.bootstrapcdn.com',
|
||||
'https://*.bootstrapcdn.com',
|
||||
'*.cloudflare.com',
|
||||
'https://*.cloudflare.com',
|
||||
'https://use.fontawesome.com'
|
||||
].concat(trusted),
|
||||
fontSrc: [
|
||||
'*.cloudflare.com',
|
||||
'https://*.cloudflare.com',
|
||||
'*.bootstrapcdn.com',
|
||||
'*.googleapis.com',
|
||||
'*.gstatic.com',
|
||||
'https://*.bootstrapcdn.com',
|
||||
'https://use.fontawesome.com'
|
||||
].concat(trusted),
|
||||
imgSrc: [
|
||||
// allow all input since we have user submitted images for
|
||||
// public profile
|
||||
'*',
|
||||
'data:'
|
||||
],
|
||||
mediaSrc: ['*.bitly.com', '*.amazonaws.com', '*.twitter.com'].concat(
|
||||
trusted
|
||||
),
|
||||
frameSrc: [
|
||||
'*.gitter.im',
|
||||
'*.gitter.im https:',
|
||||
'*.youtube.com',
|
||||
'*.twitter.com',
|
||||
'*.ghbtns.com',
|
||||
'*.freecatphotoapp.com',
|
||||
'freecodecamp.github.io'
|
||||
].concat(trusted)
|
||||
},
|
||||
// set to true if you only want to report errors
|
||||
reportOnly: false,
|
||||
// set to true if you want to set all headers
|
||||
setAllHeaders: false,
|
||||
// set to true if you want to force buggy CSP in Safari 5
|
||||
safari5: false
|
||||
});
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import csurf from 'csurf';
|
||||
|
||||
export default function() {
|
||||
const protection = csurf({
|
||||
cookie: {
|
||||
domain: process.env.COOKIE_DOMAIN || 'localhost',
|
||||
sameSite: 'strict',
|
||||
secure: process.env.FREECODECAMP_NODE_ENV === 'production'
|
||||
}
|
||||
});
|
||||
return function csrf(req, res, next) {
|
||||
const { path } = req;
|
||||
if (
|
||||
// eslint-disable-next-line max-len
|
||||
/^\/hooks\/update-paypal$|^\/hooks\/update-stripe$|^\/donate\/charge-stripe$/.test(
|
||||
path
|
||||
)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
return protection(req, res, next);
|
||||
};
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
// import { inspect } from 'util';
|
||||
// import _ from 'lodash/fp';
|
||||
import accepts from 'accepts';
|
||||
|
||||
import { unwrapHandledError } from '../utils/create-handled-error.js';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
const errTemplate = (error, req) => {
|
||||
const { message, stack } = error;
|
||||
return `
|
||||
Error: ${message}
|
||||
Is authenticated user: ${!!req.user}
|
||||
Headers: ${JSON.stringify(req.headers, null, 2)}
|
||||
Original request: ${req.originalMethod} ${req.originalUrl}
|
||||
Stack: ${stack}
|
||||
|
||||
// raw
|
||||
${JSON.stringify(error, null, 2)}
|
||||
|
||||
`;
|
||||
};
|
||||
|
||||
const isDev = process.env.FREECODECAMP_NODE_ENV !== 'production';
|
||||
|
||||
export default function prodErrorHandler() {
|
||||
// error handling in production.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
return function(err, req, res, next) {
|
||||
const { origin } = getRedirectParams(req);
|
||||
const handled = unwrapHandledError(err);
|
||||
// respect handled error status
|
||||
let status = handled.status || err.status || res.statusCode;
|
||||
if (!handled.status && status < 400) {
|
||||
status = 500;
|
||||
}
|
||||
res.status(status);
|
||||
|
||||
// parse res type
|
||||
const accept = accepts(req);
|
||||
const type = accept.type('html', 'json', 'text');
|
||||
|
||||
const redirectTo = handled.redirectTo || `${origin}/`;
|
||||
const message =
|
||||
handled.message ||
|
||||
'Oops! Something went wrong. Please try again in a moment.';
|
||||
|
||||
if (isDev) {
|
||||
console.error(errTemplate(err, req));
|
||||
}
|
||||
|
||||
if (type === 'html') {
|
||||
if (typeof req.flash === 'function') {
|
||||
req.flash(handled.type || 'danger', message);
|
||||
}
|
||||
return res.redirectWithFlash(redirectTo);
|
||||
// json
|
||||
} else if (type === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.send({
|
||||
type: handled.type || 'errors',
|
||||
message
|
||||
});
|
||||
// plain text
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
return res.send(message);
|
||||
}
|
||||
};
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import qs from 'query-string';
|
||||
|
||||
// add rx methods to express
|
||||
export default function() {
|
||||
return function expressExtensions(req, res, next) {
|
||||
res.redirectWithFlash = uri => {
|
||||
const flash = req.flash();
|
||||
res.redirect(
|
||||
`${uri}?${qs.stringify(
|
||||
{ messages: qs.stringify(flash, { arrayFormat: 'index' }) },
|
||||
{ arrayFormat: 'index' }
|
||||
)}`
|
||||
);
|
||||
};
|
||||
res.sendFlash = (type, message) => {
|
||||
if (type && message) {
|
||||
req.flash(type, message);
|
||||
}
|
||||
return res.json(req.flash());
|
||||
};
|
||||
next();
|
||||
};
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import dedent from 'dedent';
|
||||
|
||||
const ALLOWED_METHODS = ['GET'];
|
||||
const EXCLUDED_PATHS = [
|
||||
'/api/flyers/findOne',
|
||||
'/challenges/current-challenge',
|
||||
'/challenges/next-challenge',
|
||||
'/map-aside',
|
||||
'/signout'
|
||||
];
|
||||
|
||||
export default function flashCheaters() {
|
||||
return function(req, res, next) {
|
||||
if (
|
||||
ALLOWED_METHODS.indexOf(req.method) !== -1 &&
|
||||
EXCLUDED_PATHS.indexOf(req.path) === -1 &&
|
||||
req.user &&
|
||||
req.url !== '/' &&
|
||||
req.user.isCheater
|
||||
) {
|
||||
req.flash(
|
||||
'danger',
|
||||
dedent`
|
||||
Upon review, this account has been flagged for academic
|
||||
dishonesty. If you’re the owner of this account contact
|
||||
team@freecodecamp.org for details.
|
||||
`
|
||||
);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { Observable } from 'rx';
|
||||
import { login } from 'passport/lib/http/request';
|
||||
|
||||
// make login polymorphic
|
||||
// if supplied callback it works as normal
|
||||
// if called without callback it returns an observable
|
||||
// login(user, options?, cb?) => Void|Observable
|
||||
function login$(...args) {
|
||||
if (_.isFunction(_.last(args))) {
|
||||
return login.apply(this, args);
|
||||
}
|
||||
return Observable.fromNodeCallback(login).apply(this, args);
|
||||
}
|
||||
|
||||
export default function passportLogin() {
|
||||
return (req, res, next) => {
|
||||
req.login = req.logIn = login$;
|
||||
next();
|
||||
};
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { getUserById as _getUserById } from '../utils/user-stats';
|
||||
import {
|
||||
getAccessTokenFromRequest,
|
||||
errorTypes,
|
||||
authHeaderNS
|
||||
} from '../utils/getSetAccessToken';
|
||||
import { jwtSecret as _jwtSecret } from '../../../config/secrets';
|
||||
|
||||
import { wrapHandledError } from '../utils/create-handled-error';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
const authRE = /^\/auth\//;
|
||||
const confirmEmailRE = /^\/confirm-email$/;
|
||||
const newsShortLinksRE = /^\/n\/|^\/p\//;
|
||||
const publicUserRE = /^\/api\/users\/get-public-profile$/;
|
||||
const publicUsernameRE = /^\/api\/users\/exists$/;
|
||||
const resubscribeRE = /^\/resubscribe\//;
|
||||
const showCertRE = /^\/certificate\/showCert\//;
|
||||
// note: signin may not have a trailing slash
|
||||
const signinRE = /^\/signin/;
|
||||
const statusRE = /^\/status\/ping$/;
|
||||
const unsubscribedRE = /^\/unsubscribed\//;
|
||||
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
|
||||
const updateHooksRE = /^\/hooks\/update-paypal$|^\/hooks\/update-stripe$/;
|
||||
|
||||
// note: this would be replaced by webhooks later
|
||||
const donateRE = /^\/donate\/charge-stripe$/;
|
||||
|
||||
const _pathsAllowedREs = [
|
||||
authRE,
|
||||
confirmEmailRE,
|
||||
newsShortLinksRE,
|
||||
publicUserRE,
|
||||
publicUsernameRE,
|
||||
resubscribeRE,
|
||||
showCertRE,
|
||||
signinRE,
|
||||
statusRE,
|
||||
unsubscribedRE,
|
||||
unsubscribeRE,
|
||||
updateHooksRE,
|
||||
donateRE
|
||||
];
|
||||
|
||||
export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) {
|
||||
return pathsAllowedREs.some(re => re.test(path));
|
||||
}
|
||||
|
||||
export default ({ jwtSecret = _jwtSecret, getUserById = _getUserById } = {}) =>
|
||||
function requestAuthorisation(req, res, next) {
|
||||
const { origin } = getRedirectParams(req);
|
||||
const { path } = req;
|
||||
if (!isAllowedPath(path)) {
|
||||
const { accessToken, error, jwt } = getAccessTokenFromRequest(
|
||||
req,
|
||||
jwtSecret
|
||||
);
|
||||
if (!accessToken && error === errorTypes.noTokenFound) {
|
||||
throw wrapHandledError(
|
||||
new Error('Access token is required for this request'),
|
||||
{
|
||||
type: 'info',
|
||||
redirect: `${origin}/signin`,
|
||||
message: 'Access token is required for this request',
|
||||
status: 403
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!accessToken && error === errorTypes.invalidToken) {
|
||||
throw wrapHandledError(new Error('Access token is invalid'), {
|
||||
type: 'info',
|
||||
redirect: `${origin}/signin`,
|
||||
message: 'Your access token is invalid',
|
||||
status: 403
|
||||
});
|
||||
}
|
||||
if (!accessToken && error === errorTypes.expiredToken) {
|
||||
throw wrapHandledError(new Error('Access token is no longer valid'), {
|
||||
type: 'info',
|
||||
redirect: `${origin}/signin`,
|
||||
message: 'Access token is no longer valid',
|
||||
status: 403
|
||||
});
|
||||
}
|
||||
res.set(authHeaderNS, jwt);
|
||||
if (isEmpty(req.user)) {
|
||||
const { userId } = accessToken;
|
||||
return getUserById(userId)
|
||||
.then(user => {
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
return;
|
||||
})
|
||||
.then(next)
|
||||
.catch(next);
|
||||
} else {
|
||||
return Promise.resolve(next());
|
||||
}
|
||||
}
|
||||
return Promise.resolve(next());
|
||||
};
|
@ -1,287 +0,0 @@
|
||||
/* global describe it expect */
|
||||
import sinon from 'sinon';
|
||||
import { mockReq as mockRequest, mockRes } from 'sinon-express-mock';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { homeLocation } from '../../../config/env.json';
|
||||
import createRequestAuthorization, {
|
||||
isAllowedPath
|
||||
} from './request-authorization';
|
||||
|
||||
const validJWTSecret = 'this is a super secret string';
|
||||
const invalidJWTSecret = 'This is not correct secret';
|
||||
const now = new Date(Date.now());
|
||||
const theBeginningOfTime = new Date(0);
|
||||
const accessToken = {
|
||||
id: '123abc',
|
||||
userId: '456def',
|
||||
ttl: 60000,
|
||||
created: now
|
||||
};
|
||||
const users = {
|
||||
'456def': {
|
||||
username: 'camperbot',
|
||||
progressTimestamps: [1, 2, 3, 4]
|
||||
}
|
||||
};
|
||||
const mockGetUserById = id =>
|
||||
id in users ? Promise.resolve(users[id]) : Promise.reject('No user found');
|
||||
|
||||
const mockReq = args => {
|
||||
const mock = mockRequest(args);
|
||||
mock.header = () => homeLocation;
|
||||
return mock;
|
||||
};
|
||||
|
||||
describe('request-authorization', () => {
|
||||
describe('isAllowedPath', () => {
|
||||
const authRE = /^\/auth\//;
|
||||
const confirmEmailRE = /^\/confirm-email$/;
|
||||
const newsShortLinksRE = /^\/n\/|^\/p\//;
|
||||
const publicUserRE = /^\/api\/users\/get-public-profile$/;
|
||||
const publicUsernameRE = /^\/api\/users\/exists$/;
|
||||
const resubscribeRE = /^\/resubscribe\//;
|
||||
const showCertRE = /^\/certificate\/showCert\//;
|
||||
// note: signin may not have a trailing slash
|
||||
const signinRE = /^\/signin/;
|
||||
const statusRE = /^\/status\/ping$/;
|
||||
const unsubscribedRE = /^\/unsubscribed\//;
|
||||
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
|
||||
const updateHooksRE = /^\/hooks\/update-paypal$|^\/hooks\/update-stripe$/;
|
||||
|
||||
const allowedPathsList = [
|
||||
authRE,
|
||||
confirmEmailRE,
|
||||
newsShortLinksRE,
|
||||
publicUserRE,
|
||||
publicUsernameRE,
|
||||
resubscribeRE,
|
||||
showCertRE,
|
||||
signinRE,
|
||||
statusRE,
|
||||
unsubscribedRE,
|
||||
unsubscribeRE,
|
||||
updateHooksRE
|
||||
];
|
||||
|
||||
it('returns a boolean', () => {
|
||||
const result = isAllowedPath();
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
|
||||
it('returns true for a white listed path', () => {
|
||||
const resultA = isAllowedPath(
|
||||
'/auth/auth0/callback?code=yF_mGjswLsef-_RLo',
|
||||
allowedPathsList
|
||||
);
|
||||
const resultB = isAllowedPath(
|
||||
'/ue/WmjInLerysPrcon6fMb/',
|
||||
allowedPathsList
|
||||
);
|
||||
const resultC = isAllowedPath('/hooks/update-paypal', allowedPathsList);
|
||||
const resultD = isAllowedPath('/hooks/update-stripe', allowedPathsList);
|
||||
expect(resultA).toBe(true);
|
||||
expect(resultB).toBe(true);
|
||||
expect(resultC).toBe(true);
|
||||
expect(resultD).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-white-listed path', () => {
|
||||
const resultA = isAllowedPath('/hax0r-42/no-go', allowedPathsList);
|
||||
const resultB = isAllowedPath(
|
||||
'/update-current-challenge',
|
||||
allowedPathsList
|
||||
);
|
||||
expect(resultA).toBe(false);
|
||||
expect(resultB).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestAuthorization', () => {
|
||||
const requestAuthorization = createRequestAuthorization({
|
||||
jwtSecret: validJWTSecret,
|
||||
getUserById: mockGetUserById
|
||||
});
|
||||
|
||||
it('is a function', () => {
|
||||
expect(typeof requestAuthorization).toEqual('function');
|
||||
});
|
||||
|
||||
describe('cookies', () => {
|
||||
it('throws when no access token is present', () => {
|
||||
expect.assertions(2);
|
||||
const req = mockReq({ path: '/some-path/that-needs/auth' });
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
expect(() => requestAuthorization(req, res, next)).toThrowError(
|
||||
'Access token is required for this request'
|
||||
);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the access token is invalid', () => {
|
||||
expect.assertions(2);
|
||||
const invalidJWT = jwt.sign({ accessToken }, invalidJWTSecret);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
// eslint-disable-next-line camelcase
|
||||
cookie: { jwt_access_token: invalidJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
expect(() => requestAuthorization(req, res, next)).toThrowError(
|
||||
'Access token is invalid'
|
||||
);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the access token has expired', () => {
|
||||
expect.assertions(2);
|
||||
const invalidJWT = jwt.sign(
|
||||
{ accessToken: { ...accessToken, created: theBeginningOfTime } },
|
||||
validJWTSecret
|
||||
);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
// eslint-disable-next-line camelcase
|
||||
cookie: { jwt_access_token: invalidJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
expect(() => requestAuthorization(req, res, next)).toThrowError(
|
||||
'Access token is no longer valid'
|
||||
);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('adds the user to the request object', async done => {
|
||||
expect.assertions(3);
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
// eslint-disable-next-line camelcase
|
||||
cookie: { jwt_access_token: validJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await requestAuthorization(req, res, next);
|
||||
expect(next.called).toBe(true);
|
||||
expect(req).toHaveProperty('user');
|
||||
expect(req.user).toEqual(users['456def']);
|
||||
return done();
|
||||
});
|
||||
|
||||
it('adds the jwt to the headers', async done => {
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
// eslint-disable-next-line camelcase
|
||||
cookie: { jwt_access_token: validJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await requestAuthorization(req, res, next);
|
||||
expect(res.set.calledWith('X-fcc-access-token', validJWT)).toBe(true);
|
||||
return done();
|
||||
});
|
||||
|
||||
it('calls next if request does not require authorization', async () => {
|
||||
// currently /unsubscribe does not require authorization
|
||||
const req = mockReq({ path: '/unsubscribe/another/route' });
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await requestAuthorization(req, res, next);
|
||||
expect(next.called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth header', () => {
|
||||
it('throws when no access token is present', () => {
|
||||
expect.assertions(2);
|
||||
const req = mockReq({ path: '/some-path/that-needs/auth' });
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
expect(() => requestAuthorization(req, res, next)).toThrowError(
|
||||
'Access token is required for this request'
|
||||
);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the access token is invalid', () => {
|
||||
expect.assertions(2);
|
||||
const invalidJWT = jwt.sign({ accessToken }, invalidJWTSecret);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
headers: { 'X-fcc-access-token': invalidJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
expect(() => requestAuthorization(req, res, next)).toThrowError(
|
||||
'Access token is invalid'
|
||||
);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the access token has expired', () => {
|
||||
expect.assertions(2);
|
||||
const invalidJWT = jwt.sign(
|
||||
{ accessToken: { ...accessToken, created: theBeginningOfTime } },
|
||||
validJWTSecret
|
||||
);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
headers: { 'X-fcc-access-token': invalidJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
|
||||
expect(() => requestAuthorization(req, res, next)).toThrowError(
|
||||
'Access token is no longer valid'
|
||||
);
|
||||
expect(next.called).toBe(false);
|
||||
});
|
||||
|
||||
it('adds the user to the request object', async done => {
|
||||
expect.assertions(3);
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
headers: { 'X-fcc-access-token': validJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await requestAuthorization(req, res, next);
|
||||
expect(next.called).toBe(true);
|
||||
expect(req).toHaveProperty('user');
|
||||
expect(req.user).toEqual(users['456def']);
|
||||
return done();
|
||||
});
|
||||
|
||||
it('adds the jwt to the headers', async done => {
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
const req = mockReq({
|
||||
path: '/some-path/that-needs/auth',
|
||||
// eslint-disable-next-line camelcase
|
||||
cookie: { jwt_access_token: validJWT }
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await requestAuthorization(req, res, next);
|
||||
expect(res.set.calledWith('X-fcc-access-token', validJWT)).toBe(true);
|
||||
return done();
|
||||
});
|
||||
|
||||
it('calls next if request does not require authorization', async () => {
|
||||
// currently /unsubscribe does not require authorization
|
||||
const req = mockReq({ path: '/unsubscribe/another/route' });
|
||||
const res = mockRes();
|
||||
const next = sinon.spy();
|
||||
await requestAuthorization(req, res, next);
|
||||
expect(next.called).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,23 +0,0 @@
|
||||
import { Handlers, captureException } from '@sentry/node';
|
||||
import { sentry } from '../../../config/secrets';
|
||||
import { isHandledError } from '../utils/create-handled-error';
|
||||
|
||||
// sends directly to Sentry
|
||||
export function reportError(err) {
|
||||
return sentry.dns === 'dsn_from_sentry_dashboard'
|
||||
? console.error(err)
|
||||
: captureException(err);
|
||||
}
|
||||
|
||||
// determines which errors should be reported
|
||||
export default function sentryErrorHandler() {
|
||||
return sentry.dns === 'dsn_from_sentry_dashboard'
|
||||
? (req, res, next) => next()
|
||||
: Handlers.errorHandler({
|
||||
shouldHandleError(err) {
|
||||
// CSRF errors have status 403, consider ignoring them once csurf is
|
||||
// no longer rejecting people incorrectly.
|
||||
return !isHandledError(err) && (!err.status || err.status >= 500);
|
||||
}
|
||||
});
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { Handlers } from '@sentry/node';
|
||||
import { sentry } from '../../../config/secrets';
|
||||
|
||||
export default function sentryRequestHandler() {
|
||||
return sentry.dns === 'dsn_from_sentry_dashboard'
|
||||
? (req, res, next) => next()
|
||||
: Handlers.requestHandler();
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import session from 'express-session';
|
||||
import MongoStoreFactory from 'connect-mongo';
|
||||
|
||||
const MongoStore = MongoStoreFactory(session);
|
||||
const sessionSecret = process.env.SESSION_SECRET;
|
||||
const url = process.env.MONGODB || process.env.MONGOHQ_URL;
|
||||
|
||||
export default function sessionsMiddleware() {
|
||||
return session({
|
||||
// 900 day session cookie
|
||||
cookie: { maxAge: 900 * 24 * 60 * 60 * 1000 },
|
||||
// resave forces session to be resaved
|
||||
// regardless of whether it was modified
|
||||
// this causes race conditions during parallel req
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
secret: sessionSecret,
|
||||
store: new MongoStore({ url })
|
||||
});
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
{
|
||||
"_meta": {
|
||||
"sources": [
|
||||
"loopback/common/models",
|
||||
"loopback/server/models",
|
||||
"../common/models",
|
||||
"./models"
|
||||
]
|
||||
},
|
||||
"about": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"AuthToken": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"AccessToken": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"ACL": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"block": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"challenge": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"Donation": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"Email": {
|
||||
"dataSource": "mail",
|
||||
"public": false
|
||||
},
|
||||
"nonprofit": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"pledge": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"Role": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"RoleMapping": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"userCredential": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"userIdentity": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"user": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"User": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"article": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
},
|
||||
"popularity": {
|
||||
"dataSource": "db",
|
||||
"public": true
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import { createActiveUsers } from '../utils/about.js';
|
||||
|
||||
module.exports = function(About) {
|
||||
const activeUsers = createActiveUsers();
|
||||
let activeUsersForRendering = 0;
|
||||
About.getActiveUsers = async function getActiveUsers() {
|
||||
// converting to promise automatically will subscribe to Observable
|
||||
// initiating the sequence above
|
||||
const usersActive = await activeUsers.toPromise();
|
||||
activeUsersForRendering = usersActive;
|
||||
return usersActive;
|
||||
};
|
||||
|
||||
About.getActiveUsersForRendering = () => activeUsersForRendering;
|
||||
|
||||
About.remoteMethod('getActiveUsers', {
|
||||
http: {
|
||||
path: '/get-active-users',
|
||||
verb: 'get'
|
||||
},
|
||||
returns: {
|
||||
type: 'number',
|
||||
arg: 'activeUsers'
|
||||
}
|
||||
});
|
||||
};
|
@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "about",
|
||||
"plural": "about",
|
||||
"base": "PersistedModel",
|
||||
"idInjection": true,
|
||||
"options": {
|
||||
"validateUpsert": true
|
||||
},
|
||||
"properties": {},
|
||||
"validations": [],
|
||||
"relations": {},
|
||||
"acls": [
|
||||
{
|
||||
"accessType": "*",
|
||||
"principalType": "ROLE",
|
||||
"principalId": "$everyone",
|
||||
"permission": "DENY"
|
||||
},
|
||||
{
|
||||
"accessType": "EXECUTE",
|
||||
"principalType": "ROLE",
|
||||
"principalId": "$everyone",
|
||||
"permission": "ALLOW",
|
||||
"property": "getActiveUsers"
|
||||
}
|
||||
],
|
||||
"methods": {}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { Observable } from 'rx';
|
||||
|
||||
export default function(AuthToken) {
|
||||
AuthToken.on('dataSourceAttached', () => {
|
||||
AuthToken.findOne$ = Observable.fromNodeCallback(
|
||||
AuthToken.findOne.bind(AuthToken)
|
||||
);
|
||||
AuthToken.prototype.validate$ = Observable.fromNodeCallback(
|
||||
AuthToken.prototype.validate
|
||||
);
|
||||
AuthToken.prototype.destroy$ = Observable.fromNodeCallback(
|
||||
AuthToken.prototype.destroy
|
||||
);
|
||||
});
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "AuthToken",
|
||||
"base": "AccessToken",
|
||||
"idInjection": true,
|
||||
"options": {
|
||||
"validateUpsert": true
|
||||
},
|
||||
"properties": {},
|
||||
"validations": [],
|
||||
"relations": {},
|
||||
"acls": [],
|
||||
"methods": {}
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
import { Observable } from 'rx';
|
||||
import debug from 'debug';
|
||||
|
||||
import { reportError } from '../middlewares/sentry-error-handler.js';
|
||||
import InMemoryCache from '../utils/in-memory-cache';
|
||||
|
||||
const log = debug('fcc:boot:donate');
|
||||
const fiveMinutes = 1000 * 60 * 5;
|
||||
|
||||
export default function(Donation) {
|
||||
let activeDonationUpdateInterval = null;
|
||||
const activeDonationCountCacheTTL = fiveMinutes;
|
||||
const activeDonationCountCache = InMemoryCache(0, reportError);
|
||||
const activeDonationsQuery$ = () =>
|
||||
Donation.find$({
|
||||
// eslint-disable-next-line no-undefined
|
||||
where: { endDate: undefined }
|
||||
}).map(instances => instances.length);
|
||||
function cleanUp() {
|
||||
if (activeDonationUpdateInterval) {
|
||||
clearInterval(activeDonationUpdateInterval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.on('exit', cleanUp);
|
||||
|
||||
Donation.on('dataSourceAttached', () => {
|
||||
Donation.find$ = Observable.fromNodeCallback(Donation.find.bind(Donation));
|
||||
Donation.findOne$ = Observable.fromNodeCallback(
|
||||
Donation.findOne.bind(Donation)
|
||||
);
|
||||
|
||||
seedTheCache()
|
||||
.then(setupCacheUpdateInterval)
|
||||
.catch(err => {
|
||||
const errMsg = `Error caught seeding the cache: ${err.message}`;
|
||||
err.message = errMsg;
|
||||
reportError(err);
|
||||
});
|
||||
});
|
||||
|
||||
function seedTheCache() {
|
||||
return new Promise((resolve, reject) =>
|
||||
Observable.defer(activeDonationsQuery$).subscribe(count => {
|
||||
log('activeDonor count: %d', count);
|
||||
activeDonationCountCache.update(() => count);
|
||||
return resolve();
|
||||
}, reject)
|
||||
);
|
||||
}
|
||||
|
||||
function setupCacheUpdateInterval() {
|
||||
activeDonationUpdateInterval = setInterval(
|
||||
() =>
|
||||
Observable.defer(activeDonationsQuery$).subscribe(
|
||||
count => {
|
||||
log('activeDonor count: %d', count);
|
||||
return activeDonationCountCache.update(() => count);
|
||||
},
|
||||
err => {
|
||||
const errMsg = `Error caught updating the cache: ${err.message}`;
|
||||
err.message = errMsg;
|
||||
reportError(err);
|
||||
}
|
||||
),
|
||||
activeDonationCountCacheTTL
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCurrentActiveDonationCount$() {
|
||||
return Observable.of(activeDonationCountCache.get());
|
||||
}
|
||||
|
||||
Donation.getCurrentActiveDonationCount$ = getCurrentActiveDonationCount$;
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
{
|
||||
"name": "Donation",
|
||||
"description": "A representation of a donation to freeCodeCamp",
|
||||
"plural": "donations",
|
||||
"base": "PersistedModel",
|
||||
"idInjection": true,
|
||||
"scopes": {},
|
||||
"indexes": {},
|
||||
"options": {
|
||||
"validateUpsert": true
|
||||
},
|
||||
"hidden": [],
|
||||
"remoting": {},
|
||||
"http": {},
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"description": "The email used to create the donation"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"description": "The payment handler, paypal/stripe etc..."
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"description": "The donation amount in cents"
|
||||
},
|
||||
"duration": {
|
||||
"type": "string"
|
||||
},
|
||||
"startDate": {
|
||||
"type": "DateString",
|
||||
"required": true
|
||||
},
|
||||
"endDate": {
|
||||
"type": "DateString"
|
||||
},
|
||||
"subscriptionId": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"description": "The donation subscription id returned from the provider"
|
||||
},
|
||||
"customerId": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"description": "The providers reference for the donor"
|
||||
}
|
||||
},
|
||||
"validations": [
|
||||
{
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"description": "Amount should be >= $1 (100c)",
|
||||
"min": 100
|
||||
},
|
||||
"facetName": "server"
|
||||
}
|
||||
],
|
||||
"relations": {
|
||||
"user": {
|
||||
"type": "belongsTo",
|
||||
"model": "user",
|
||||
"foreignKey": "userId"
|
||||
}
|
||||
},
|
||||
"acls": [],
|
||||
"methods": {}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import { auth0 } from '../../config/secrets';
|
||||
import { homeLocation, apiLocation } from '../../config/env';
|
||||
|
||||
const { clientID, clientSecret, domain } = auth0;
|
||||
|
||||
// These don't seem to be used, can they go?
|
||||
const successRedirect = `${homeLocation}/learn`;
|
||||
const failureRedirect = `${homeLocation}/signin`;
|
||||
|
||||
export default {
|
||||
devlogin: {
|
||||
authScheme: 'mock',
|
||||
provider: 'dev',
|
||||
module: 'passport-mock-strategy'
|
||||
},
|
||||
local: {
|
||||
provider: 'local',
|
||||
module: 'passport-local',
|
||||
usernameField: 'email',
|
||||
passwordField: 'password',
|
||||
authPath: '/auth/local',
|
||||
successRedirect: successRedirect,
|
||||
failureRedirect: failureRedirect,
|
||||
session: true,
|
||||
failureFlash: true
|
||||
},
|
||||
'auth0-login': {
|
||||
provider: 'auth0',
|
||||
module: 'passport-auth0',
|
||||
clientID,
|
||||
clientSecret,
|
||||
domain,
|
||||
cookieDomain: process.env.COOKIE_DOMAIN || 'localhost',
|
||||
callbackURL: `${apiLocation}/auth/auth0/callback`,
|
||||
authPath: '/auth/auth0',
|
||||
callbackPath: '/auth/auth0/callback',
|
||||
useCustomCallback: true,
|
||||
passReqToCallback: true,
|
||||
state: false,
|
||||
successRedirect: successRedirect,
|
||||
failureRedirect: failureRedirect,
|
||||
scope: ['openid profile email'],
|
||||
failureFlash: true
|
||||
}
|
||||
};
|
@ -1,78 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import compareDesc from 'date-fns/compare_desc';
|
||||
import debug from 'debug';
|
||||
|
||||
import { getLybsynFeed } from './lybsyn';
|
||||
|
||||
const log = debug('fcc:rss:news-feed');
|
||||
|
||||
const fiveMinutes = 1000 * 60 * 5;
|
||||
|
||||
class NewsFeed {
|
||||
constructor() {
|
||||
this.state = {
|
||||
readyState: false,
|
||||
lybsynFeed: [],
|
||||
combinedFeed: []
|
||||
};
|
||||
this.refreshFeeds();
|
||||
|
||||
setInterval(this.refreshFeeds, fiveMinutes);
|
||||
}
|
||||
|
||||
setState = stateUpdater => {
|
||||
const newState = stateUpdater(this.state);
|
||||
this.state = _.merge({}, this.state, newState);
|
||||
return;
|
||||
};
|
||||
|
||||
refreshFeeds = () => {
|
||||
const currentFeed = this.state.combinedFeed.slice(0);
|
||||
log('grabbing feeds');
|
||||
return Promise.all([getLybsynFeed()])
|
||||
.then(([lybsynFeed]) =>
|
||||
this.setState(state => ({
|
||||
...state,
|
||||
lybsynFeed
|
||||
}))
|
||||
)
|
||||
.then(() => {
|
||||
log('crossing the streams');
|
||||
const { lybsynFeed } = this.state;
|
||||
const combinedFeed = [...lybsynFeed].sort((a, b) => {
|
||||
return compareDesc(a.isoDate, b.isoDate);
|
||||
});
|
||||
this.setState(state => ({
|
||||
...state,
|
||||
combinedFeed,
|
||||
readyState: true
|
||||
}));
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
this.setState(state => ({
|
||||
...state,
|
||||
combinedFeed: currentFeed
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
getFeed = () =>
|
||||
new Promise(resolve => {
|
||||
let notReadyCount = 0;
|
||||
|
||||
function waitForReady() {
|
||||
log('notReadyCount', notReadyCount);
|
||||
notReadyCount++;
|
||||
return this.state.readyState || notReadyCount === 5
|
||||
? resolve(this.state.combinedFeed)
|
||||
: setTimeout(waitForReady, 100);
|
||||
}
|
||||
log('are we ready?', this.state.readyState);
|
||||
return this.state.readyState
|
||||
? resolve(this.state.combinedFeed)
|
||||
: setTimeout(waitForReady, 100);
|
||||
});
|
||||
}
|
||||
|
||||
export default NewsFeed;
|
@ -1,48 +0,0 @@
|
||||
import http from 'http';
|
||||
import _ from 'lodash';
|
||||
|
||||
const lybsynFeed = 'http://freecodecamp.libsyn.com/render-type/json';
|
||||
|
||||
export function getLybsynFeed() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(lybsynFeed, res => {
|
||||
let raw = '';
|
||||
|
||||
res.on('data', chunk => {
|
||||
raw += chunk;
|
||||
});
|
||||
|
||||
res.on('error', err => reject(err));
|
||||
|
||||
res.on('end', () => {
|
||||
let feed = [];
|
||||
|
||||
try {
|
||||
feed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
const items = feed
|
||||
.map(item =>
|
||||
_.pick(item, [
|
||||
'full_item_url',
|
||||
'item_title',
|
||||
'release_date',
|
||||
'item_body_short'
|
||||
])
|
||||
)
|
||||
/* eslint-disable camelcase */
|
||||
.map(
|
||||
({ full_item_url, item_title, release_date, item_body_short }) => ({
|
||||
title: item_title,
|
||||
extract: item_body_short,
|
||||
isoDate: new Date(release_date).toISOString(),
|
||||
link: full_item_url
|
||||
})
|
||||
);
|
||||
/* eslint-enable camelcase */
|
||||
return resolve(items);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -1,214 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
export const mockCancellationHook = {
|
||||
headers: {
|
||||
host: 'a47fb0f4.ngrok.io',
|
||||
accept: '*/*',
|
||||
'paypal-transmission-id': '2e24bc40-61d1-11ea-8ac4-7d4e2605c70c',
|
||||
'paypal-transmission-time': '2020-03-09T06:42:43Z',
|
||||
'paypal-transmission-sig': 'ODCa4gXmfnxkNga1t9p2HTIWFjlTj68P7MhueQd',
|
||||
'paypal-auth-version': 'v2',
|
||||
'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs',
|
||||
'paypal-auth-algo': 'SHA256withRSA',
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'PayPal/AUHD-214.0-54280748',
|
||||
'correlation-id': 'c3823d4c07ce5',
|
||||
cal_poolstack: 'amqunphttpdeliveryd:UNPHTTPDELIVERY',
|
||||
client_pid: '23853',
|
||||
'content-length': '1706',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '173.0.82.126'
|
||||
},
|
||||
body: {
|
||||
id: 'WH-1VF24938EU372274X-83540367M0110254R',
|
||||
event_version: '1.0',
|
||||
create_time: '2020-03-06T15:34:50.000Z',
|
||||
resource_type: 'subscription',
|
||||
resource_version: '2.0',
|
||||
event_type: 'BILLING.SUBSCRIPTION.CANCELLED',
|
||||
summary: 'Subscription cancelled',
|
||||
resource: {
|
||||
shipping_amount: { currency_code: 'USD', value: '0.0' },
|
||||
start_time: '2020-03-05T08:00:00Z',
|
||||
update_time: '2020-03-09T06:42:09Z',
|
||||
quantity: '1',
|
||||
subscriber: {
|
||||
name: [Object],
|
||||
email_address: 'sb-zdry81054163@personal.example.com',
|
||||
payer_id: '82PVXVLDAU3E8',
|
||||
shipping_address: [Object]
|
||||
},
|
||||
billing_info: {
|
||||
outstanding_balance: [Object],
|
||||
cycle_executions: [Array],
|
||||
last_payment: [Object],
|
||||
next_billing_time: '2020-04-05T10:00:00Z',
|
||||
failed_payments_count: 0
|
||||
},
|
||||
create_time: '2020-03-06T07:34:50Z',
|
||||
links: [[Object]],
|
||||
id: 'I-BA1ATBNF8T3P',
|
||||
plan_id: 'P-6VP46874PR423771HLZDKFBA',
|
||||
status: 'CANCELLED',
|
||||
status_update_time: '2020-03-09T06:42:09Z'
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href:
|
||||
'https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-1VF24938EU372274X-83540367M0110254R',
|
||||
rel: 'self',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
href:
|
||||
'https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-1VF24938EU372274X-83540367M0110254R/resend',
|
||||
rel: 'resend',
|
||||
method: 'POST'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
export const mockActivationHook = {
|
||||
headers: {
|
||||
host: 'a47fb0f4.ngrok.io',
|
||||
accept: '*/*',
|
||||
'paypal-transmission-id': '22103660-5f7d-11ea-8ac4-7d4e2605c70c',
|
||||
'paypal-transmission-time': '2020-03-06T07:36:03Z',
|
||||
'paypal-transmission-sig':
|
||||
'a;sldfn;lqwjhepjtn12l3n5123mnpu1i-sc-_+++dsflqenwpk1n234uthmsqwr123',
|
||||
'paypal-auth-version': 'v2',
|
||||
'paypal-cert-url':
|
||||
'https://api.sandbox.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-1d93a270',
|
||||
'paypal-auth-algo': 'SHASHASHA',
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'PayPal/AUHD-214.0-54280748',
|
||||
'correlation-id': 'e0b25772e11af',
|
||||
client_pid: '14973',
|
||||
'content-length': '2201',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-for': '173.0.82.126'
|
||||
},
|
||||
body: {
|
||||
id: 'WH-77687562XN25889J8-8Y6T55435R66168T6',
|
||||
create_time: '2018-19-12T22:20:32.000Z',
|
||||
resource_type: 'subscription',
|
||||
event_type: 'BILLING.SUBSCRIPTION.ACTIVATED',
|
||||
summary: 'A billing agreement was activated.',
|
||||
resource: {
|
||||
quantity: '20',
|
||||
subscriber: {
|
||||
name: {
|
||||
given_name: 'John',
|
||||
surname: 'Doe'
|
||||
},
|
||||
email_address: 'donor@freecodecamp.com',
|
||||
shipping_address: {
|
||||
name: {
|
||||
full_name: 'John Doe'
|
||||
},
|
||||
address: {
|
||||
address_line_1: '2211 N First Street',
|
||||
address_line_2: 'Building 17',
|
||||
admin_area_2: 'San Jose',
|
||||
admin_area_1: 'CA',
|
||||
postal_code: '95131',
|
||||
country_code: 'US'
|
||||
}
|
||||
}
|
||||
},
|
||||
create_time: '2018-12-10T21:20:49Z',
|
||||
shipping_amount: {
|
||||
currency_code: 'USD',
|
||||
value: '10.00'
|
||||
},
|
||||
start_time: '2018-11-01T00:00:00Z',
|
||||
update_time: '2018-12-10T21:20:49Z',
|
||||
billing_info: {
|
||||
outstanding_balance: {
|
||||
currency_code: 'USD',
|
||||
value: '10.00'
|
||||
},
|
||||
cycle_executions: [
|
||||
{
|
||||
tenure_type: 'TRIAL',
|
||||
sequence: 1,
|
||||
cycles_completed: 1,
|
||||
cycles_remaining: 0,
|
||||
current_pricing_scheme_version: 1
|
||||
},
|
||||
{
|
||||
tenure_type: 'REGULAR',
|
||||
sequence: 2,
|
||||
cycles_completed: 1,
|
||||
cycles_remaining: 0,
|
||||
current_pricing_scheme_version: 2
|
||||
}
|
||||
],
|
||||
last_payment: {
|
||||
amount: {
|
||||
currency_code: 'USD',
|
||||
value: '500.00'
|
||||
},
|
||||
time: '2018-12-01T01:20:49Z'
|
||||
},
|
||||
next_billing_time: '2019-01-01T00:20:49Z',
|
||||
final_payment_time: '2020-01-01T00:20:49Z',
|
||||
failed_payments_count: 2
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href:
|
||||
'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G',
|
||||
rel: 'self',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
href:
|
||||
'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G',
|
||||
rel: 'edit',
|
||||
method: 'PATCH'
|
||||
},
|
||||
{
|
||||
href:
|
||||
'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/suspend',
|
||||
rel: 'suspend',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
href:
|
||||
'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/cancel',
|
||||
rel: 'cancel',
|
||||
method: 'POST'
|
||||
},
|
||||
{
|
||||
href:
|
||||
'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/capture',
|
||||
rel: 'capture',
|
||||
method: 'POST'
|
||||
}
|
||||
],
|
||||
id: 'I-BW452GLLEP1G',
|
||||
plan_id: 'P-5ML4271244454362WXNWU5NQ',
|
||||
auto_renewal: true,
|
||||
status: 'ACTIVE',
|
||||
status_update_time: '2018-12-10T21:20:49Z'
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href:
|
||||
'https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J8-8Y6T55435R66168T6',
|
||||
rel: 'self',
|
||||
method: 'GET',
|
||||
encType: 'application/json'
|
||||
},
|
||||
{
|
||||
href:
|
||||
'https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J8-8Y6T55435R66168T6/resend',
|
||||
rel: 'resend',
|
||||
method: 'POST',
|
||||
encType: 'application/json'
|
||||
}
|
||||
],
|
||||
event_version: '1.0',
|
||||
resource_version: '2.0'
|
||||
}
|
||||
};
|
@ -1,93 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import debug from 'debug';
|
||||
import dedent from 'dedent';
|
||||
import fs from 'fs';
|
||||
import { google } from 'googleapis';
|
||||
import { Observable } from 'rx';
|
||||
|
||||
import { timeCache, observeMethod } from './rx';
|
||||
|
||||
// one million!
|
||||
const upperBound = 1000 * 1000;
|
||||
const scope = 'https://www.googleapis.com/auth/analytics.readonly';
|
||||
const pathToCred = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
|
||||
const log = debug('fcc:server:utils:about');
|
||||
const analytics = google.analytics('v3');
|
||||
const makeRequest = observeMethod(analytics.data.realtime, 'get');
|
||||
export const toBoundInt = _.flow(
|
||||
// first convert string to integer
|
||||
_.toInteger,
|
||||
// then we bound the integer to prevent weird things like Infinity
|
||||
// and negative numbers
|
||||
// can't wait to the day we need to update this!
|
||||
_.partialRight(_.clamp, 0, upperBound)
|
||||
);
|
||||
|
||||
export function createActiveUsers() {
|
||||
const zero = Observable.of(0);
|
||||
let credentials;
|
||||
if (!pathToCred) {
|
||||
// if no path to credentials set to zero;
|
||||
log(dedent`
|
||||
no google applications credentials environmental variable found
|
||||
'GOOGLE_APPLICATION_CREDENTIALS'
|
||||
'activeUser' api will always return 0
|
||||
this can safely be ignored during development
|
||||
`);
|
||||
return zero;
|
||||
}
|
||||
try {
|
||||
credentials = require(fs.realpathSync(pathToCred));
|
||||
} catch (err) {
|
||||
log('google applications credentials file failed to require');
|
||||
console.error(err);
|
||||
// if we can't require credentials set to zero;
|
||||
return zero;
|
||||
}
|
||||
if (
|
||||
!credentials.private_key ||
|
||||
!credentials.client_email ||
|
||||
!credentials.viewId
|
||||
) {
|
||||
log(dedent`
|
||||
google applications credentials json should have a
|
||||
* private_key
|
||||
* client_email
|
||||
* viewId
|
||||
but none were found
|
||||
`);
|
||||
return zero;
|
||||
}
|
||||
|
||||
const client = new google.auth.JWT(
|
||||
credentials['client_email'],
|
||||
null,
|
||||
credentials['private_key'],
|
||||
[scope]
|
||||
);
|
||||
const authorize = observeMethod(client, 'authorize');
|
||||
const options = {
|
||||
ids: `ga:${credentials.viewId}`,
|
||||
auth: client,
|
||||
metrics: 'rt:activeUsers'
|
||||
};
|
||||
return Observable.defer(
|
||||
// we wait for authorize to complete before attempting to make request
|
||||
// this ensures our token is initialized and valid
|
||||
// we defer here to make sure the actual request is done per subscription
|
||||
// instead of once at startup
|
||||
() => authorize().flatMap(() => makeRequest(options))
|
||||
)
|
||||
// data: Array[body|Object, request: Request]
|
||||
.map(data => data[0])
|
||||
.map(
|
||||
({ totalsForAllResults } = {}) => totalsForAllResults['rt:activeUsers']
|
||||
)
|
||||
.map(toBoundInt)
|
||||
// print errors to error log for logging, duh
|
||||
.do(null, err => console.error(err))
|
||||
// always send a number down
|
||||
.catch(() => Observable.of(0))
|
||||
::timeCache(2, 'seconds');
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
const githubRegex = /github/i;
|
||||
const providerHash = {
|
||||
facebook: ({ id }) => id,
|
||||
github: ({ username }) => username,
|
||||
twitter: ({ username }) => username,
|
||||
linkedin({ _json }) {
|
||||
return (_json && _json.publicProfileUrl) || null;
|
||||
},
|
||||
google: ({ id }) => id
|
||||
};
|
||||
|
||||
export function getUsernameFromProvider(provider, profile) {
|
||||
return typeof providerHash[provider] === 'function'
|
||||
? providerHash[provider](profile)
|
||||
: null;
|
||||
}
|
||||
|
||||
// createProfileAttributes(provider: String, profile: {}) => Object
|
||||
export function createUserUpdatesFromProfile(provider, profile) {
|
||||
if (githubRegex.test(provider)) {
|
||||
return createProfileAttributesFromGithub(profile);
|
||||
}
|
||||
return {
|
||||
[getSocialProvider(provider)]: getUsernameFromProvider(
|
||||
getSocialProvider(provider),
|
||||
profile
|
||||
)
|
||||
};
|
||||
}
|
||||
// createProfileAttributes(profile) => profileUpdate
|
||||
function createProfileAttributesFromGithub(profile) {
|
||||
const {
|
||||
profileUrl: githubProfile,
|
||||
username,
|
||||
_json: { avatar_url: picture, blog: website, location, bio, name } = {}
|
||||
} = profile;
|
||||
return {
|
||||
name,
|
||||
username: username.toLowerCase(),
|
||||
location,
|
||||
bio,
|
||||
website,
|
||||
picture,
|
||||
githubProfile
|
||||
};
|
||||
}
|
||||
|
||||
export function getSocialProvider(provider) {
|
||||
return provider.split('-')[0];
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
export default {
|
||||
bg9997c9c79feddfaeb9bdef: '56bbb991ad1ed5201cd392ca',
|
||||
bg9995c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cb',
|
||||
bg9994c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cc',
|
||||
bg9996c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cd',
|
||||
bg9997c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392ce',
|
||||
bg9997c9c89feddfaeb9bdef: '56bbb991ad1ed5201cd392cf',
|
||||
bg9998c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d0',
|
||||
bg9999c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d1',
|
||||
bg9999c9c99feedfaeb9bdef: '56bbb991ad1ed5201cd392d2',
|
||||
bg9999c9c99fdddfaeb9bdef: '56bbb991ad1ed5201cd392d3',
|
||||
bb000000000000000000001: '56bbb991ad1ed5201cd392d4',
|
||||
bc000000000000000000001: '56bbb991ad1ed5201cd392d5',
|
||||
bb000000000000000000002: '56bbb991ad1ed5201cd392d6',
|
||||
bb000000000000000000003: '56bbb991ad1ed5201cd392d7',
|
||||
bb000000000000000000004: '56bbb991ad1ed5201cd392d8',
|
||||
bb000000000000000000005: '56bbb991ad1ed5201cd392d9',
|
||||
bb000000000000000000006: '56bbb991ad1ed5201cd392da'
|
||||
};
|
@ -1,11 +0,0 @@
|
||||
import { Observable, helpers } from 'rx';
|
||||
|
||||
export default function castToObservable(maybe) {
|
||||
if (Observable.isObservable(maybe)) {
|
||||
return maybe;
|
||||
}
|
||||
if (helpers.isPromise(maybe)) {
|
||||
return Observable.fromPromise(maybe);
|
||||
}
|
||||
return Observable.of(maybe);
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
{
|
||||
"frontEnd": "isFrontEndCert",
|
||||
"backEnd": "isBackEndCert",
|
||||
"dataVis": "isDataVisCert",
|
||||
"respWebDesign": "isRespWebDesignCert",
|
||||
"frontEndLibs": "isFrontEndLibsCert",
|
||||
"dataVis2018": "is2018DataVisCert",
|
||||
"jsAlgoDataStruct": "isJsAlgoDataStructCert",
|
||||
"apisMicroservices": "isApisMicroservicesCert",
|
||||
"infosecQa": "isInfosecQaCert",
|
||||
"qaV7": "isQaCertV7",
|
||||
"infosecV7": "isInfosecCertV7",
|
||||
"sciCompPyV7": "isSciCompPyCertV7",
|
||||
"dataAnalysisPyV7": "isDataAnalysisPyCertV7",
|
||||
"machineLearningPyV7": "isMachineLearningPyCertV7",
|
||||
"fullStack": "isFullStackCert"
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
{
|
||||
"gitHubUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1521.3 Safari/537.36",
|
||||
|
||||
"legacyFrontEndChallengeId": "561add10cb82ac38a17513be",
|
||||
"legacyBackEndChallengeId": "660add10cb82ac38a17513be",
|
||||
"legacyDataVisId": "561add10cb82ac39a17513bc",
|
||||
"legacyInfosecQaId": "561add10cb82ac38a17213bc",
|
||||
"legacyFullStackId": "561add10cb82ac38a17213bd",
|
||||
|
||||
"respWebDesignId": "561add10cb82ac38a17513bc",
|
||||
"frontEndLibsId": "561acd10cb82ac38a17513bc",
|
||||
"dataVis2018Id": "5a553ca864b52e1d8bceea14",
|
||||
"jsAlgoDataStructId": "561abd10cb81ac38a17513bc",
|
||||
"apisMicroservicesId": "561add10cb82ac38a17523bc",
|
||||
"qaV7Id": "5e611829481575a52dc59c0e",
|
||||
"infosecV7Id": "5e6021435ac9d0ecd8b94b00",
|
||||
"sciCompPyV7Id": "5e44431b903586ffb414c951",
|
||||
"dataAnalysisPyV7Id": "5e46fc95ac417301a38fb934",
|
||||
"machineLearningPyV7Id": "5e46fc95ac417301a38fb935"
|
||||
}
|
@ -1,651 +0,0 @@
|
||||
let alphabet = '';
|
||||
|
||||
for (let i = 0; i < 26; i++) {
|
||||
alphabet = alphabet.concat(String.fromCharCode(97 + i));
|
||||
}
|
||||
|
||||
let blocklist = [
|
||||
...alphabet.split(''),
|
||||
'about',
|
||||
'academic-honesty',
|
||||
'account',
|
||||
'agile',
|
||||
'all-stories',
|
||||
'api',
|
||||
'backend-challenge-completed',
|
||||
'bonfire',
|
||||
'cats.json',
|
||||
'challenge-completed',
|
||||
'challenge',
|
||||
'challenges',
|
||||
'chat',
|
||||
'code-of-conduct',
|
||||
'coding-bootcamp-cost-calculator',
|
||||
'completed-bonfire',
|
||||
'completed-challenge',
|
||||
'completed-field-guide',
|
||||
'completed-zipline-or-basejump',
|
||||
'copyright-policy',
|
||||
'copyright',
|
||||
'deprecated-signin',
|
||||
'donate',
|
||||
'email-signin',
|
||||
'events',
|
||||
'explorer',
|
||||
'external',
|
||||
'field-guide',
|
||||
'forgot',
|
||||
'forum',
|
||||
'get-help',
|
||||
'get-pai',
|
||||
'guide',
|
||||
'how-nonprofit-projects-work',
|
||||
'internal',
|
||||
'jobs-form',
|
||||
'jobs',
|
||||
'learn-to-code',
|
||||
'learn',
|
||||
'login',
|
||||
'logout',
|
||||
'map',
|
||||
'modern-challenge-completed',
|
||||
'news',
|
||||
'nonprofit-project-instructions',
|
||||
'nonprofits-form',
|
||||
'nonprofits',
|
||||
'open-api',
|
||||
'passwordless-change',
|
||||
'pmi-acp-agile-project-managers-form',
|
||||
'pmi-acp-agile-project-managers',
|
||||
'privacy-policy',
|
||||
'privacy',
|
||||
'profile',
|
||||
'project-completed',
|
||||
'reset',
|
||||
'services',
|
||||
'shop',
|
||||
'signin',
|
||||
'signout',
|
||||
'signup',
|
||||
'sitemap.xml',
|
||||
'software-resources-for-nonprofits',
|
||||
'sponsors',
|
||||
'stories',
|
||||
'support',
|
||||
'terms-of-service',
|
||||
'terms',
|
||||
'the-fastest-web-page-on-the-internet',
|
||||
'twitch',
|
||||
'unsubscribe',
|
||||
'unsubscribed',
|
||||
'update-my-portfolio',
|
||||
'update-my-profile-ui',
|
||||
'update-my-projects',
|
||||
'update-my-theme',
|
||||
'update-my-username',
|
||||
'user',
|
||||
'username',
|
||||
'wiki',
|
||||
|
||||
// reserved paths for localizations
|
||||
'afrikaans',
|
||||
'arabic',
|
||||
'bengali',
|
||||
'catalan',
|
||||
'chinese',
|
||||
'czech',
|
||||
'danish',
|
||||
'dutch',
|
||||
'espanol',
|
||||
'finnish',
|
||||
'french',
|
||||
'german',
|
||||
'greek',
|
||||
'hebrew',
|
||||
'hindi',
|
||||
'hungarian',
|
||||
'italian',
|
||||
'japanese',
|
||||
'korean',
|
||||
'norwegian',
|
||||
'polish',
|
||||
'portuguese',
|
||||
'romanian',
|
||||
'russian',
|
||||
'serbian',
|
||||
'spanish',
|
||||
'swahili',
|
||||
'swedish',
|
||||
'turkish',
|
||||
'ukrainian',
|
||||
'vietnamese',
|
||||
|
||||
// some more names from https://github.com/marteinn/The-Big-Username-Blacklist-JS/blob/master/src/list.js
|
||||
'.htaccess',
|
||||
'.htpasswd',
|
||||
'.well-known',
|
||||
'400',
|
||||
'401',
|
||||
'403',
|
||||
'404',
|
||||
'405',
|
||||
'406',
|
||||
'407',
|
||||
'408',
|
||||
'409',
|
||||
'410',
|
||||
'411',
|
||||
'412',
|
||||
'413',
|
||||
'414',
|
||||
'415',
|
||||
'416',
|
||||
'417',
|
||||
'421',
|
||||
'422',
|
||||
'423',
|
||||
'424',
|
||||
'426',
|
||||
'428',
|
||||
'429',
|
||||
'431',
|
||||
'500',
|
||||
'501',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
'505',
|
||||
'506',
|
||||
'507',
|
||||
'508',
|
||||
'509',
|
||||
'510',
|
||||
'511',
|
||||
'about',
|
||||
'about-us',
|
||||
'abuse',
|
||||
'access',
|
||||
'account',
|
||||
'accounts',
|
||||
'ad',
|
||||
'add',
|
||||
'admin',
|
||||
'administration',
|
||||
'administrator',
|
||||
'ads',
|
||||
'advertise',
|
||||
'advertising',
|
||||
'aes128-ctr',
|
||||
'aes128-gcm',
|
||||
'aes192-ctr',
|
||||
'aes256-ctr',
|
||||
'aes256-gcm',
|
||||
'affiliate',
|
||||
'affiliates',
|
||||
'ajax',
|
||||
'alert',
|
||||
'alerts',
|
||||
'alpha',
|
||||
'amp',
|
||||
'analytics',
|
||||
'api',
|
||||
'app',
|
||||
'apps',
|
||||
'asc',
|
||||
'assets',
|
||||
'atom',
|
||||
'auth',
|
||||
'authentication',
|
||||
'authorize',
|
||||
'autoconfig',
|
||||
'autodiscover',
|
||||
'avatar',
|
||||
'backup',
|
||||
'banner',
|
||||
'banners',
|
||||
'beta',
|
||||
'billing',
|
||||
'billings',
|
||||
'blog',
|
||||
'blogs',
|
||||
'board',
|
||||
'bookmark',
|
||||
'bookmarks',
|
||||
'broadcasthost',
|
||||
'business',
|
||||
'buy',
|
||||
'cache',
|
||||
'calendar',
|
||||
'campaign',
|
||||
'captcha',
|
||||
'careers',
|
||||
'cart',
|
||||
'cas',
|
||||
'categories',
|
||||
'category',
|
||||
'cdn',
|
||||
'cgi',
|
||||
'cgi-bin',
|
||||
'chacha20-poly1305',
|
||||
'change',
|
||||
'channel',
|
||||
'channels',
|
||||
'chart',
|
||||
'chat',
|
||||
'checkout',
|
||||
'clear',
|
||||
'client',
|
||||
'close',
|
||||
'cms',
|
||||
'com',
|
||||
'comment',
|
||||
'comments',
|
||||
'community',
|
||||
'compare',
|
||||
'compose',
|
||||
'config',
|
||||
'connect',
|
||||
'contact',
|
||||
'contest',
|
||||
'cookies',
|
||||
'copy',
|
||||
'copyright',
|
||||
'count',
|
||||
'create',
|
||||
'crossdomain.xml',
|
||||
'css',
|
||||
'curve25519-sha256',
|
||||
'customer',
|
||||
'customers',
|
||||
'customize',
|
||||
'dashboard',
|
||||
'db',
|
||||
'deals',
|
||||
'debug',
|
||||
'delete',
|
||||
'desc',
|
||||
'destroy',
|
||||
'dev',
|
||||
'developer',
|
||||
'developers',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'disconnect',
|
||||
'discuss',
|
||||
'dns',
|
||||
'dns0',
|
||||
'dns1',
|
||||
'dns2',
|
||||
'dns3',
|
||||
'dns4',
|
||||
'docs',
|
||||
'documentation',
|
||||
'domain',
|
||||
'download',
|
||||
'downloads',
|
||||
'downvote',
|
||||
'draft',
|
||||
'drop',
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521',
|
||||
'edit',
|
||||
'editor',
|
||||
'email',
|
||||
'enterprise',
|
||||
'error',
|
||||
'errors',
|
||||
'event',
|
||||
'events',
|
||||
'example',
|
||||
'exception',
|
||||
'exit',
|
||||
'explore',
|
||||
'export',
|
||||
'extensions',
|
||||
'false',
|
||||
'family',
|
||||
'faq',
|
||||
'faqs',
|
||||
'favicon.ico',
|
||||
'features',
|
||||
'feed',
|
||||
'feedback',
|
||||
'feeds',
|
||||
'file',
|
||||
'files',
|
||||
'filter',
|
||||
'follow',
|
||||
'follower',
|
||||
'followers',
|
||||
'following',
|
||||
'fonts',
|
||||
'forgot',
|
||||
'forgot-password',
|
||||
'forgotpassword',
|
||||
'form',
|
||||
'forms',
|
||||
'forum',
|
||||
'forums',
|
||||
'friend',
|
||||
'friends',
|
||||
'ftp',
|
||||
'get',
|
||||
'git',
|
||||
'go',
|
||||
'group',
|
||||
'groups',
|
||||
'guest',
|
||||
'guidelines',
|
||||
'guides',
|
||||
'head',
|
||||
'header',
|
||||
'help',
|
||||
'hide',
|
||||
'hmac-sha',
|
||||
'hmac-sha1',
|
||||
'hmac-sha1-etm',
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha2-256-etm',
|
||||
'hmac-sha2-512',
|
||||
'hmac-sha2-512-etm',
|
||||
'home',
|
||||
'host',
|
||||
'hosting',
|
||||
'hostmaster',
|
||||
'htpasswd',
|
||||
'http',
|
||||
'httpd',
|
||||
'https',
|
||||
'humans.txt',
|
||||
'icons',
|
||||
'images',
|
||||
'imap',
|
||||
'img',
|
||||
'import',
|
||||
'index',
|
||||
'info',
|
||||
'insert',
|
||||
'investors',
|
||||
'invitations',
|
||||
'invite',
|
||||
'invites',
|
||||
'invoice',
|
||||
'is',
|
||||
'isatap',
|
||||
'issues',
|
||||
'it',
|
||||
'jobs',
|
||||
'join',
|
||||
'js',
|
||||
'json',
|
||||
'keybase.txt',
|
||||
'learn',
|
||||
'legal',
|
||||
'license',
|
||||
'licensing',
|
||||
'like',
|
||||
'limit',
|
||||
'live',
|
||||
'load',
|
||||
'local',
|
||||
'localdomain',
|
||||
'localhost',
|
||||
'lock',
|
||||
'login',
|
||||
'logout',
|
||||
'lost-password',
|
||||
'mail',
|
||||
'mail0',
|
||||
'mail1',
|
||||
'mail2',
|
||||
'mail3',
|
||||
'mail4',
|
||||
'mail5',
|
||||
'mail6',
|
||||
'mail7',
|
||||
'mail8',
|
||||
'mail9',
|
||||
'mailer-daemon',
|
||||
'mailerdaemon',
|
||||
'map',
|
||||
'marketing',
|
||||
'marketplace',
|
||||
'master',
|
||||
'me',
|
||||
'media',
|
||||
'member',
|
||||
'members',
|
||||
'message',
|
||||
'messages',
|
||||
'metrics',
|
||||
'mis',
|
||||
'mobile',
|
||||
'moderator',
|
||||
'modify',
|
||||
'more',
|
||||
'mx',
|
||||
'my',
|
||||
'net',
|
||||
'network',
|
||||
'new',
|
||||
'news',
|
||||
'newsletter',
|
||||
'newsletters',
|
||||
'next',
|
||||
'nil',
|
||||
'no-reply',
|
||||
'nobody',
|
||||
'noc',
|
||||
'none',
|
||||
'noreply',
|
||||
'notification',
|
||||
'notifications',
|
||||
'ns',
|
||||
'ns0',
|
||||
'ns1',
|
||||
'ns2',
|
||||
'ns3',
|
||||
'ns4',
|
||||
'ns5',
|
||||
'ns6',
|
||||
'ns7',
|
||||
'ns8',
|
||||
'ns9',
|
||||
'null',
|
||||
'oauth',
|
||||
'oauth2',
|
||||
'offer',
|
||||
'offers',
|
||||
'online',
|
||||
'openid',
|
||||
'order',
|
||||
'orders',
|
||||
'overview',
|
||||
'owner',
|
||||
'page',
|
||||
'pages',
|
||||
'partners',
|
||||
'passwd',
|
||||
'password',
|
||||
'pay',
|
||||
'payment',
|
||||
'payments',
|
||||
'photo',
|
||||
'photos',
|
||||
'pixel',
|
||||
'plans',
|
||||
'plugins',
|
||||
'policies',
|
||||
'policy',
|
||||
'pop',
|
||||
'pop3',
|
||||
'popular',
|
||||
'portfolio',
|
||||
'post',
|
||||
'postfix',
|
||||
'postmaster',
|
||||
'poweruser',
|
||||
'preferences',
|
||||
'premium',
|
||||
'press',
|
||||
'previous',
|
||||
'pricing',
|
||||
'print',
|
||||
'privacy',
|
||||
'privacy-policy',
|
||||
'private',
|
||||
'prod',
|
||||
'product',
|
||||
'production',
|
||||
'profile',
|
||||
'profiles',
|
||||
'project',
|
||||
'projects',
|
||||
'public',
|
||||
'purchase',
|
||||
'put',
|
||||
'quota',
|
||||
'redirect',
|
||||
'reduce',
|
||||
'refund',
|
||||
'refunds',
|
||||
'register',
|
||||
'registration',
|
||||
'remove',
|
||||
'replies',
|
||||
'reply',
|
||||
'report',
|
||||
'request',
|
||||
'request-password',
|
||||
'reset',
|
||||
'reset-password',
|
||||
'response',
|
||||
'return',
|
||||
'returns',
|
||||
'review',
|
||||
'reviews',
|
||||
'robots.txt',
|
||||
'root',
|
||||
'rootuser',
|
||||
'rsa-sha2-2',
|
||||
'rsa-sha2-512',
|
||||
'rss',
|
||||
'rules',
|
||||
'sales',
|
||||
'save',
|
||||
'script',
|
||||
'sdk',
|
||||
'search',
|
||||
'secure',
|
||||
'security',
|
||||
'select',
|
||||
'services',
|
||||
'session',
|
||||
'sessions',
|
||||
'settings',
|
||||
'setup',
|
||||
'share',
|
||||
'shift',
|
||||
'shop',
|
||||
'signin',
|
||||
'signup',
|
||||
'site',
|
||||
'sitemap',
|
||||
'sites',
|
||||
'smtp',
|
||||
'sort',
|
||||
'source',
|
||||
'sql',
|
||||
'ssh',
|
||||
'ssh-rsa',
|
||||
'ssl',
|
||||
'ssladmin',
|
||||
'ssladministrator',
|
||||
'sslwebmaster',
|
||||
'stage',
|
||||
'staging',
|
||||
'stat',
|
||||
'static',
|
||||
'statistics',
|
||||
'stats',
|
||||
'status',
|
||||
'store',
|
||||
'style',
|
||||
'styles',
|
||||
'stylesheet',
|
||||
'stylesheets',
|
||||
'subdomain',
|
||||
'subscribe',
|
||||
'sudo',
|
||||
'super',
|
||||
'superuser',
|
||||
'support',
|
||||
'survey',
|
||||
'sync',
|
||||
'sysadmin',
|
||||
'system',
|
||||
'tablet',
|
||||
'tag',
|
||||
'tags',
|
||||
'team',
|
||||
'telnet',
|
||||
'terms',
|
||||
'terms-of-use',
|
||||
'test',
|
||||
'testimonials',
|
||||
'theme',
|
||||
'themes',
|
||||
'today',
|
||||
'tools',
|
||||
'topic',
|
||||
'topics',
|
||||
'tour',
|
||||
'training',
|
||||
'translate',
|
||||
'translations',
|
||||
'trending',
|
||||
'trial',
|
||||
'true',
|
||||
'umac-128',
|
||||
'umac-128-etm',
|
||||
'umac-64',
|
||||
'umac-64-etm',
|
||||
'undefined',
|
||||
'unfollow',
|
||||
'unlike',
|
||||
'unsubscribe',
|
||||
'update',
|
||||
'upgrade',
|
||||
'usenet',
|
||||
'user',
|
||||
'username',
|
||||
'users',
|
||||
'uucp',
|
||||
'var',
|
||||
'verify',
|
||||
'video',
|
||||
'view',
|
||||
'void',
|
||||
'vote',
|
||||
'webmail',
|
||||
'webmaster',
|
||||
'website',
|
||||
'widget',
|
||||
'widgets',
|
||||
'wiki',
|
||||
'wpad',
|
||||
'write',
|
||||
'www',
|
||||
'www-data',
|
||||
'www1',
|
||||
'www2',
|
||||
'www3',
|
||||
'www4',
|
||||
'you',
|
||||
'yourname',
|
||||
'yourusername',
|
||||
'zlib'
|
||||
];
|
||||
|
||||
export const blocklistedUsernames = [...new Set(blocklist)];
|
@ -1,6 +0,0 @@
|
||||
export function createCookieConfig(req) {
|
||||
return {
|
||||
signed: !!req.signedCookies,
|
||||
domain: process.env.COOKIE_DOMAIN || 'localhost'
|
||||
};
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
const _handledError = Symbol('handledError');
|
||||
|
||||
export function isHandledError(err) {
|
||||
return !!err[_handledError];
|
||||
}
|
||||
|
||||
export function unwrapHandledError(err) {
|
||||
return err[_handledError] || {};
|
||||
}
|
||||
|
||||
export function wrapHandledError(
|
||||
err,
|
||||
{ type, message, redirectTo, status = 200 }
|
||||
) {
|
||||
err[_handledError] = { type, message, redirectTo, status };
|
||||
return err;
|
||||
}
|
||||
|
||||
// for use with express-validator error formatter
|
||||
export const createValidatorErrorFormatter = (type, redirectTo) => ({ msg }) =>
|
||||
wrapHandledError(new Error(msg), {
|
||||
type,
|
||||
message: msg,
|
||||
redirectTo,
|
||||
// we default to 400 as these are malformed requests
|
||||
status: 400
|
||||
});
|
@ -1,18 +0,0 @@
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
// day count between two epochs (inclusive)
|
||||
export function dayCount([head, tail], timezone = 'UTC') {
|
||||
return Math.ceil(
|
||||
moment(
|
||||
moment(head)
|
||||
.tz(timezone)
|
||||
.endOf('day')
|
||||
).diff(
|
||||
moment(tail)
|
||||
.tz(timezone)
|
||||
.startOf('day'),
|
||||
'days',
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
@ -1,88 +0,0 @@
|
||||
/* global describe expect it */
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import { dayCount } from './date-utils';
|
||||
|
||||
const PST = 'America/Los_Angeles';
|
||||
|
||||
describe('date utils', () => {
|
||||
describe('dayCount', () => {
|
||||
it('should return 1 day given epochs of the same day', () => {
|
||||
expect(
|
||||
dayCount([
|
||||
moment.utc('8/3/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it('should return 1 day given same epochs', () => {
|
||||
expect(
|
||||
dayCount([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it('should return 2 days when there is a 24 hours difference', () => {
|
||||
expect(
|
||||
dayCount([
|
||||
moment.utc('8/4/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual(2);
|
||||
});
|
||||
|
||||
it(
|
||||
'should return 2 days when the diff is less than 24h but ' +
|
||||
'different in UTC',
|
||||
() => {
|
||||
expect(
|
||||
dayCount([
|
||||
moment.utc('8/4/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 23:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual(2);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 1 day when the diff is less than 24h ' +
|
||||
'and days are different in UTC, but given PST',
|
||||
() => {
|
||||
expect(
|
||||
dayCount(
|
||||
[
|
||||
moment.utc('8/4/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 23:00', 'M/D/YYYY H:mm').valueOf()
|
||||
],
|
||||
PST
|
||||
)
|
||||
).toEqual(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('should return correct count when there is very big period', () => {
|
||||
expect(
|
||||
dayCount([
|
||||
moment.utc('10/27/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('5/12/1982 1:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual(12222);
|
||||
});
|
||||
|
||||
it(
|
||||
'should return 2 days when there is a 24 hours difference ' +
|
||||
'between dates given `undefined` timezone',
|
||||
() => {
|
||||
expect(
|
||||
dayCount([
|
||||
moment.utc('8/4/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual(2);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
@ -1,173 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
import axios from 'axios';
|
||||
import debug from 'debug';
|
||||
import keys from '../../../config/secrets';
|
||||
|
||||
const log = debug('fcc:boot:donate');
|
||||
|
||||
const paypalverifyWebhookURL =
|
||||
keys.paypal.verifyWebhookURL ||
|
||||
`https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature`;
|
||||
const paypalTokenURL =
|
||||
keys.paypal.tokenUrl || `https://api.sandbox.paypal.com/v1/oauth2/token`;
|
||||
|
||||
export async function getAsyncPaypalToken() {
|
||||
const res = await axios.post(paypalTokenURL, null, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
auth: {
|
||||
username: keys.paypal.client,
|
||||
password: keys.paypal.secret
|
||||
},
|
||||
params: {
|
||||
grant_type: 'client_credentials'
|
||||
}
|
||||
});
|
||||
return res.data.access_token;
|
||||
}
|
||||
|
||||
export function capitalizeKeys(object) {
|
||||
Object.keys(object).forEach(function(key) {
|
||||
object[key.toUpperCase()] = object[key];
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWebHook(headers, body, token, webhookId) {
|
||||
var webhookEventBody = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
|
||||
capitalizeKeys(headers);
|
||||
|
||||
const payload = {
|
||||
auth_algo: headers['PAYPAL-AUTH-ALGO'],
|
||||
cert_url: headers['PAYPAL-CERT-URL'],
|
||||
transmission_id: headers['PAYPAL-TRANSMISSION-ID'],
|
||||
transmission_sig: headers['PAYPAL-TRANSMISSION-SIG'],
|
||||
transmission_time: headers['PAYPAL-TRANSMISSION-TIME'],
|
||||
webhook_id: webhookId,
|
||||
webhook_event: webhookEventBody
|
||||
};
|
||||
|
||||
const response = await axios.post(paypalverifyWebhookURL, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.verification_status === 'SUCCESS') {
|
||||
return body;
|
||||
} else {
|
||||
throw {
|
||||
message: `Failed token verification.`,
|
||||
type: 'FailedPaypalTokenVerificationError'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyWebHookType(req) {
|
||||
// check if webhook type for creation
|
||||
const {
|
||||
body: { event_type }
|
||||
} = req;
|
||||
|
||||
if (
|
||||
event_type === 'BILLING.SUBSCRIPTION.ACTIVATED' ||
|
||||
event_type === 'BILLING.SUBSCRIPTION.CANCELLED'
|
||||
)
|
||||
return req;
|
||||
else
|
||||
throw {
|
||||
message: 'Webhook type is not supported',
|
||||
type: 'UnsupportedWebhookType'
|
||||
};
|
||||
}
|
||||
|
||||
export const createAsyncUserDonation = (user, donation) => {
|
||||
log(`Creating donation:${donation.subscriptionId}`);
|
||||
user
|
||||
.createDonation(donation)
|
||||
.toPromise()
|
||||
.catch(err => {
|
||||
throw new Error(err);
|
||||
});
|
||||
};
|
||||
|
||||
export function createDonationObj(body) {
|
||||
const {
|
||||
resource: {
|
||||
id,
|
||||
status_update_time,
|
||||
subscriber: { email_address } = {
|
||||
email_address: null
|
||||
}
|
||||
}
|
||||
} = body;
|
||||
|
||||
let donation = {
|
||||
email: email_address,
|
||||
amount: 500,
|
||||
duration: 'month',
|
||||
provider: 'paypal',
|
||||
subscriptionId: id,
|
||||
customerId: email_address,
|
||||
startDate: new Date(status_update_time).toISOString()
|
||||
};
|
||||
return donation;
|
||||
}
|
||||
|
||||
export function createDonation(body, app) {
|
||||
const { User } = app.models;
|
||||
const {
|
||||
resource: {
|
||||
subscriber: { email_address } = {
|
||||
email_address: null
|
||||
}
|
||||
}
|
||||
} = body;
|
||||
|
||||
let donation = createDonationObj(body);
|
||||
|
||||
let email = email_address;
|
||||
return User.findOne({ where: { email } }, (err, user) => {
|
||||
if (err) throw new Error(err);
|
||||
if (!user) {
|
||||
log(`Creating new user:${email}`);
|
||||
return User.create({ email })
|
||||
.then(user => {
|
||||
createAsyncUserDonation(user, donation);
|
||||
})
|
||||
.catch(err => {
|
||||
throw new Error(err);
|
||||
});
|
||||
}
|
||||
return createAsyncUserDonation(user, donation);
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelDonation(body, app) {
|
||||
const {
|
||||
resource: { id, status_update_time = new Date(Date.now()).toISOString() }
|
||||
} = body;
|
||||
const { Donation } = app.models;
|
||||
Donation.findOne({ where: { subscriptionId: id } }, (err, donation) => {
|
||||
if (err || !donation) throw Error(err);
|
||||
log(`Updating donation record: ${donation.subscriptionId}`);
|
||||
donation.updateAttributes({
|
||||
endDate: new Date(status_update_time).toISOString()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(body, app) {
|
||||
const { event_type } = body;
|
||||
if (event_type === 'BILLING.SUBSCRIPTION.ACTIVATED') {
|
||||
createDonation(body, app);
|
||||
} else if (event_type === 'BILLING.SUBSCRIPTION.CANCELLED') {
|
||||
cancelDonation(body, app);
|
||||
} else
|
||||
throw {
|
||||
message: 'Webhook type is not supported',
|
||||
type: 'UnsupportedWebhookType'
|
||||
};
|
||||
}
|
@ -1,163 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
/* global describe it expect */
|
||||
/* global jest*/
|
||||
|
||||
import axios from 'axios';
|
||||
import keys from '../../../config/secrets';
|
||||
import {
|
||||
getAsyncPaypalToken,
|
||||
verifyWebHook,
|
||||
updateUser,
|
||||
capitalizeKeys,
|
||||
createDonationObj
|
||||
} from './donation';
|
||||
import { mockActivationHook, mockCancellationHook } from './__mocks__/donation';
|
||||
import {
|
||||
mockApp,
|
||||
createDonationMockFn,
|
||||
createUserMockFn,
|
||||
updateDonationAttr,
|
||||
updateUserAttr
|
||||
} from '../boot_tests/fixtures';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const verificationUrl = `https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature`;
|
||||
const tokenUrl = `https://api.sandbox.paypal.com/v1/oauth2/token`;
|
||||
const {
|
||||
body: activationHookBody,
|
||||
headers: activationHookHeaders
|
||||
} = mockActivationHook;
|
||||
|
||||
describe('donation', () => {
|
||||
describe('getAsyncPaypalToken', () => {
|
||||
it('call paypal api for token ', async () => {
|
||||
const res = {
|
||||
data: {
|
||||
access_token: 'token'
|
||||
}
|
||||
};
|
||||
|
||||
axios.post.mockImplementationOnce(() => Promise.resolve(res));
|
||||
|
||||
await expect(getAsyncPaypalToken()).resolves.toEqual(
|
||||
res.data.access_token
|
||||
);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
expect(axios.post).toHaveBeenCalledWith(tokenUrl, null, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
auth: {
|
||||
username: keys.paypal.client,
|
||||
password: keys.paypal.secret
|
||||
},
|
||||
params: {
|
||||
grant_type: 'client_credentials'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyWebHook', () => {
|
||||
// normalize headers
|
||||
capitalizeKeys(activationHookHeaders);
|
||||
const mockWebhookId = 'qwdfq;3w12341dfa4';
|
||||
const mockAccessToken = '241231223$!@$#1243';
|
||||
const mockPayLoad = {
|
||||
auth_algo: activationHookHeaders['PAYPAL-AUTH-ALGO'],
|
||||
cert_url: activationHookHeaders['PAYPAL-CERT-URL'],
|
||||
transmission_id: activationHookHeaders['PAYPAL-TRANSMISSION-ID'],
|
||||
transmission_sig: activationHookHeaders['PAYPAL-TRANSMISSION-SIG'],
|
||||
transmission_time: activationHookHeaders['PAYPAL-TRANSMISSION-TIME'],
|
||||
webhook_id: mockWebhookId,
|
||||
webhook_event: activationHookBody
|
||||
};
|
||||
const failedVerificationErr = {
|
||||
message: `Failed token verification.`,
|
||||
type: 'FailedPaypalTokenVerificationError'
|
||||
};
|
||||
const axiosOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${mockAccessToken}`
|
||||
}
|
||||
};
|
||||
const successVerificationResponce = {
|
||||
data: {
|
||||
verification_status: 'SUCCESS'
|
||||
}
|
||||
};
|
||||
const failedVerificationResponce = {
|
||||
data: {
|
||||
verification_status: 'FAILED'
|
||||
}
|
||||
};
|
||||
|
||||
it('calls paypal for Webhook verification', async () => {
|
||||
axios.post.mockImplementationOnce(() =>
|
||||
Promise.resolve(successVerificationResponce)
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyWebHook(
|
||||
activationHookHeaders,
|
||||
activationHookBody,
|
||||
mockAccessToken,
|
||||
mockWebhookId
|
||||
)
|
||||
).resolves.toEqual(activationHookBody);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
verificationUrl,
|
||||
mockPayLoad,
|
||||
axiosOptions
|
||||
);
|
||||
});
|
||||
it('throws error if verification not successful', async () => {
|
||||
axios.post.mockImplementationOnce(() =>
|
||||
Promise.resolve(failedVerificationResponce)
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyWebHook(
|
||||
activationHookHeaders,
|
||||
activationHookBody,
|
||||
mockAccessToken,
|
||||
mockWebhookId
|
||||
)
|
||||
).rejects.toEqual(failedVerificationErr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('created a donation when a machting user found', () => {
|
||||
updateUser(activationHookBody, mockApp);
|
||||
expect(createDonationMockFn).toHaveBeenCalledTimes(1);
|
||||
expect(createDonationMockFn).toHaveBeenCalledWith(
|
||||
createDonationObj(activationHookBody)
|
||||
);
|
||||
});
|
||||
it('create a user and donation when no machting user found', () => {
|
||||
let newActivationHookBody = activationHookBody;
|
||||
newActivationHookBody.resource.subscriber.email_address =
|
||||
'new@freecodecamp.org';
|
||||
updateUser(newActivationHookBody, mockApp);
|
||||
expect(createUserMockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('modify user and donation records on cancellation', () => {
|
||||
const { body: cancellationHookBody } = mockCancellationHook;
|
||||
const {
|
||||
resource: { status_update_time = new Date(Date.now()).toISOString() }
|
||||
} = cancellationHookBody;
|
||||
|
||||
updateUser(cancellationHookBody, mockApp);
|
||||
expect(updateDonationAttr).toHaveBeenCalledWith({
|
||||
endDate: new Date(status_update_time).toISOString()
|
||||
});
|
||||
expect(updateUserAttr).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,30 +0,0 @@
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
import { getChallengesForLang } from '../../../curriculum/getChallenges';
|
||||
|
||||
// TODO: this caching is handy if we want to field requests that need to 'query'
|
||||
// the curriculum, but if we force the client to handle
|
||||
// redirectToCurrentChallenge and, instead, only report the current challenge
|
||||
// id via the user object, then we should *not* store this so it can be garbage
|
||||
// collected.
|
||||
|
||||
let curriculum;
|
||||
export async function getCurriculum() {
|
||||
// NOTE: this is always 'english' because we are only interested in the slugs
|
||||
// and those should not change between the languages.
|
||||
curriculum = curriculum ? curriculum : getChallengesForLang('english');
|
||||
return curriculum;
|
||||
}
|
||||
|
||||
export async function getChallenges() {
|
||||
return getCurriculum().then(curriculum => {
|
||||
return Object.keys(curriculum)
|
||||
.map(key => curriculum[key].blocks)
|
||||
.reduce((challengeArray, superBlock) => {
|
||||
const challengesForBlock = Object.keys(superBlock).map(
|
||||
key => superBlock[key].challenges
|
||||
);
|
||||
return [...challengeArray, ...flatten(challengesForBlock)];
|
||||
}, []);
|
||||
});
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
function getCompletedCertCount(user) {
|
||||
return [
|
||||
'isApisMicroservicesCert',
|
||||
'is2018DataVisCert',
|
||||
'isFrontEndLibsCert',
|
||||
'isQaCertV7',
|
||||
'isInfosecCertV7',
|
||||
'isJsAlgoDataStructCert',
|
||||
'isRespWebDesignCert',
|
||||
'isSciCompPyCertV7',
|
||||
'isDataAnalysisPyCertV7',
|
||||
'isMachineLearningPyCertV7'
|
||||
].reduce((sum, key) => (user[key] ? sum + 1 : sum), 0);
|
||||
}
|
||||
|
||||
function getLegacyCertCount(user) {
|
||||
return [
|
||||
'isFrontEndCert',
|
||||
'isBackEndCert',
|
||||
'isDataVisCert',
|
||||
'isInfosecQaCert'
|
||||
].reduce((sum, key) => (user[key] ? sum + 1 : sum), 0);
|
||||
}
|
||||
|
||||
export default function populateUser(db, user) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let populatedUser = { ...user };
|
||||
db.collection('user')
|
||||
.aggregate([
|
||||
{ $match: { _id: user.id } },
|
||||
{ $project: { points: { $size: '$progressTimestamps' } } }
|
||||
])
|
||||
.get(function(err, [{ points = 1 } = {}]) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
user.points = points;
|
||||
let completedChallengeCount = 0;
|
||||
let completedProjectCount = 0;
|
||||
if ('completedChallenges' in user) {
|
||||
completedChallengeCount = user.completedChallenges.length;
|
||||
user.completedChallenges.forEach(item => {
|
||||
if (
|
||||
'challengeType' in item &&
|
||||
(item.challengeType === 3 || item.challengeType === 4)
|
||||
) {
|
||||
completedProjectCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
populatedUser.completedChallengeCount = completedChallengeCount;
|
||||
populatedUser.completedProjectCount = completedProjectCount;
|
||||
populatedUser.completedCertCount = getCompletedCertCount(user);
|
||||
populatedUser.completedLegacyCertCount = getLegacyCertCount(user);
|
||||
populatedUser.completedChallenges = [];
|
||||
return resolve(populatedUser);
|
||||
});
|
||||
});
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { isBefore } from 'date-fns';
|
||||
|
||||
import { jwtSecret as _jwtSecret } from '../../../config/secrets';
|
||||
|
||||
export const authHeaderNS = 'X-fcc-access-token';
|
||||
export const jwtCookieNS = 'jwt_access_token';
|
||||
|
||||
export function createCookieConfig(req) {
|
||||
return {
|
||||
signed: !!req.signedCookies,
|
||||
domain: process.env.COOKIE_DOMAIN || 'localhost'
|
||||
};
|
||||
}
|
||||
|
||||
export function setAccessTokenToResponse(
|
||||
{ accessToken },
|
||||
req,
|
||||
res,
|
||||
jwtSecret = _jwtSecret
|
||||
) {
|
||||
const cookieConfig = {
|
||||
...createCookieConfig(req),
|
||||
maxAge: accessToken.ttl || 77760000000
|
||||
};
|
||||
const jwtAccess = jwt.sign({ accessToken }, jwtSecret);
|
||||
res.cookie(jwtCookieNS, jwtAccess, cookieConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
export function getAccessTokenFromRequest(req, jwtSecret = _jwtSecret) {
|
||||
const maybeToken =
|
||||
(req.headers && req.headers[authHeaderNS]) ||
|
||||
(req.signedCookies && req.signedCookies[jwtCookieNS]) ||
|
||||
(req.cookie && req.cookie[jwtCookieNS]);
|
||||
if (!maybeToken) {
|
||||
return {
|
||||
accessToken: null,
|
||||
error: errorTypes.noTokenFound
|
||||
};
|
||||
}
|
||||
let token;
|
||||
try {
|
||||
token = jwt.verify(maybeToken, jwtSecret);
|
||||
} catch (err) {
|
||||
return { accessToken: null, error: errorTypes.invalidToken };
|
||||
}
|
||||
|
||||
const { accessToken } = token;
|
||||
const { created, ttl } = accessToken;
|
||||
const valid = isBefore(Date.now(), Date.parse(created) + ttl);
|
||||
if (!valid) {
|
||||
return {
|
||||
accessToken: null,
|
||||
error: errorTypes.expiredToken
|
||||
};
|
||||
}
|
||||
return { accessToken, error: '', jwt: maybeToken };
|
||||
}
|
||||
|
||||
export function removeCookies(req, res) {
|
||||
const config = createCookieConfig(req);
|
||||
res.clearCookie(jwtCookieNS, config);
|
||||
res.clearCookie('access_token', config);
|
||||
res.clearCookie('userId', config);
|
||||
res.clearCookie('_csrf', config);
|
||||
return;
|
||||
}
|
||||
|
||||
export const errorTypes = {
|
||||
noTokenFound: 'No token found',
|
||||
invalidToken: 'Invalid token',
|
||||
expiredToken: 'Token timed out'
|
||||
};
|
@ -1,167 +0,0 @@
|
||||
/* global describe it expect */
|
||||
import {
|
||||
getAccessTokenFromRequest,
|
||||
errorTypes,
|
||||
setAccessTokenToResponse,
|
||||
removeCookies
|
||||
} from './getSetAccessToken';
|
||||
import { mockReq, mockRes } from 'sinon-express-mock';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
describe('getSetAccessToken', () => {
|
||||
const validJWTSecret = 'this is a super secret string';
|
||||
const invalidJWTSecret = 'This is not correct secret';
|
||||
const now = new Date(Date.now());
|
||||
const theBeginningOfTime = new Date(0);
|
||||
const domain = process.env.COOKIE_DOMAIN || 'localhost';
|
||||
const accessToken = {
|
||||
id: '123abc',
|
||||
userId: '456def',
|
||||
ttl: 60000,
|
||||
created: now
|
||||
};
|
||||
|
||||
describe('getAccessTokenFromRequest', () => {
|
||||
it('return `no token` error if no token is found', () => {
|
||||
const req = mockReq({ headers: {}, cookie: {} });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
expect(result.error).toEqual(errorTypes.noTokenFound);
|
||||
});
|
||||
|
||||
describe('cookies', () => {
|
||||
it('returns `invalid token` error for malformed tokens', () => {
|
||||
const invalidJWT = jwt.sign({ accessToken }, invalidJWTSecret);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ cookie: { jwt_access_token: invalidJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.error).toEqual(errorTypes.invalidToken);
|
||||
});
|
||||
|
||||
it('returns `expired token` error for expired tokens', () => {
|
||||
const invalidJWT = jwt.sign(
|
||||
{ accessToken: { ...accessToken, created: theBeginningOfTime } },
|
||||
validJWTSecret
|
||||
);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ cookie: { jwt_access_token: invalidJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.error).toEqual(errorTypes.expiredToken);
|
||||
});
|
||||
|
||||
it('returns a valid access token with no errors ', () => {
|
||||
expect.assertions(2);
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ cookie: { jwt_access_token: validJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.error).toBeFalsy();
|
||||
expect(result.accessToken).toEqual({
|
||||
...accessToken,
|
||||
created: accessToken.created.toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the signed jwt if found', () => {
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ cookie: { jwt_access_token: validJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.jwt).toEqual(validJWT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth headers', () => {
|
||||
it('returns `invalid token` error for malformed tokens', () => {
|
||||
const invalidJWT = jwt.sign({ accessToken }, invalidJWTSecret);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ headers: { 'X-fcc-access-token': invalidJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.error).toEqual(errorTypes.invalidToken);
|
||||
});
|
||||
|
||||
it('returns `expired token` error for expired tokens', () => {
|
||||
const invalidJWT = jwt.sign(
|
||||
{ accessToken: { ...accessToken, created: theBeginningOfTime } },
|
||||
validJWTSecret
|
||||
);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ headers: { 'X-fcc-access-token': invalidJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.error).toEqual(errorTypes.expiredToken);
|
||||
});
|
||||
|
||||
it('returns a valid access token with no errors ', () => {
|
||||
expect.assertions(2);
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ headers: { 'X-fcc-access-token': validJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.error).toBeFalsy();
|
||||
expect(result.accessToken).toEqual({
|
||||
...accessToken,
|
||||
created: accessToken.created.toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the signed jwt if found', () => {
|
||||
const validJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
// eslint-disable-next-line camelcase
|
||||
const req = mockReq({ headers: { 'X-fcc-access-token': validJWT } });
|
||||
const result = getAccessTokenFromRequest(req, validJWTSecret);
|
||||
|
||||
expect(result.jwt).toEqual(validJWT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAccessTokenToResponse', () => {
|
||||
it('sets a jwt access token cookie in the response', () => {
|
||||
const req = mockReq();
|
||||
const res = mockRes();
|
||||
|
||||
const expectedJWT = jwt.sign({ accessToken }, validJWTSecret);
|
||||
|
||||
setAccessTokenToResponse({ accessToken }, req, res, validJWTSecret);
|
||||
|
||||
expect(res.cookie.getCall(0).args).toEqual([
|
||||
'jwt_access_token',
|
||||
expectedJWT,
|
||||
{
|
||||
signed: false,
|
||||
domain,
|
||||
maxAge: accessToken.ttl
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCookies', () => {
|
||||
// eslint-disable-next-line max-len
|
||||
it('removes four cookies set in the lifetime of an authenticated session', () => {
|
||||
// expect.assertions(4);
|
||||
const req = mockReq();
|
||||
const res = mockRes();
|
||||
const jwtOptions = { signed: false, domain };
|
||||
|
||||
removeCookies(req, res);
|
||||
|
||||
expect(res.clearCookie.getCall(0).args).toEqual([
|
||||
'jwt_access_token',
|
||||
jwtOptions
|
||||
]);
|
||||
expect(res.clearCookie.getCall(1).args).toEqual([
|
||||
'access_token',
|
||||
jwtOptions
|
||||
]);
|
||||
expect(res.clearCookie.getCall(2).args).toEqual(['userId', jwtOptions]);
|
||||
expect(res.clearCookie.getCall(3).args).toEqual(['_csrf', jwtOptions]);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,43 +0,0 @@
|
||||
function isPromiseLike(thing) {
|
||||
return !!thing && typeof thing.then === 'function';
|
||||
}
|
||||
|
||||
function InMemoryCache(initialValue, reportError) {
|
||||
if (typeof reportError !== 'function') {
|
||||
throw new Error(
|
||||
'No reportError function specified for this in-memory-cache'
|
||||
);
|
||||
}
|
||||
const cacheKey = Symbol('cacheKey');
|
||||
const cache = new Map();
|
||||
cache.set(cacheKey, initialValue);
|
||||
|
||||
return {
|
||||
get() {
|
||||
const value = cache.get(cacheKey);
|
||||
return typeof value !== 'undefined' ? value : null;
|
||||
},
|
||||
|
||||
update(fn) {
|
||||
try {
|
||||
const value = fn();
|
||||
if (isPromiseLike(value)) {
|
||||
return value.then(value => cache.set(cacheKey, value));
|
||||
} else {
|
||||
cache.set(cacheKey, value);
|
||||
}
|
||||
} catch (e) {
|
||||
const errMsg = `InMemoryCache > update > caught: ${e.message}`;
|
||||
e.message = errMsg;
|
||||
reportError(e);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
clear() {
|
||||
return cache.delete(cacheKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default InMemoryCache;
|
@ -1,68 +0,0 @@
|
||||
/* global describe beforeEach expect it */
|
||||
import inMemoryCache from './in-memory-cache';
|
||||
import sinon from 'sinon';
|
||||
|
||||
describe('InMemoryCache', () => {
|
||||
let reportErrorStub;
|
||||
const theAnswer = 42;
|
||||
const before = 'before';
|
||||
const after = 'after';
|
||||
const emptyCacheValue = null;
|
||||
|
||||
beforeEach(() => {
|
||||
reportErrorStub = sinon.spy();
|
||||
});
|
||||
|
||||
it('throws if no report function is passed as a second argument', () => {
|
||||
expect(() => inMemoryCache(null)).toThrowError(
|
||||
'No reportError function specified for this in-memory-cache'
|
||||
);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns an initial value', () => {
|
||||
const cache = inMemoryCache(theAnswer, reportErrorStub);
|
||||
expect(cache.get()).toBe(theAnswer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates the cached value', () => {
|
||||
const cache = inMemoryCache(before, reportErrorStub);
|
||||
cache.update(() => after);
|
||||
expect(cache.get()).toBe(after);
|
||||
});
|
||||
|
||||
it('can handle promises correctly', done => {
|
||||
const cache = inMemoryCache(before, reportErrorStub);
|
||||
const promisedUpdate = () => new Promise(resolve => resolve(after));
|
||||
cache.update(promisedUpdate).then(() => {
|
||||
expect(cache.get()).toBe(after);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('reports errors thrown from the update function', () => {
|
||||
const reportErrorStub = sinon.spy();
|
||||
const cache = inMemoryCache(before, reportErrorStub);
|
||||
|
||||
const updateError = new Error('An update error');
|
||||
const updateThatThrows = () => {
|
||||
throw updateError;
|
||||
};
|
||||
|
||||
cache.update(updateThatThrows);
|
||||
expect(reportErrorStub.calledWith(updateError)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('clears the cache', () => {
|
||||
expect.assertions(2);
|
||||
const cache = inMemoryCache(theAnswer, reportErrorStub);
|
||||
expect(cache.get()).toBe(theAnswer);
|
||||
cache.clear();
|
||||
expect(cache.get()).toBe(emptyCacheValue);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,3 +0,0 @@
|
||||
exports.addPlaceholderImage = function addPlaceholderImage(name) {
|
||||
return `https://example.com/${name}.png`;
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
export default ['auth', 'services', 'link'].reduce((throughs, route) => {
|
||||
throughs[route] = true;
|
||||
return throughs;
|
||||
}, {});
|
@ -1,91 +0,0 @@
|
||||
import dedent from 'dedent';
|
||||
import { validationResult } from 'express-validator';
|
||||
|
||||
import { createValidatorErrorFormatter } from './create-handled-error.js';
|
||||
import {
|
||||
getAccessTokenFromRequest,
|
||||
removeCookies
|
||||
} from './getSetAccessToken.js';
|
||||
import { getRedirectParams } from './redirection';
|
||||
|
||||
export function ifNoUserRedirectHome(message, type = 'errors') {
|
||||
return function(req, res, next) {
|
||||
const { path } = req;
|
||||
if (req.user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const { origin } = getRedirectParams(req);
|
||||
req.flash(type, message || `You must be signed in to access ${path}`);
|
||||
|
||||
return res.redirect(origin);
|
||||
};
|
||||
}
|
||||
|
||||
export function ifNoUserSend(sendThis) {
|
||||
return function(req, res, next) {
|
||||
if (req.user) {
|
||||
return next();
|
||||
}
|
||||
return res.status(200).send(sendThis);
|
||||
};
|
||||
}
|
||||
|
||||
export function ifNoUser401(req, res, next) {
|
||||
if (req.user) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
export function ifNotVerifiedRedirectToUpdateEmail(req, res, next) {
|
||||
const { user } = req;
|
||||
if (!user) {
|
||||
return next();
|
||||
}
|
||||
if (!user.emailVerified) {
|
||||
req.flash(
|
||||
'danger',
|
||||
dedent`
|
||||
We do not have your verified email address on record,
|
||||
please add it in the settings to continue with your request.
|
||||
`
|
||||
);
|
||||
return res.redirect('/settings');
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
export function ifUserRedirectTo(status) {
|
||||
status = status === 301 ? 301 : 302;
|
||||
return (req, res, next) => {
|
||||
const { accessToken } = getAccessTokenFromRequest(req);
|
||||
const { returnTo } = getRedirectParams(req);
|
||||
if (req.user && accessToken) {
|
||||
return res.status(status).redirect(returnTo);
|
||||
}
|
||||
if (req.user && !accessToken) {
|
||||
// This request has an active auth session
|
||||
// but there is no accessToken attached to the request
|
||||
// perhaps the user cleared cookies?
|
||||
// we need to remove the zombie auth session
|
||||
removeCookies(req, res);
|
||||
delete req.session.passport;
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
// for use with express-validator error formatter
|
||||
export const createValidatorErrorHandler = (...args) => (req, res, next) => {
|
||||
const validation = validationResult(req).formatWith(
|
||||
createValidatorErrorFormatter(...args)
|
||||
);
|
||||
|
||||
if (!validation.isEmpty()) {
|
||||
const errors = validation.array();
|
||||
return next(errors.pop());
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
@ -1,87 +0,0 @@
|
||||
import { isURL } from 'validator';
|
||||
|
||||
import { addPlaceholderImage } from './';
|
||||
import {
|
||||
prepUniqueDaysByHours,
|
||||
calcCurrentStreak,
|
||||
calcLongestStreak
|
||||
} from '../utils/user-stats';
|
||||
|
||||
export const publicUserProps = [
|
||||
'about',
|
||||
'calendar',
|
||||
'completedChallenges',
|
||||
'githubProfile',
|
||||
'isApisMicroservicesCert',
|
||||
'isBackEndCert',
|
||||
'isCheater',
|
||||
'isDonating',
|
||||
'is2018DataVisCert',
|
||||
'isDataVisCert',
|
||||
'isFrontEndCert',
|
||||
'isFullStackCert',
|
||||
'isFrontEndLibsCert',
|
||||
'isHonest',
|
||||
'isInfosecQaCert',
|
||||
'isQaCertV7',
|
||||
'isInfosecCertV7',
|
||||
'isJsAlgoDataStructCert',
|
||||
'isRespWebDesignCert',
|
||||
'isSciCompPyCertV7',
|
||||
'isDataAnalysisPyCertV7',
|
||||
'isMachineLearningPyCertV7',
|
||||
'linkedin',
|
||||
'location',
|
||||
'name',
|
||||
'points',
|
||||
'portfolio',
|
||||
'profileUI',
|
||||
'projects',
|
||||
'streak',
|
||||
'twitter',
|
||||
'username',
|
||||
'website',
|
||||
'yearsTopContributor'
|
||||
];
|
||||
|
||||
export const userPropsForSession = [
|
||||
...publicUserProps,
|
||||
'currentChallengeId',
|
||||
'email',
|
||||
'emailVerified',
|
||||
'id',
|
||||
'sendQuincyEmail',
|
||||
'theme',
|
||||
'completedChallengeCount',
|
||||
'completedProjectCount',
|
||||
'completedCertCount',
|
||||
'completedLegacyCertCount',
|
||||
'acceptedPrivacyTerms',
|
||||
'donationEmails'
|
||||
];
|
||||
|
||||
export function normaliseUserFields(user) {
|
||||
const about = user.bio && !user.about ? user.bio : user.about;
|
||||
const picture = user.picture || addPlaceholderImage(user.username);
|
||||
const twitter =
|
||||
user.twitter && isURL(user.twitter)
|
||||
? user.twitter
|
||||
: user.twitter &&
|
||||
`https://www.twitter.com/${user.twitter.replace(/^@/, '')}`;
|
||||
return { about, picture, twitter };
|
||||
}
|
||||
|
||||
export function getProgress(progressTimestamps, timezone = 'EST') {
|
||||
const calendar = progressTimestamps
|
||||
.filter(Boolean)
|
||||
.reduce((data, timestamp) => {
|
||||
data[Math.floor(timestamp / 1000)] = 1;
|
||||
return data;
|
||||
}, {});
|
||||
const uniqueHours = prepUniqueDaysByHours(progressTimestamps, timezone);
|
||||
const streak = {
|
||||
longest: calcLongestStreak(uniqueHours, timezone),
|
||||
current: calcCurrentStreak(uniqueHours, timezone)
|
||||
};
|
||||
return { calendar, streak };
|
||||
}
|
6
api-server/server/utils/react.js
vendored
6
api-server/server/utils/react.js
vendored
@ -1,6 +0,0 @@
|
||||
export const errorThrowerMiddleware = () => next => action => {
|
||||
if (action.error) {
|
||||
throw action.payload;
|
||||
}
|
||||
return next(action);
|
||||
};
|
@ -1,83 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { availableLangs } = require('../../../client/i18n/allLangs');
|
||||
const { allowedOrigins } = require('../../../config/cors-settings');
|
||||
// homeLocation is being used as a fallback here. If the one provided by the
|
||||
// client is invalid we default to this.
|
||||
const { homeLocation } = require('../../../config/env.json');
|
||||
|
||||
function getReturnTo(encryptedParams, secret, _homeLocation = homeLocation) {
|
||||
let params;
|
||||
try {
|
||||
params = jwt.verify(encryptedParams, secret);
|
||||
} catch (e) {
|
||||
// TODO: report to Sentry? Probably not. Remove entirely?
|
||||
console.log(e);
|
||||
// something went wrong, use default params
|
||||
params = {
|
||||
returnTo: `${_homeLocation}/learn`,
|
||||
origin: _homeLocation,
|
||||
pathPrefix: ''
|
||||
};
|
||||
}
|
||||
|
||||
return normalizeParams(params, _homeLocation);
|
||||
}
|
||||
|
||||
function normalizeParams(
|
||||
{ returnTo, origin, pathPrefix },
|
||||
_homeLocation = homeLocation
|
||||
) {
|
||||
// coerce to strings, just in case something weird and nefarious is happening
|
||||
returnTo = '' + returnTo;
|
||||
origin = '' + origin;
|
||||
pathPrefix = '' + pathPrefix;
|
||||
// we add the '/' to prevent returns to
|
||||
// www.freecodecamp.org.somewhere.else.com
|
||||
if (
|
||||
!returnTo ||
|
||||
!allowedOrigins.some(allowed => returnTo.startsWith(allowed + '/'))
|
||||
) {
|
||||
returnTo = `${_homeLocation}/learn`;
|
||||
origin = _homeLocation;
|
||||
pathPrefix = '';
|
||||
}
|
||||
if (!origin || !allowedOrigins.includes(origin)) {
|
||||
returnTo = `${_homeLocation}/learn`;
|
||||
origin = _homeLocation;
|
||||
pathPrefix = '';
|
||||
}
|
||||
pathPrefix = availableLangs.client.includes(pathPrefix) ? pathPrefix : '';
|
||||
return { returnTo, origin, pathPrefix };
|
||||
}
|
||||
|
||||
// TODO: tests!
|
||||
// TODO: ensure origin and pathPrefix validation happens first
|
||||
// (it needs a dedicated function that can be called from here and getReturnTo)
|
||||
function getRedirectBase(origin, pathPrefix) {
|
||||
const redirectPathSegment = pathPrefix ? `/${pathPrefix}` : '';
|
||||
return `${origin}${redirectPathSegment}`;
|
||||
}
|
||||
|
||||
function getRedirectParams(req, _normalizeParams = normalizeParams) {
|
||||
const url = req.header('Referer');
|
||||
// since we do not always redirect the user back to the page they were on
|
||||
// we need client locale and origin to construct the redirect url.
|
||||
const returnUrl = new URL(url ? url : homeLocation);
|
||||
const origin = returnUrl.origin;
|
||||
// if this is not one of the client languages, validation will convert
|
||||
// this to '' before it is used.
|
||||
const pathPrefix = returnUrl.pathname.split('/')[0];
|
||||
return _normalizeParams({ returnTo: returnUrl.href, origin, pathPrefix });
|
||||
}
|
||||
|
||||
function isRootPath(redirectBase, returnUrl) {
|
||||
const base = new URL(redirectBase);
|
||||
const url = new URL(returnUrl);
|
||||
return base.pathname === url.pathname;
|
||||
}
|
||||
|
||||
module.exports.getReturnTo = getReturnTo;
|
||||
module.exports.getRedirectBase = getRedirectBase;
|
||||
module.exports.normalizeParams = normalizeParams;
|
||||
module.exports.getRedirectParams = getRedirectParams;
|
||||
module.exports.isRootPath = isRootPath;
|
@ -1,120 +0,0 @@
|
||||
/* global describe expect it */
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { getReturnTo, normalizeParams } = require('./redirection');
|
||||
|
||||
const validJWTSecret = 'this is a super secret string';
|
||||
const invalidJWTSecret = 'This is not correct secret';
|
||||
const validReturnTo = 'https://www.freecodecamp.org/settings';
|
||||
const invalidReturnTo = 'https://www.freecodecamp.org.fake/settings';
|
||||
const defaultReturnTo = 'https://www.freecodecamp.org/learn';
|
||||
const defaultOrigin = 'https://www.freecodecamp.org';
|
||||
const defaultPrefix = '';
|
||||
|
||||
const defaultObject = {
|
||||
returnTo: defaultReturnTo,
|
||||
origin: defaultOrigin,
|
||||
pathPrefix: defaultPrefix
|
||||
};
|
||||
|
||||
describe('redirection', () => {
|
||||
describe('getReturnTo', () => {
|
||||
it('should extract returnTo from a jwt', () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const encryptedReturnTo = jwt.sign(
|
||||
{ returnTo: validReturnTo, origin: defaultOrigin },
|
||||
validJWTSecret
|
||||
);
|
||||
expect(
|
||||
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
|
||||
).toStrictEqual({
|
||||
...defaultObject,
|
||||
returnTo: validReturnTo
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a default url if the secrets do not match', () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const encryptedReturnTo = jwt.sign(
|
||||
{ returnTo: validReturnTo },
|
||||
invalidJWTSecret
|
||||
);
|
||||
expect(
|
||||
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
|
||||
).toStrictEqual(defaultObject);
|
||||
});
|
||||
|
||||
it('should return a default url for unknown origins', () => {
|
||||
expect.assertions(1);
|
||||
const encryptedReturnTo = jwt.sign(
|
||||
{ returnTo: invalidReturnTo },
|
||||
validJWTSecret
|
||||
);
|
||||
expect(
|
||||
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
|
||||
).toStrictEqual(defaultObject);
|
||||
});
|
||||
});
|
||||
describe('normalizeParams', () => {
|
||||
it('should return a {returnTo, origin, pathPrefix} object', () => {
|
||||
expect.assertions(2);
|
||||
const keys = Object.keys(normalizeParams({}));
|
||||
const expectedKeys = ['returnTo', 'origin', 'pathPrefix'];
|
||||
expect(keys.length).toBe(3);
|
||||
expect(keys).toEqual(expect.arrayContaining(expectedKeys));
|
||||
});
|
||||
it('should default to homeLocation', () => {
|
||||
expect.assertions(1);
|
||||
expect(normalizeParams({}, defaultOrigin)).toEqual(defaultObject);
|
||||
});
|
||||
it('should convert an unknown pathPrefix to ""', () => {
|
||||
expect.assertions(1);
|
||||
const brokenPrefix = {
|
||||
...defaultObject,
|
||||
pathPrefix: 'not-really-a-name'
|
||||
};
|
||||
expect(normalizeParams(brokenPrefix, defaultOrigin)).toEqual(
|
||||
defaultObject
|
||||
);
|
||||
});
|
||||
it('should not change a known pathPrefix', () => {
|
||||
expect.assertions(1);
|
||||
const spanishPrefix = {
|
||||
...defaultObject,
|
||||
pathPrefix: 'espanol'
|
||||
};
|
||||
expect(normalizeParams(spanishPrefix, defaultOrigin)).toEqual(
|
||||
spanishPrefix
|
||||
);
|
||||
});
|
||||
// we *could*, in principle, grab the path and send them to
|
||||
// homeLocation/path, but if the origin is wrong something unexpected is
|
||||
// going on. In that case it's probably best to just send them to
|
||||
// homeLocation/learn.
|
||||
it('should return default parameters if the origin is unknown', () => {
|
||||
expect.assertions(1);
|
||||
const exampleOrigin = {
|
||||
...defaultObject,
|
||||
origin: 'http://example.com',
|
||||
pathPrefix: 'espanol'
|
||||
};
|
||||
expect(normalizeParams(exampleOrigin, defaultOrigin)).toEqual(
|
||||
defaultObject
|
||||
);
|
||||
});
|
||||
it('should return default parameters if the returnTo is unknown', () => {
|
||||
expect.assertions(1);
|
||||
const exampleReturnTo = {
|
||||
...defaultObject,
|
||||
returnTo: 'http://example.com/path',
|
||||
pathPrefix: 'espanol'
|
||||
};
|
||||
expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual(
|
||||
defaultObject
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,60 +0,0 @@
|
||||
import Rx, { AsyncSubject, Observable } from 'rx';
|
||||
import moment from 'moment';
|
||||
import debugFactory from 'debug';
|
||||
|
||||
const debug = debugFactory('fcc:rxUtils');
|
||||
|
||||
export function saveInstance(instance) {
|
||||
return new Rx.Observable.create(function(observer) {
|
||||
if (!instance || typeof instance.save !== 'function') {
|
||||
debug('no instance or save method');
|
||||
observer.onNext();
|
||||
return observer.onCompleted();
|
||||
}
|
||||
return instance.save(function(err, savedInstance) {
|
||||
if (err) {
|
||||
return observer.onError(err);
|
||||
}
|
||||
debug('instance saved');
|
||||
observer.onNext(savedInstance);
|
||||
return observer.onCompleted();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// alias saveInstance
|
||||
export const saveUser = saveInstance;
|
||||
|
||||
// observeQuery(Model: Object, methodName: String, query: Any) => Observable
|
||||
export function observeQuery(Model, methodName, query) {
|
||||
return Rx.Observable.fromNodeCallback(Model[methodName], Model)(query);
|
||||
}
|
||||
|
||||
// observeMethod(
|
||||
// context: Object, methodName: String
|
||||
// ) => (query: Any) => Observable
|
||||
export function observeMethod(context, methodName) {
|
||||
return Rx.Observable.fromNodeCallback(context[methodName], context);
|
||||
}
|
||||
|
||||
// must be bound to an observable instance
|
||||
// timeCache(amount: Number, unit: String) => Observable
|
||||
export function timeCache(time, unit) {
|
||||
const source = this;
|
||||
let cache;
|
||||
let expireCacheAt;
|
||||
return Observable.create(observable => {
|
||||
// if there is no expire time set
|
||||
// or if expireCacheAt is smaller than now,
|
||||
// set new expire time in MS and create new subscription to source
|
||||
if (!expireCacheAt || expireCacheAt < Date.now()) {
|
||||
// set expire in ms;
|
||||
expireCacheAt = moment()
|
||||
.add(time, unit)
|
||||
.valueOf();
|
||||
cache = new AsyncSubject();
|
||||
source.subscribe(cache);
|
||||
}
|
||||
return cache.subscribe(observable);
|
||||
});
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import certTypes from './certTypes.json';
|
||||
|
||||
const superBlockCertTypeMap = {
|
||||
// legacy
|
||||
'legacy-front-end': certTypes.frontEnd,
|
||||
'legacy-back-end': certTypes.backEnd,
|
||||
'legacy-data-visualization': certTypes.dataVis,
|
||||
// Keep these slugs the same so we don't
|
||||
// break existing links
|
||||
'information-security-and-quality-assurance': certTypes.infosecQa,
|
||||
'full-stack': certTypes.fullStack,
|
||||
|
||||
// modern
|
||||
'responsive-web-design': certTypes.respWebDesign,
|
||||
'javascript-algorithms-and-data-structures': certTypes.jsAlgoDataStruct,
|
||||
'front-end-libraries': certTypes.frontEndLibs,
|
||||
'data-visualization': certTypes.dataVis2018,
|
||||
'apis-and-microservices': certTypes.apisMicroservices,
|
||||
'quality-assurance-v7': certTypes.qaV7,
|
||||
'information-security-v7': certTypes.infosecV7,
|
||||
'scientific-computing-with-python-v7': certTypes.sciCompPyV7,
|
||||
'data-analysis-with-python-v7': certTypes.dataAnalysisPyV7,
|
||||
'machine-learning-with-python-v7': certTypes.machineLearningPyV7
|
||||
};
|
||||
|
||||
export default superBlockCertTypeMap;
|
@ -1,3 +0,0 @@
|
||||
export function getEmailSender() {
|
||||
return process.env.SES_MAIL_FROM || 'team@freecodecamp.org';
|
||||
}
|
@ -1,162 +0,0 @@
|
||||
import loopback from 'loopback';
|
||||
import compose from 'lodash/fp/compose';
|
||||
import map from 'lodash/fp/map';
|
||||
import sortBy from 'lodash/fp/sortBy';
|
||||
import trans from 'lodash/fp/transform';
|
||||
import last from 'lodash/fp/last';
|
||||
import forEachRight from 'lodash/fp/forEachRight';
|
||||
import { isEmpty } from 'lodash';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import { dayCount } from '../utils/date-utils';
|
||||
|
||||
const transform = trans.convert({ cap: false });
|
||||
|
||||
const hoursBetween = 24;
|
||||
const hoursDay = 24;
|
||||
|
||||
export function prepUniqueDaysByHours(cals, tz = 'UTC') {
|
||||
let prev = null;
|
||||
|
||||
// compose goes bottom to top (map > sortBy > transform)
|
||||
return compose(
|
||||
transform((data, cur, i) => {
|
||||
if (i < 1) {
|
||||
data.push(cur);
|
||||
prev = cur;
|
||||
} else if (
|
||||
moment(cur)
|
||||
.tz(tz)
|
||||
.diff(
|
||||
moment(prev)
|
||||
.tz(tz)
|
||||
.startOf('day'),
|
||||
'hours'
|
||||
) >= hoursDay
|
||||
) {
|
||||
data.push(cur);
|
||||
prev = cur;
|
||||
}
|
||||
}, []),
|
||||
sortBy(e => e),
|
||||
map(ts =>
|
||||
moment(ts)
|
||||
.tz(tz)
|
||||
.startOf('hours')
|
||||
.valueOf()
|
||||
)
|
||||
)(cals);
|
||||
}
|
||||
|
||||
export function calcCurrentStreak(cals, tz = 'UTC') {
|
||||
let prev = last(cals);
|
||||
if (
|
||||
moment()
|
||||
.tz(tz)
|
||||
.startOf('day')
|
||||
.diff(moment(prev).tz(tz), 'hours') > hoursBetween
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
let currentStreak = 0;
|
||||
let streakContinues = true;
|
||||
forEachRight(cur => {
|
||||
if (
|
||||
moment(prev)
|
||||
.tz(tz)
|
||||
.startOf('day')
|
||||
.diff(moment(cur).tz(tz), 'hours') <= hoursBetween
|
||||
) {
|
||||
prev = cur;
|
||||
currentStreak++;
|
||||
} else {
|
||||
// current streak found
|
||||
streakContinues = false;
|
||||
}
|
||||
return streakContinues;
|
||||
})(cals);
|
||||
|
||||
return currentStreak;
|
||||
}
|
||||
|
||||
export function calcLongestStreak(cals, tz = 'UTC') {
|
||||
let tail = cals[0];
|
||||
const longest = cals.reduce(
|
||||
(longest, head, index) => {
|
||||
const last = cals[index === 0 ? 0 : index - 1];
|
||||
// is streak broken
|
||||
if (
|
||||
moment(head)
|
||||
.tz(tz)
|
||||
.startOf('day')
|
||||
.diff(moment(last).tz(tz), 'hours') > hoursBetween
|
||||
) {
|
||||
tail = head;
|
||||
}
|
||||
if (dayCount(longest, tz) < dayCount([head, tail], tz)) {
|
||||
return [head, tail];
|
||||
}
|
||||
return longest;
|
||||
},
|
||||
[cals[0], cals[0]]
|
||||
);
|
||||
|
||||
return dayCount(longest, tz);
|
||||
}
|
||||
|
||||
export function getUserById(id, User = loopback.getModelByType('User')) {
|
||||
return new Promise((resolve, reject) =>
|
||||
User.findById(id, (err, instance) => {
|
||||
if (err || isEmpty(instance)) {
|
||||
return reject(err || 'No user instance found');
|
||||
}
|
||||
|
||||
let completedChallengeCount = 0;
|
||||
let completedProjectCount = 0;
|
||||
if ('completedChallenges' in instance) {
|
||||
completedChallengeCount = instance.completedChallenges.length;
|
||||
instance.completedChallenges.forEach(item => {
|
||||
if (
|
||||
'challengeType' in item &&
|
||||
(item.challengeType === 3 || item.challengeType === 4)
|
||||
) {
|
||||
completedProjectCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
instance.completedChallengeCount = completedChallengeCount;
|
||||
instance.completedProjectCount = completedProjectCount;
|
||||
instance.completedCertCount = getCompletedCertCount(instance);
|
||||
instance.completedLegacyCertCount = getLegacyCertCount(instance);
|
||||
instance.points =
|
||||
(instance.progressTimestamps && instance.progressTimestamps.length) ||
|
||||
1;
|
||||
return resolve(instance);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getCompletedCertCount(user) {
|
||||
return [
|
||||
'isApisMicroservicesCert',
|
||||
'is2018DataVisCert',
|
||||
'isFrontEndLibsCert',
|
||||
'isQaCertV7',
|
||||
'isInfosecCertV7',
|
||||
'isJsAlgoDataStructCert',
|
||||
'isRespWebDesignCert',
|
||||
'isSciCompPyCertV7',
|
||||
'isDataAnalysisPyCertV7',
|
||||
'isMachineLearningPyCertV7'
|
||||
].reduce((sum, key) => (user[key] ? sum + 1 : sum), 0);
|
||||
}
|
||||
|
||||
function getLegacyCertCount(user) {
|
||||
return [
|
||||
'isFrontEndCert',
|
||||
'isBackEndCert',
|
||||
'isDataVisCert',
|
||||
'isInfosecQaCert'
|
||||
].reduce((sum, key) => (user[key] ? sum + 1 : sum), 0);
|
||||
}
|
@ -1,628 +0,0 @@
|
||||
/* global describe it expect afterAll */
|
||||
import moment from 'moment-timezone';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {
|
||||
prepUniqueDaysByHours,
|
||||
calcCurrentStreak,
|
||||
calcLongestStreak,
|
||||
getUserById
|
||||
} from './user-stats';
|
||||
import { mockUserID, mockApp, mockUser } from '../boot_tests/fixtures';
|
||||
|
||||
// setting now to 2016-02-03T11:00:00 (PST)
|
||||
const clock = sinon.useFakeTimers(1454526000000);
|
||||
const PST = 'America/Los_Angeles';
|
||||
|
||||
describe('user stats', () => {
|
||||
afterAll(() => {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
describe('prepUniqueDaysByHours', () => {
|
||||
it(
|
||||
'should return correct epoch when all entries fall into ' +
|
||||
'one day in UTC',
|
||||
() => {
|
||||
expect(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 14:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 20:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual([1438567200000]);
|
||||
}
|
||||
);
|
||||
|
||||
it('should return correct epoch when given two identical dates', () => {
|
||||
expect(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual([1438567200000]);
|
||||
});
|
||||
|
||||
it('should return 2 epochs when dates fall into two days in PST', () => {
|
||||
expect(
|
||||
prepUniqueDaysByHours(
|
||||
[
|
||||
// 8/2/2015 in America/Los_Angeles
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 14:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('8/3/2015 20:00', 'M/D/YYYY H:mm').valueOf()
|
||||
],
|
||||
PST
|
||||
)
|
||||
).toEqual([1438567200000, 1438610400000]);
|
||||
});
|
||||
|
||||
it('should return 3 epochs when dates fall into three days', () => {
|
||||
expect(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/1/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('3/3/2015 14:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/30/2014 20:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
).toEqual([1412107200000, 1425391200000, 1438394400000]);
|
||||
});
|
||||
|
||||
it(
|
||||
'should return same but sorted array if all input dates are ' +
|
||||
'start of day',
|
||||
() => {
|
||||
expect(
|
||||
prepUniqueDaysByHours([1438387200000, 1425340800000, 1412035200000])
|
||||
).toEqual([1412035200000, 1425340800000, 1438387200000]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('calcCurrentStreak', function() {
|
||||
it('should return 1 day when today one challenge was completed', () => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it(
|
||||
'should return 1 day when today more than one challenge ' +
|
||||
'was completed',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('should return 0 days when today 0 challenges were completed', () => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(0);
|
||||
});
|
||||
|
||||
it(
|
||||
'should return 2 days when today and yesterday challenges were ' +
|
||||
'completed',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(2);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 3 when today and for two days before user was ' + 'active',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/14/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc(moment.utc().subtract(2, 'days')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(3);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 1 when there is a 1.5 day long break and ' +
|
||||
'dates fall into two days separated by third',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(47, 'hours')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(11, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(1);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 2 when the break is more than 1.5 days ' +
|
||||
'but dates fall into two consecutive days',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(40, 'hours')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(2);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return correct count in default timezone UTC ' +
|
||||
'given `undefined` timezone',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(1);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 2 days when today and yesterday ' +
|
||||
'challenges were completed given PST',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours(
|
||||
[
|
||||
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
],
|
||||
PST
|
||||
),
|
||||
PST
|
||||
)
|
||||
).toEqual(2);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 17 when there is no break in given timezone ' +
|
||||
'(but would be the break if in UTC)',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours(
|
||||
[
|
||||
1453174506164,
|
||||
1453175436725,
|
||||
1453252466853,
|
||||
1453294968225,
|
||||
1453383782844,
|
||||
1453431903117,
|
||||
1453471373080,
|
||||
1453594733026,
|
||||
1453645014058,
|
||||
1453746762747,
|
||||
1453747659197,
|
||||
1453748029416,
|
||||
1453818029213,
|
||||
1453951796007,
|
||||
1453988570615,
|
||||
1454069704441,
|
||||
1454203673979,
|
||||
1454294055498,
|
||||
1454333545125,
|
||||
1454415163903,
|
||||
1454519128123,
|
||||
moment.tz(PST).valueOf()
|
||||
],
|
||||
PST
|
||||
),
|
||||
PST
|
||||
)
|
||||
).toEqual(17);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 4 when there is a break in UTC ' +
|
||||
'(but would be no break in PST)',
|
||||
() => {
|
||||
expect(
|
||||
calcCurrentStreak(
|
||||
prepUniqueDaysByHours([
|
||||
1453174506164,
|
||||
1453175436725,
|
||||
1453252466853,
|
||||
1453294968225,
|
||||
1453383782844,
|
||||
1453431903117,
|
||||
1453471373080,
|
||||
1453594733026,
|
||||
1453645014058,
|
||||
1453746762747,
|
||||
1453747659197,
|
||||
1453748029416,
|
||||
1453818029213,
|
||||
1453951796007,
|
||||
1453988570615,
|
||||
1454069704441,
|
||||
1454203673979,
|
||||
1454294055498,
|
||||
1454333545125,
|
||||
1454415163903,
|
||||
1454519128123,
|
||||
moment.utc().valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(4);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('calcLongestStreak', function() {
|
||||
it(
|
||||
'should return 1 when there is the only one one-day-long ' +
|
||||
'streak available',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('9/12/2015 4:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(1);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 4 when there is the only one ' +
|
||||
'more-than-one-days-long streak available',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/13/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/14/2015 1:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(4);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 1 when there is only one one-day-long streak ' +
|
||||
'and it is today',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('should return 2 when yesterday and today makes longest streak', () => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(2);
|
||||
});
|
||||
|
||||
it('should return 4 when there is a month long break', () => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/4/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/5/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/6/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/7/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('11/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(4);
|
||||
});
|
||||
|
||||
it(
|
||||
'should return 2 when there is a more than 1.5 days ' +
|
||||
'long break of (36 hours)',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 15:30', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment
|
||||
.utc(
|
||||
moment
|
||||
.utc('9/12/2015 15:30', 'M/D/YYYY H:mm')
|
||||
.add(37, 'hours')
|
||||
)
|
||||
.valueOf(),
|
||||
moment.utc('9/14/2015 22:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/15/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(2);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 4 when the longest streak consist of ' +
|
||||
'several same day timestamps',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/14/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc(moment.utc().subtract(2, 'days')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
|
||||
moment.utc().valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(4);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 4 when there are several longest streaks ' +
|
||||
'(same length)',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/14/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('10/14/2015 5:00', 'M/D/YYYY H:mm').valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(4);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return correct longest streak when there is a very ' +
|
||||
'long period',
|
||||
() => {
|
||||
let cals = [];
|
||||
const n = 100;
|
||||
for (let i = 0; i < n; i++) {
|
||||
cals.push(moment.utc(moment.utc().subtract(i, 'days')).valueOf());
|
||||
}
|
||||
|
||||
expect(calcLongestStreak(prepUniqueDaysByHours(cals))).toEqual(n);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return correct longest streak in default timezone ' +
|
||||
'UTC given `undefined` timezone',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
|
||||
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(2);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 4 when there is the only one more-than-one-days-long ' +
|
||||
'streak available given PST',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/13/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/14/2015 1:00', 'M/D/YYYY H:mm').valueOf()
|
||||
]),
|
||||
PST
|
||||
)
|
||||
).toEqual(4);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 3 when longest streak is 3 in PST ' +
|
||||
'(but would be different in default timezone UTC)',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours(
|
||||
[
|
||||
moment.utc('9/11/2015 23:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/13/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
|
||||
moment.utc('9/14/2015 6:00', 'M/D/YYYY H:mm').valueOf()
|
||||
],
|
||||
PST
|
||||
),
|
||||
PST
|
||||
)
|
||||
).toEqual(3);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return 17 when there is no break in PST ' +
|
||||
'(but would be break in UTC) and it is current',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours(
|
||||
[
|
||||
1453174506164,
|
||||
1453175436725,
|
||||
1453252466853,
|
||||
1453294968225,
|
||||
1453383782844,
|
||||
1453431903117,
|
||||
1453471373080,
|
||||
1453594733026,
|
||||
1453645014058,
|
||||
1453746762747,
|
||||
1453747659197,
|
||||
1453748029416,
|
||||
1453818029213,
|
||||
1453951796007,
|
||||
1453988570615,
|
||||
1454069704441,
|
||||
1454203673979,
|
||||
1454294055498,
|
||||
1454333545125,
|
||||
1454415163903,
|
||||
1454519128123,
|
||||
moment.tz(PST).valueOf()
|
||||
],
|
||||
PST
|
||||
),
|
||||
PST
|
||||
)
|
||||
).toEqual(17);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'should return a streak of 4 when there is a break in UTC ' +
|
||||
'(but no break in PST)',
|
||||
() => {
|
||||
expect(
|
||||
calcLongestStreak(
|
||||
prepUniqueDaysByHours([
|
||||
1453174506164,
|
||||
1453175436725,
|
||||
1453252466853,
|
||||
1453294968225,
|
||||
1453383782844,
|
||||
1453431903117,
|
||||
1453471373080,
|
||||
1453594733026,
|
||||
1453645014058,
|
||||
1453746762747,
|
||||
1453747659197,
|
||||
1453748029416,
|
||||
1453818029213,
|
||||
1453951796007,
|
||||
1453988570615,
|
||||
1454069704441,
|
||||
1454203673979,
|
||||
1454294055498,
|
||||
1454333545125,
|
||||
1454415163903,
|
||||
1454519128123,
|
||||
moment.utc().valueOf()
|
||||
])
|
||||
)
|
||||
).toEqual(4);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('getUserById', () => {
|
||||
const stubUser = {
|
||||
findById(id, cb) {
|
||||
cb(null, { id: 123 });
|
||||
}
|
||||
};
|
||||
it('returns a promise', () => {
|
||||
expect.assertions(3);
|
||||
expect(typeof getUserById('123', stubUser).then).toEqual('function');
|
||||
expect(typeof getUserById('123', stubUser).catch).toEqual('function');
|
||||
expect(typeof getUserById('123', stubUser).finally).toEqual('function');
|
||||
});
|
||||
|
||||
it('resolves a user for a given id', done => {
|
||||
expect.assertions(7);
|
||||
return getUserById(mockUserID, mockApp.models.User)
|
||||
.then(user => {
|
||||
expect(user).toEqual(mockUser);
|
||||
|
||||
expect(user).toHaveProperty('progressTimestamps');
|
||||
expect(user).toHaveProperty('completedChallengeCount');
|
||||
expect(user).toHaveProperty('completedProjectCount');
|
||||
expect(user).toHaveProperty('completedCertCount');
|
||||
expect(user).toHaveProperty('completedLegacyCertCount');
|
||||
expect(user).toHaveProperty('completedChallenges');
|
||||
})
|
||||
.then(done)
|
||||
.catch(done);
|
||||
});
|
||||
|
||||
it('throws when no user is found', done => {
|
||||
const noUserError = new Error('No user found');
|
||||
const throwyUserModel = {
|
||||
findById(_, cb) {
|
||||
cb(noUserError);
|
||||
}
|
||||
};
|
||||
expect(
|
||||
getUserById('not-a-real-id', throwyUserModel).catch(error => {
|
||||
expect(error).toEqual(noUserError);
|
||||
done();
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,30 +0,0 @@
|
||||
// Refer : http://stackoverflow.com/a/430240/1932901
|
||||
function trimTags(value) {
|
||||
const tagBody = '(?:[^"\'>]|"[^"]*"|\'[^\']*\')*';
|
||||
const tagOrComment = new RegExp(
|
||||
'<(?:' +
|
||||
// Comment body.
|
||||
'!--(?:(?:-*[^->])*--+|-?)' +
|
||||
// Special "raw text" elements whose content should be elided.
|
||||
'|script\\b' +
|
||||
tagBody +
|
||||
'>[\\s\\S]*?</script\\s*' +
|
||||
'|style\\b' +
|
||||
tagBody +
|
||||
'>[\\s\\S]*?</style\\s*' +
|
||||
// Regular name
|
||||
'|/?[a-z]' +
|
||||
tagBody +
|
||||
')>',
|
||||
'gi'
|
||||
);
|
||||
let rawValue;
|
||||
do {
|
||||
rawValue = value;
|
||||
value = value.replace(tagOrComment, '');
|
||||
} while (value !== rawValue);
|
||||
|
||||
return value.replace(/</g, '<');
|
||||
}
|
||||
|
||||
export { trimTags };
|
@ -1,10 +0,0 @@
|
||||
Camper <%= username %> has completed all six certifications!
|
||||
|
||||
Completed Responsive Web Design Certification on <%= responsiveWebDesignDate %>.
|
||||
Completed Front End Libraries Certification on <%= frontEndLibrariesDate %>.
|
||||
Completed JavaScript Algorithms and Data Structures Certification on <%= javascriptAlgorithmsDataStructuresDate %>.
|
||||
Completed Data Visualization Certification on <%= dataVisualizationDate %>.
|
||||
Completed API's and microservices Certification on <%= apisMicroservicesDate %>.
|
||||
Completed Information Security and Quality Assurance Certification on <%= infosecQADate %>.
|
||||
|
||||
https://www.freecodecamp.org/<%= username %>
|
@ -1,15 +0,0 @@
|
||||
Hi <%= name || username %>,
|
||||
|
||||
Congratulations on completing all of the freeCodeCamp certifications!
|
||||
|
||||
All of your certifications are now live at at: https://www.freecodecamp.org/<%= username %>
|
||||
|
||||
Please tell me a bit more about you and your near-term goals.
|
||||
|
||||
Are you interested in contributing to our open source projects used by nonprofits?
|
||||
|
||||
Also, check out https://contribute.freecodecamp.org/ for some fun and convenient ways you can contribute to the community.
|
||||
|
||||
Happy coding,
|
||||
|
||||
- Quincy Larson, teacher at freeCodeCamp
|
@ -1,9 +0,0 @@
|
||||
Here's your sign in link. It will instantly sign you into freeCodeCamp.org - no password necessary:
|
||||
|
||||
<%= host %>/passwordless-auth/?email=<%= loginEmail %>&token=<%= loginToken %>
|
||||
|
||||
Note: this sign in link will expire after 15 minutes. If you need a new sign in link, go to https://www.freecodecamp.org/signin
|
||||
|
||||
See you soon!
|
||||
|
||||
- The freeCodeCamp.org Team
|
@ -1,13 +0,0 @@
|
||||
Welcome to the freeCodeCamp community!
|
||||
|
||||
We have created a new account for you.
|
||||
|
||||
Here's your sign in link. It will instantly sign you into freeCodeCamp.org - no password necessary:
|
||||
|
||||
<%= host %>/passwordless-auth/?email=<%= loginEmail %>&token=<%= loginToken %>
|
||||
|
||||
Note: this sign in link will expire after 15 minutes. If you need a new sign in link, go to https://www.freecodecamp.org/signin
|
||||
|
||||
See you soon!
|
||||
|
||||
- The freeCodeCamp.org Team
|
@ -1,7 +0,0 @@
|
||||
Please confirm this email address for freeCodeCamp.org:
|
||||
|
||||
<%= host %>/confirm-email?email=<%= loginEmail %>&token=<%= loginToken %>&emailChange=<%= emailChange %>
|
||||
|
||||
Happy coding!
|
||||
|
||||
- The freeCodeCamp.org Team
|
@ -1,113 +0,0 @@
|
||||
extends ../layout-wide
|
||||
block content
|
||||
script(src="../../../js/calculator.js")
|
||||
.row
|
||||
.col-xs-12.col-sm-10.col-md-8.col-lg-6.col-sm-offset-1.col-md-offset-2.col-lg-offset-3
|
||||
h1.text-center Coding Bootcamp Cost Calculator
|
||||
h3.text-center.text-primary#chosen Coming from _______, and making $_______, your true costs will be:
|
||||
#city-buttons
|
||||
.spacer
|
||||
h2.text-center Where do you live?
|
||||
.spacer
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#atlanta.btn.btn-primary.btn-block.btn-lg Atlanta
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#austin.btn.btn-primary.btn-block.btn-lg Austin
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#brisbane.btn.btn-primary.btn-block.btn-lg Brisbane
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#boulder.btn.btn-primary.btn-block.btn-lg Boulder
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#chicago.btn.btn-primary.btn-block.btn-lg Chicago
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#denver.btn.btn-primary.btn-block.btn-lg Denver
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#hong-kong.btn.btn-primary.btn-block.btn-lg Hong Kong
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#london.btn.btn-primary.btn-block.btn-lg London
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#los-angeles.btn.btn-primary.btn-block.btn-lg Los Angeles
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#manchester.btn.btn-primary.btn-block.btn-lg Manchester
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#melbourne.btn.btn-primary.btn-block.btn-lg Melbourne
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#new-york-city.btn.btn-primary.btn-block.btn-lg New York City
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#portland.btn.btn-primary.btn-block.btn-lg Portland
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#raleigh-durham.btn.btn-primary.btn-block.btn-lg Raleigh-Durham
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#san-francisco.btn.btn-primary.btn-block.btn-lg San Francisco
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#seattle.btn.btn-primary.btn-block.btn-lg Seattle
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#singapore.btn.btn-primary.btn-block.btn-lg Singapore
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#toronto.btn.btn-primary.btn-block.btn-lg Toronto
|
||||
.col-xs-12.btn-nav
|
||||
button#other.btn.btn-primary.btn-block.btn-lg Other
|
||||
.spacer
|
||||
#income.initially-hidden
|
||||
.spacer
|
||||
h2.text-center How much money did you make last year (in USD)?
|
||||
.spacer
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#0.btn.btn-primary.btn-block.btn-lg(href='#') $0
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#20000.btn.btn-primary.btn-block.btn-lg(href='#') $20,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#30000.btn.btn-primary.btn-block.btn-lg(href='#') $30,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#40000.btn.btn-primary.btn-block.btn-lg(href='#') $40,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#50000.btn.btn-primary.btn-block.btn-lg(href='#') $50,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#60000.btn.btn-primary.btn-block.btn-lg(href='#') $60,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#70000.btn.btn-primary.btn-block.btn-lg(href='#') $70,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#80000.btn.btn-primary.btn-block.btn-lg(href='#') $80,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#90000.btn.btn-primary.btn-block.btn-lg(href='#') $90,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#100000.btn.btn-primary.btn-block.btn-lg(href='#') $100,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#120000.btn.btn-primary.btn-block.btn-lg(href='#') $120,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#140000.btn.btn-primary.btn-block.btn-lg(href='#') $140,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#160000.btn.btn-primary.btn-block.btn-lg(href='#') $160,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#180000.btn.btn-primary.btn-block.btn-lg(href='#') $180,000
|
||||
.col-xs-12.col-sm-12.col-md-4.btn-nav
|
||||
button#200000.btn.btn-primary.btn-block.btn-lg(href='#') $200,000
|
||||
.spacer
|
||||
#chart.initially-hidden
|
||||
.d3-centered
|
||||
svg.chart
|
||||
#explanation.initially-hidden
|
||||
.col-xs-12.col-sm-10.col-sm-offset-1
|
||||
.text-center
|
||||
button#transform.btn.btn-primary.btn-lg Transform
|
||||
.button-spacer
|
||||
a(href='/json/bootcamps.json') View Data Source JSON
|
||||
span •
|
||||
a(href='/coding-bootcamp-cost-calculator') Recalculate
|
||||
h3 Notes:
|
||||
ol
|
||||
li.large-li We assumed an APR of 6% and a term of 3 years. If you happen to have around $15,000 in cash set aside for a coding bootcamp, please ignore this cost.
|
||||
li.large-li We assume a cost of living of $500 for cities like San Francisco and New York City, and $400 per week for everywhere else.
|
||||
li.large-li The most substantial cost for most people is lost wages. A 40-hour-per-week job at the US Federal minimum wage would pay at least $15,000 per year. You can read more about economic cost
|
||||
a(href='https://en.wikipedia.org/wiki/Economic_cost' target='_blank') here
|
||||
| .
|
||||
.spacer
|
||||
.row
|
||||
.col-xs-12.col-sm-6
|
||||
img.img-responsive.testimonial-image.img-center(src='https://www.evernote.com/l/AHRIBndcq-5GwZVnSy1_D7lskpH4OcJcUKUB/image.png')
|
||||
.col-xs-12.col-sm-6
|
||||
h3 Built by Suzanne Atkinson
|
||||
p.large-p Suzanne is an emergency medicine physician, triathlon coach and web developer from Pittsburgh. You should  
|
||||
a(href='https://twitter.com/intent/user?screen_name=SteelCityCoach' target='_blank') follow her on Twitter
|
||||
| .
|
||||
.spacer
|
@ -1,7 +0,0 @@
|
||||
h1 This is the fastest web page on the internet.
|
||||
h2 This is raw HTML with no CSS and no JavaScript.
|
||||
h2 This is served to you lightning fast from the cloud using Node.js and NGINX.
|
||||
h2 Unfortunately, this doesn't do anything.
|
||||
h2 I guess speed isn't everything, after all.
|
||||
h2
|
||||
a(href='/') Learn to code more useful websites than this one
|
Reference in New Issue
Block a user