feat(api): decouple api from curriculum (#40703)

This commit is contained in:
Oliver Eyton-Williams
2021-02-22 07:53:59 +01:00
committed by GitHub
parent f4bbe3f34c
commit c077ffe4b9
172 changed files with 376 additions and 345 deletions

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

@ -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}`);
};
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
module.exports = function mountRestApi(app) {
const restApi = app.loopback.rest();
const restApiRoot = app.get('restApiRoot');
app.use(restApiRoot, restApi);
};

View File

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

View File

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

View File

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

View File

@ -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/');
}
};

View File

@ -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;

View File

@ -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');
});
}

View File

@ -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.

View File

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

View File

@ -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();
});
});
});

View File

@ -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: []
}
];

View File

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

View File

@ -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
}
};

View File

@ -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
}
}

View File

@ -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
}
};

View File

@ -1,3 +0,0 @@
module.exports = {
host: process.env.HOST || 'localhost'
};

View File

@ -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;

View File

@ -1,11 +0,0 @@
{
"db": {
"name": "db",
"connector": "mongodb",
"allowExtendedOperators": true
},
"mail": {
"name": "mail",
"connector": "mail"
}
}

View File

@ -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
}
}
};

View File

@ -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();
}

View File

@ -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.

View File

@ -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
}
}
}
}

View File

@ -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();
};
}

View File

@ -1,4 +0,0 @@
import cookieParser from 'cookie-parser';
const cookieSecret = process.env.COOKIE_SECRET;
export default cookieParser.bind(cookieParser, cookieSecret);

View File

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

View File

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

View File

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

View File

@ -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();
};
}

View File

@ -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 youre the owner of this account contact
team@freecodecamp.org for details.
`
);
}
return next();
};
}

View File

@ -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();
};
}

View File

@ -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());
};

View File

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

View File

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

View File

@ -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();
}

View File

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

View File

@ -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
}
}

View File

@ -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'
}
});
};

View File

@ -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": {}
}

View File

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

View File

@ -1,13 +0,0 @@
{
"name": "AuthToken",
"base": "AccessToken",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {},
"validations": [],
"relations": {},
"acls": [],
"methods": {}
}

View File

@ -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$;
}

View File

@ -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": {}
}

View File

@ -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
}
};

View File

@ -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;

View File

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

View File

@ -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'
}
};

View File

@ -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');
}

View File

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

View File

@ -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'
};

View File

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

View File

@ -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"
}

View File

@ -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"
}

View File

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

View File

@ -1,6 +0,0 @@
export function createCookieConfig(req) {
return {
signed: !!req.signedCookies,
domain: process.env.COOKIE_DOMAIN || 'localhost'
};
}

View File

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

View File

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

View File

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

View File

@ -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'
};
}

View File

@ -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();
});
});
});

View File

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

View File

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

View File

@ -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'
};

View File

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

View File

@ -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;

View File

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

View File

@ -1,3 +0,0 @@
exports.addPlaceholderImage = function addPlaceholderImage(name) {
return `https://example.com/${name}.png`;
};

View File

@ -1,4 +0,0 @@
export default ['auth', 'services', 'link'].reduce((throughs, route) => {
throughs[route] = true;
return throughs;
}, {});

View File

@ -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();
};

View File

@ -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 };
}

View File

@ -1,6 +0,0 @@
export const errorThrowerMiddleware = () => next => action => {
if (action.error) {
throw action.payload;
}
return next(action);
};

View File

@ -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;

View File

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

View File

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

View File

@ -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;

View File

@ -1,3 +0,0 @@
export function getEmailSender() {
return process.env.SES_MAIL_FROM || 'team@freecodecamp.org';
}

View File

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

View File

@ -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();
})
);
});
});
});

View File

@ -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, '&lt;');
}
export { trimTags };

View File

@ -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 %>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 &nbsp; &bullet; &nbsp;
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 &thinsp;
a(href='https://twitter.com/intent/user?screen_name=SteelCityCoach' target='_blank') follow her on Twitter
| .
.spacer

View File

@ -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