diff --git a/api-server/server/boot/authentication.js b/api-server/server/boot/authentication.js index 969fc97df2..19405113ca 100644 --- a/api-server/server/boot/authentication.js +++ b/api-server/server/boot/authentication.js @@ -4,7 +4,6 @@ import { check } from 'express-validator'; import { isEmail } from 'validator'; import jwt from 'jsonwebtoken'; -import { homeLocation } from '../../../config/env'; import { jwtSecret } from '../../../config/secrets'; import { @@ -12,11 +11,11 @@ import { devSaveResponseAuthCookies, devLoginRedirect } from '../component-passport'; -import { ifUserRedirectTo, ifNoUserRedirectTo } from '../utils/middleware'; +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 { getParamsFromReq } from '../utils/get-return-to'; +import { getRedirectParams } from '../utils/redirection'; const isSignUpDisabled = !!process.env.DISABLE_SIGNUP; if (isSignUpDisabled) { @@ -40,7 +39,7 @@ module.exports = function enableAuthentication(app) { // loopback.io/doc/en/lb2/Authentication-authorization-and-permissions.html app.enableAuth(); const ifUserRedirect = ifUserRedirectTo(); - const ifNoUserRedirectHome = ifNoUserRedirectTo(homeLocation); + const ifNoUserRedirect = ifNoUserRedirectHome(); const devSaveAuthCookies = devSaveResponseAuthCookies(); const devLoginSuccessRedirect = devLoginRedirect(); const api = app.loopback.Router(); @@ -57,7 +56,7 @@ module.exports = function enableAuthentication(app) { ); } else { api.get('/signin', ifUserRedirect, (req, res, next) => { - const { returnTo, origin, pathPrefix } = getParamsFromReq(req); + const { returnTo, origin, pathPrefix } = getRedirectParams(req); const state = jwt.sign({ returnTo, origin, pathPrefix }, jwtSecret); return passport.authenticate('auth0-login', { state })(req, res, next); }); @@ -69,23 +68,24 @@ module.exports = function enableAuthentication(app) { } 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: homeLocation + redirectTo: origin }); } removeCookies(req, res); - res.redirect(homeLocation); + res.redirect(origin); }); }); api.get( '/confirm-email', - ifNoUserRedirectHome, + ifNoUserRedirect, passwordlessGetValidators, createGetPasswordlessAuth(app) ); @@ -106,14 +106,14 @@ function createGetPasswordlessAuth(app) { 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: `${homeLocation}/signin` + redirectTo: `${origin}/signin` }) ); } @@ -127,7 +127,7 @@ function createGetPasswordlessAuth(app) { { type: 'info', message: defaultErrorMsg, - redirectTo: `${homeLocation}/signin` + redirectTo: `${origin}/signin` } ); } @@ -141,7 +141,7 @@ function createGetPasswordlessAuth(app) { { type: 'info', message: defaultErrorMsg, - redirectTo: `${homeLocation}/signin` + redirectTo: `${origin}/signin` } ); } @@ -152,7 +152,7 @@ function createGetPasswordlessAuth(app) { { type: 'info', message: defaultErrorMsg, - redirectTo: `${homeLocation}/signin` + redirectTo: `${origin}/signin` } ); } @@ -167,7 +167,7 @@ function createGetPasswordlessAuth(app) { Looks like the link you clicked has expired, please request a fresh link, to sign in. `, - redirectTo: `${homeLocation}/signin` + redirectTo: `${origin}/signin` }); } return authToken.destroy$(); @@ -184,7 +184,7 @@ function createGetPasswordlessAuth(app) { 'success', 'Success! You have signed in to your account. Happy Coding!' ); - return res.redirectWithFlash(`${homeLocation}/learn`); + return res.redirectWithFlash(`${origin}/learn`); }) .subscribe(() => {}, next) ); diff --git a/api-server/server/boot/certificate.js b/api-server/server/boot/certificate.js index aa29390840..82345f5234 100644 --- a/api-server/server/boot/certificate.js +++ b/api-server/server/boot/certificate.js @@ -29,7 +29,6 @@ import { import { oldDataVizId } from '../../../config/misc'; import certTypes from '../utils/certTypes.json'; import superBlockCertTypeMap from '../utils/superBlockCertTypeMap'; -import { completeCommitment$ } from '../utils/commit'; import { getChallenges } from '../utils/get-curriculum'; const log = debug('fcc:certification'); @@ -354,9 +353,6 @@ function createVerifyCert(certTypeIds, app) { return Observable.combineLatest( // update user data Observable.fromPromise(updatePromise), - // If user has committed to nonprofit, - // this will complete their pledge - completeCommitment$(user), // sends notification email is user has all 6 certs // if not it noop sendCertifiedEmail(user, Email.send$), diff --git a/api-server/server/boot/challenge.js b/api-server/server/boot/challenge.js index ce4dc7ac00..f3b1d2f1db 100644 --- a/api-server/server/boot/challenge.js +++ b/api-server/server/boot/challenge.js @@ -17,10 +17,10 @@ import { dasherize } from '../../../utils/slugs'; import { fixCompletedChallengeItem } from '../../common/utils'; import { getChallenges } from '../utils/get-curriculum'; import { - getParamsFromReq, + getRedirectParams, getRedirectBase, normalizeParams -} from '../utils/get-return-to'; +} from '../utils/redirection'; const log = debug('fcc:boot:challenges'); @@ -34,7 +34,7 @@ export default async function bootChallenge(app, done) { const redirectToCurrentChallenge = createRedirectToCurrentChallenge( challengeUrlResolver, normalizeParams, - getParamsFromReq + getRedirectParams ); api.post( @@ -336,11 +336,11 @@ function backendChallengeCompleted(req, res, next) { export function createRedirectToCurrentChallenge( challengeUrlResolver, normalizeParams, - getParamsFromReq + getRedirectParams ) { return async function redirectToCurrentChallenge(req, res, next) { const { user } = req; - const { origin, pathPrefix } = normalizeParams(getParamsFromReq(req)); + const { origin, pathPrefix } = getRedirectParams(req, normalizeParams); const redirectBase = getRedirectBase(origin, pathPrefix); if (!user) { diff --git a/api-server/server/boot/commit.js b/api-server/server/boot/commit.js deleted file mode 100644 index dd7be86e2e..0000000000 --- a/api-server/server/boot/commit.js +++ /dev/null @@ -1,194 +0,0 @@ -import _ from 'lodash'; -import { Observable } from 'rx'; -import debugFactory from 'debug'; -import dedent from 'dedent'; - -import { homeLocation } from '../../../config/env'; - -import nonprofits from '../utils/commit.json'; -import { commitGoals, completeCommitment$ } from '../utils/commit'; - -import { unDasherize } from '../../../utils/slugs'; - -import { observeQuery, saveInstance } from '../utils/rx'; - -import { ifNoUserRedirectTo } from '../utils/middleware'; - -const sendNonUserToSignIn = ifNoUserRedirectTo( - `${homeLocation}/signin`, - 'You must be signed in to commit to a nonprofit.', - 'info' -); - -const sendNonUserToCommit = ifNoUserRedirectTo( - '/commit', - 'You must be signed in to update commit', - 'info' -); - -const debug = debugFactory('fcc:commit'); - -function findNonprofit(name) { - let nonprofit; - if (name) { - nonprofit = _.find(nonprofits, nonprofit => { - return name === nonprofit.name; - }); - } - - nonprofit = nonprofit || nonprofits[_.random(0, nonprofits.length - 1)]; - return nonprofit; -} - -export default function commit(app) { - const router = app.loopback.Router(); - const api = app.loopback.Router(); - const { Pledge } = app.models; - - router.get('/commit', commitToNonprofit); - - router.get('/commit/pledge', sendNonUserToSignIn, pledge); - - router.get('/commit/directory', renderDirectory); - - api.post('/commit/stop-commitment', sendNonUserToCommit, stopCommit); - - api.post('/commit/complete-goal', sendNonUserToCommit, completeCommitment); - - app.use(api); - app.use(router); - - function commitToNonprofit(req, res, next) { - const { user } = req; - let nonprofitName = unDasherize(req.query.nonprofit); - - debug('looking for nonprofit', nonprofitName); - const nonprofit = findNonprofit(nonprofitName); - - Observable.just(user) - .flatMap(user => { - if (user) { - debug('getting user pledge'); - return observeQuery(user, 'pledge'); - } - return Observable.just(); - }) - .subscribe(pledge => { - if (pledge) { - debug('found previous pledge'); - req.flash( - 'info', - dedent` - Looks like you already have a pledge to ${pledge.displayName}. - Clicking "Commit" here will replace your old commitment. If you - do change your commitment, please remember to cancel your - previous recurring donation directly with ${pledge.displayName}. - ` - ); - } - res.render('commit/', { - title: 'Commit to a nonprofit. Commit to your goal.', - pledge, - ...commitGoals, - ...nonprofit - }); - }, next); - } - - function pledge(req, res, next) { - const { user } = req; - const { - nonprofit: nonprofitName = 'girl develop it', - amount = '5', - goal = commitGoals.respWebDesignCert - } = req.query; - - const nonprofit = findNonprofit(nonprofitName); - - observeQuery(user, 'pledge') - .flatMap(oldPledge => { - // create new pledge for user - const pledge = Pledge({ - amount, - goal, - userId: user.id, - ...nonprofit - }); - - if (oldPledge) { - debug('user already has pledge, creating a new one'); - // we orphan last pledge since a user only has one pledge at a time - oldPledge.userId = ''; - oldPledge.formerUser = user.id; - oldPledge.endDate = new Date(); - oldPledge.isOrphaned = true; - return saveInstance(oldPledge).flatMap(() => { - return saveInstance(pledge); - }); - } - return saveInstance(pledge); - }) - .subscribe(({ displayName, goal, amount }) => { - req.flash( - 'success', - dedent` - Congratulations, you have committed to giving - ${displayName} $${amount} each month until you have completed - your ${goal}. Please remember to cancel your pledge directly - with ${displayName} once you finish. - ` - ); - res.redirect('/' + user.username); - }, next); - } - - function renderDirectory(req, res) { - res.render('commit/directory', { - title: 'Commit Directory', - nonprofits - }); - } - - function completeCommitment(req, res, next) { - const { user } = req; - - return completeCommitment$(user).subscribe(msgOrPledge => { - if (typeof msgOrPledge === 'string') { - return res.send(msgOrPledge); - } - return res.send(true); - }, next); - } - - function stopCommit(req, res, next) { - const { user } = req; - - observeQuery(user, 'pledge') - .flatMap(pledge => { - if (!pledge) { - return Observable.just(); - } - - pledge.formerUserId = pledge.userId; - pledge.userId = null; - pledge.isOrphaned = true; - pledge.dateEnded = new Date(); - return saveInstance(pledge); - }) - .subscribe(pledge => { - let msg = dedent` - You have successfully stopped your pledge. Please - remember to cancel your recurring donation directly - with the nonprofit if you haven't already done so. - `; - if (!pledge) { - msg = dedent` - It doesn't look like you had an active pledge, so - there's no pledge to stop. - `; - } - req.flash('info', msg); - return res.redirect(`/${user.username}`); - }, next); - } -} diff --git a/api-server/server/boot/randomAPIs.js b/api-server/server/boot/randomAPIs.js index 4868fe66a2..2accf545fc 100644 --- a/api-server/server/boot/randomAPIs.js +++ b/api-server/server/boot/randomAPIs.js @@ -1,8 +1,7 @@ import request from 'request'; -import { homeLocation } from '../../../config/env'; - import constantStrings from '../utils/constantStrings.json'; +import { getRedirectParams } from '../utils/redirection'; const githubClient = process.env.GITHUB_ID; const githubSecret = process.env.GITHUB_SECRET; @@ -51,19 +50,21 @@ module.exports = function(app) { 'We are no longer able to process this unsubscription request. ' + 'Please go to your settings to update your email preferences' ); - res.redirectWithFlash(homeLocation); + 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(homeLocation); + 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(homeLocation); + return res.redirectWithFlash(origin); } const updates = users.map(user => { return new Promise((resolve, reject) => @@ -88,7 +89,7 @@ module.exports = function(app) { "We've successfully updated your email preferences." ); return res.redirectWithFlash( - `${homeLocation}/unsubscribed/${unsubscribeId}` + `${origin}/unsubscribed/${unsubscribeId}` ); }) .catch(next); @@ -111,17 +112,18 @@ module.exports = function(app) { 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(homeLocation); + 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(homeLocation); + return res.redirectWithFlash(origin); } const [user] = users; return new Promise((resolve, reject) => @@ -144,7 +146,7 @@ module.exports = function(app) { "We've successfully updated your email preferences. Thank you " + 'for resubscribing.' ); - return res.redirectWithFlash(homeLocation); + return res.redirectWithFlash(origin); }) .catch(next); }); diff --git a/api-server/server/boot/user.js b/api-server/server/boot/user.js index 238ae6f04d..5921cfce38 100644 --- a/api-server/server/boot/user.js +++ b/api-server/server/boot/user.js @@ -4,19 +4,19 @@ import { pick } from 'lodash'; import { Observable } from 'rx'; import { body } from 'express-validator'; -import { homeLocation } from '../../../config/env'; import { getProgress, normaliseUserFields, userPropsForSession } from '../utils/publicUserProps'; import { fixCompletedChallengeItem } from '../../common/utils'; -import { ifNoUser401, ifNoUserRedirectTo } from '../utils/middleware'; +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 = ifNoUserRedirectTo(homeLocation); +const sendNonUserToHome = ifNoUserRedirectHome(); function bootUser(app) { const api = app.loopback.Router(); @@ -100,7 +100,7 @@ function getAccount(req, res) { 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'); @@ -151,7 +151,7 @@ function getUnlinkSocial(req, res, next) { log(`${social} has been unlinked successfully`); req.flash('info', `You've successfully unlinked your ${social}.`); - return res.redirectWithFlash(`${homeLocation}/${username}`); + return res.redirectWithFlash(`${origin}/${username}`); }); }); }); @@ -209,7 +209,7 @@ function createPostReportUserProfile(app) { return function postReportUserProfile(req, res, next) { const { user } = req; const { username, reportDescription: report } = req.body; - + const { origin } = getRedirectParams(req); log(username); log(report); @@ -241,7 +241,7 @@ function createPostReportUserProfile(app) { }, err => { if (err) { - err.redirectTo = `${homeLocation}/${username}`; + err.redirectTo = `${origin}/${username}`; return next(err); } diff --git a/api-server/server/boot/z-not-found.js b/api-server/server/boot/z-not-found.js index 25098945dc..2a73823f6a 100644 --- a/api-server/server/boot/z-not-found.js +++ b/api-server/server/boot/z-not-found.js @@ -1,16 +1,16 @@ import accepts from 'accepts'; - -import { homeLocation } from '../../../config/env'; +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(`${homeLocation}/404`); + return res.redirectWithFlash(`${origin}/404`); } if (type === 'json') { diff --git a/api-server/server/component-passport.js b/api-server/server/component-passport.js index 7407e6266b..e0512d0c50 100644 --- a/api-server/server/component-passport.js +++ b/api-server/server/component-passport.js @@ -13,9 +13,9 @@ import { jwtSecret } from '../../config/secrets'; import { getReturnTo, getRedirectBase, - getParamsFromReq, + getRedirectParams, isRootPath -} from './utils/get-return-to'; +} from './utils/redirection'; const passportOptions = { emailOptional: true, @@ -86,7 +86,7 @@ export const devSaveResponseAuthCookies = () => { export const devLoginRedirect = () => { return (req, res) => { // this mirrors the production approach, but without any validation - let { returnTo, origin, pathPrefix } = getParamsFromReq(req); + let { returnTo, origin, pathPrefix } = getRedirectParams(req); returnTo += isRootPath(getRedirectBase(origin, pathPrefix), returnTo) ? '/learn' : ''; diff --git a/api-server/server/middlewares/error-handlers.js b/api-server/server/middlewares/error-handlers.js index 7ecd851a3b..780e3a8a8d 100644 --- a/api-server/server/middlewares/error-handlers.js +++ b/api-server/server/middlewares/error-handlers.js @@ -2,9 +2,8 @@ // import _ from 'lodash/fp'; import accepts from 'accepts'; -import { homeLocation } from '../../../config/env'; - import { unwrapHandledError } from '../utils/create-handled-error.js'; +import { getRedirectParams } from '../utils/redirection'; const errTemplate = (error, req) => { const { message, stack } = error; @@ -27,6 +26,7 @@ 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; @@ -39,7 +39,7 @@ export default function prodErrorHandler() { const accept = accepts(req); const type = accept.type('html', 'json', 'text'); - const redirectTo = handled.redirectTo || `${homeLocation}/`; + const redirectTo = handled.redirectTo || `${origin}/`; const message = handled.message || 'Oops! Something went wrong. Please try again in a moment.'; diff --git a/api-server/server/middlewares/request-authorization.js b/api-server/server/middlewares/request-authorization.js index 55d98a1223..9674a9765c 100644 --- a/api-server/server/middlewares/request-authorization.js +++ b/api-server/server/middlewares/request-authorization.js @@ -6,10 +6,10 @@ import { errorTypes, authHeaderNS } from '../utils/getSetAccessToken'; -import { homeLocation } from '../../../config/env'; 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$/; @@ -50,6 +50,7 @@ export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) { 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( @@ -61,7 +62,7 @@ export default ({ jwtSecret = _jwtSecret, getUserById = _getUserById } = {}) => new Error('Access token is required for this request'), { type: 'info', - redirect: `${homeLocation}/signin`, + redirect: `${origin}/signin`, message: 'Access token is required for this request', status: 403 } @@ -70,7 +71,7 @@ export default ({ jwtSecret = _jwtSecret, getUserById = _getUserById } = {}) => if (!accessToken && error === errorTypes.invalidToken) { throw wrapHandledError(new Error('Access token is invalid'), { type: 'info', - redirect: `${homeLocation}/signin`, + redirect: `${origin}/signin`, message: 'Your access token is invalid', status: 403 }); @@ -78,7 +79,7 @@ export default ({ jwtSecret = _jwtSecret, getUserById = _getUserById } = {}) => if (!accessToken && error === errorTypes.expiredToken) { throw wrapHandledError(new Error('Access token is no longer valid'), { type: 'info', - redirect: `${homeLocation}/signin`, + redirect: `${origin}/signin`, message: 'Access token is no longer valid', status: 403 }); diff --git a/api-server/server/middlewares/request-authorization.test.js b/api-server/server/middlewares/request-authorization.test.js index ba56cded40..22962a8f45 100644 --- a/api-server/server/middlewares/request-authorization.test.js +++ b/api-server/server/middlewares/request-authorization.test.js @@ -1,8 +1,9 @@ /* global describe it expect */ import sinon from 'sinon'; -import { mockReq, mockRes } from 'sinon-express-mock'; +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'; @@ -26,6 +27,12 @@ const users = { 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\//; diff --git a/api-server/server/passport-providers.js b/api-server/server/passport-providers.js index 4d0dbc2ad9..7ac4ffb0f3 100644 --- a/api-server/server/passport-providers.js +++ b/api-server/server/passport-providers.js @@ -3,6 +3,7 @@ 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`; diff --git a/api-server/server/utils/commit-goals.json b/api-server/server/utils/commit-goals.json deleted file mode 100644 index c4630405a7..0000000000 --- a/api-server/server/utils/commit-goals.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "frontEndCert": "Front End Development Certification", - "backEndCert": "Back End Development Certification", - "fullStackCert": "Full Stack Development Certification", - "respWebDesignCert": "Responsive Web Design Certification", - "frontEndLibsCert": "Front End Libraries Certification", - "jsAlgoDataStructCert": "JavaScript Algorithms and Data Structures Certification", - "dataVisCert": "Data Visualisation Certification", - "apisMicroservicesCert": "APIs and Microservices Certification", - "infosecQaCert": "Information Security and Quality Assurance Certification", - "qaCert": "Quality Assurance Certification", - "infosecCert": "Information Security Certification", - "sciCompPyCert": "Scientific Computing with Python Certification", - "dataAnalysisPyCert": "Data Analysis with Python Certification", - "machineLearningPyCert": "Machine Learning with Python Certification" -} diff --git a/api-server/server/utils/commit.js b/api-server/server/utils/commit.js deleted file mode 100644 index a34de539d2..0000000000 --- a/api-server/server/utils/commit.js +++ /dev/null @@ -1,63 +0,0 @@ -import dedent from 'dedent'; -import debugFactory from 'debug'; -import { Observable } from 'rx'; -import commitGoals from './commit-goals.json'; - -const debug = debugFactory('fcc:utils/commit'); - -export { commitGoals }; - -export function completeCommitment$(user) { - const { - isFrontEndCert, - isBackEndCert, - isFullStackCert, - isRespWebDesignCert, - isFrontEndLibsCert, - isJsAlgoDataStructCert, - isDataVisCert, - isApisMicroservicesCert, - isInfosecQaCert, - isQaCertV7, - isInfosecCertV7, - isSciCompPyCertV7, - isDataAnalysisPyCertV7, - isMachineLearningPyCertV7 - } = user; - - return Observable.fromNodeCallback(user.pledge, user)().flatMap(pledge => { - if (!pledge) { - return Observable.just('No pledge found'); - } - - const { goal } = pledge; - - if ( - (isFrontEndCert && goal === commitGoals.frontEndCert) || - (isBackEndCert && goal === commitGoals.backEndCert) || - (isFullStackCert && goal === commitGoals.fullStackCert) || - (isRespWebDesignCert && goal === commitGoals.respWebDesignCert) || - (isFrontEndLibsCert && goal === commitGoals.frontEndLibsCert) || - (isJsAlgoDataStructCert && goal === commitGoals.jsAlgoDataStructCert) || - (isDataVisCert && goal === commitGoals.dataVisCert) || - (isApisMicroservicesCert && goal === commitGoals.apisMicroservicesCert) || - (isInfosecQaCert && goal === commitGoals.infosecQaCert) || - (isQaCertV7 && goal === commitGoals.QaCert) || - (isInfosecCertV7 && goal === commitGoals.infosecCert) || - (isSciCompPyCertV7 && goal === commitGoals.sciCompPyCert) || - (isDataAnalysisPyCertV7 && goal === commitGoals.dataAnalysisPyCert) || - (isMachineLearningPyCertV7 && goal === commitGoals.machineLearningPyCert) - ) { - debug('marking goal complete'); - pledge.isCompleted = true; - pledge.dateEnded = new Date(); - pledge.formerUserId = pledge.userId; - pledge.userId = null; - return Observable.fromNodeCallback(pledge.save, pledge)(); - } - return Observable.just(dedent` - You have not yet reached your goal of completing the ${goal} - Please retry when you have met the requirements. - `); - }); -} diff --git a/api-server/server/utils/commit.json b/api-server/server/utils/commit.json deleted file mode 100644 index 3fa35468fa..0000000000 --- a/api-server/server/utils/commit.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "name": "girl develop it", - "displayName": "Girl Develop It", - "donateUrl": "https://www.girldevelopit.com/donate", - "description": "Girl Develop It provides in-person classes for women to learn to code.", - "imgAlt": "Girl Develop It participants coding at tables.", - "imgUrl": "https://cdn-media-1.freecodecamp.org/imgr/U1CyEuA.jpg" - }, - { - "name": "black girls code", - "displayName": "Black Girls CODE", - "donateUrl": "http://www.blackgirlscode.com/", - "description": "Black Girls CODE is devoted to showing the world that black girls can code, and do so much more.", - "imgAlt": "Girls developing code with instructor", - "imgUrl": "https://cdn-media-1.freecodecamp.org/imgr/HBVrdaj.jpg" - }, - { - "name": "coderdojo", - "displayName": "CoderDojo", - "donateUrl": "https://www.globalgiving.org/projects/coderdojo-start-a-dojo-support/", - "description": "CoderDojo is the global network of free computer programming clubs for young people.", - "imgAlt": "Two adults help several kids program on their laptops.", - "imgUrl": "https://cdn-media-1.freecodecamp.org/imgr/701RLfV.jpg" - }, - { - "name": "women who code", - "displayName": "Women Who Code", - "donateUrl": "https://www.womenwhocode.com/donate", - "description": "Women Who Code (WWCode) is a global leader in propelling women in the tech industry.", - "imgAlt": "Four women sitting in a classroom together learning to code.", - "imgUrl": "https://cdn-media-1.freecodecamp.org/imgr/tKUi6Rf.jpg" - }, - { - "name": "girls who code", - "displayName": "Girls Who Code", - "donateUrl": "http://girlswhocode.com/", - "description": "Girls Who Code programs work to inspire, educate, and equip girls with the computing skills to pursue 21st century opportunities.", - "imgAlt": "Three women smiling while they code on a computer together.", - "imgUrl": "https://cdn-media-1.freecodecamp.org/imgr/op8BVph.jpg" - }, - { - "name": "hack club", - "displayName": "Hack Club", - "donateUrl": "https://hackclub.com/donate", - "description": "Hack Club helps high schoolers start after-school coding clubs.", - "imgAlt": "A bunch of high school students posing for a photo in their Hack Club.", - "imgUrl": "https://cdn-media-1.freecodecamp.org/imgr/G2YvPHf.jpg" - } -] diff --git a/api-server/server/utils/get-return-to.test.js b/api-server/server/utils/get-return-to.test.js deleted file mode 100644 index 237a91fb37..0000000000 --- a/api-server/server/utils/get-return-to.test.js +++ /dev/null @@ -1,60 +0,0 @@ -/* global describe expect it */ - -const { homeLocation } = require('../../../config/env.json'); -const jwt = require('jsonwebtoken'); - -const { getReturnTo } = require('./get-return-to'); - -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 = `${homeLocation}/learn`; -const defaultOrigin = homeLocation; -const defaultPrefix = ''; - -const defaultObject = { - returnTo: defaultReturnTo, - origin: defaultOrigin, - pathPrefix: defaultPrefix -}; - -describe('get-return-to', () => { - describe('getReturnTo', () => { - it('should extract returnTo from a jwt', () => { - expect.assertions(1); - - const encryptedReturnTo = jwt.sign( - { returnTo: validReturnTo }, - validJWTSecret - ); - expect(getReturnTo(encryptedReturnTo, validJWTSecret)).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)).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)).toStrictEqual( - defaultObject - ); - }); - }); -}); diff --git a/api-server/server/utils/middleware.js b/api-server/server/utils/middleware.js index 3744f33a0f..aa9824bf8b 100644 --- a/api-server/server/utils/middleware.js +++ b/api-server/server/utils/middleware.js @@ -2,22 +2,23 @@ import dedent from 'dedent'; import { validationResult } from 'express-validator'; import { createValidatorErrorFormatter } from './create-handled-error.js'; -import { homeLocation } from '../../../config/env'; import { getAccessTokenFromRequest, removeCookies } from './getSetAccessToken.js'; +import { getRedirectParams } from './redirection'; -export function ifNoUserRedirectTo(url, message, type = 'errors') { +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(url); + return res.redirect(origin); }; } @@ -55,15 +56,13 @@ export function ifNotVerifiedRedirectToUpdateEmail(req, res, next) { return next(); } -export function ifUserRedirectTo(path = `${homeLocation}/learn`, status) { +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) { - if (req.query && req.query.returnTo) { - return res.status(status).redirect(req.query.returnTo); - } - return res.status(status).redirect(path); + return res.status(status).redirect(returnTo); } if (req.user && !accessToken) { // This request has an active auth session diff --git a/api-server/server/utils/get-return-to.js b/api-server/server/utils/redirection.js similarity index 74% rename from api-server/server/utils/get-return-to.js rename to api-server/server/utils/redirection.js index 89be3792fc..c93f6fe735 100644 --- a/api-server/server/utils/get-return-to.js +++ b/api-server/server/utils/redirection.js @@ -1,30 +1,33 @@ 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 +// 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(encryptedReturnTo, secret) { +function getReturnTo(encryptedParams, secret, _homeLocation = homeLocation) { let params; try { - params = jwt.verify(encryptedReturnTo, secret); + 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, + returnTo: `${_homeLocation}/learn`, + origin: _homeLocation, pathPrefix: '' }; } - return normalizeParams(params); + return normalizeParams(params, _homeLocation); } // TODO: tests! -function normalizeParams({ returnTo, origin, pathPrefix }) { +function normalizeParams( + { returnTo, origin, pathPrefix }, + _homeLocation = homeLocation +) { // coerce to strings, just in case something weird and nefarious is happening returnTo = '' + returnTo; origin = '' + origin; @@ -35,20 +38,20 @@ function normalizeParams({ returnTo, origin, pathPrefix }) { !returnTo || !allowedOrigins.some(allowed => returnTo.startsWith(allowed + '/')) ) { - returnTo = `${homeLocation}/learn`; + returnTo = `${_homeLocation}/learn`; + origin = _homeLocation; + pathPrefix = ''; } - // this can be strict equality. if (!origin || !allowedOrigins.includes(origin)) { - origin = homeLocation; + returnTo = `${_homeLocation}/learn`; + origin = _homeLocation; + pathPrefix = ''; } - // default to '' if the locale isn't recognised pathPrefix = availableLangs.client.includes(pathPrefix) ? pathPrefix : ''; return { returnTo, origin, pathPrefix }; } -// TODO: use this to redirect to current challenge // 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) { @@ -59,7 +62,7 @@ function getRedirectBase(origin, pathPrefix) { // TODO: this might be cleaner if we just use a URL for returnTo (call it // returnURL for clarity) rather than pulling out origin and returning it // separately -function getParamsFromReq(req) { +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. @@ -68,7 +71,7 @@ function getParamsFromReq(req) { // 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 { returnTo: returnUrl.href, origin, pathPrefix }; + return _normalizeParams({ returnTo: returnUrl.href, origin, pathPrefix }); } function isRootPath(redirectBase, returnUrl) { @@ -80,5 +83,5 @@ function isRootPath(redirectBase, returnUrl) { module.exports.getReturnTo = getReturnTo; module.exports.getRedirectBase = getRedirectBase; module.exports.normalizeParams = normalizeParams; -module.exports.getParamsFromReq = getParamsFromReq; +module.exports.getRedirectParams = getRedirectParams; module.exports.isRootPath = isRootPath; diff --git a/api-server/server/utils/redirection.test.js b/api-server/server/utils/redirection.test.js new file mode 100644 index 0000000000..37dfdb91dc --- /dev/null +++ b/api-server/server/utils/redirection.test.js @@ -0,0 +1,120 @@ +/* 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 + ); + }); + }); +}); diff --git a/api-server/server/views/commit/directory.jade b/api-server/server/views/commit/directory.jade deleted file mode 100644 index d2e86ef426..0000000000 --- a/api-server/server/views/commit/directory.jade +++ /dev/null @@ -1,14 +0,0 @@ -extends ../layout -block content - h1.text-center Commit to one of these nonprofits - hr - .row - .col-xs-12.col-sm-10.col-sm-offset-1 - for nonprofit in nonprofits - .col-xs-12.col-sm-6.col-md-4.height-400 - .text-center - h2= nonprofit.displayName - img.testimonial-image.img-responsive.img-center(src=nonprofit.imgUrl) - .button-spacer - a.text-center(href='/commit?nonprofit=#{nonprofit.name}') Commit to #{nonprofit.displayName} - p= nonprofit.description diff --git a/api-server/server/views/commit/index.jade b/api-server/server/views/commit/index.jade deleted file mode 100644 index acde265865..0000000000 --- a/api-server/server/views/commit/index.jade +++ /dev/null @@ -1,110 +0,0 @@ -extends ../layout -block content - h2.text-center Commit to yourself. Commit to a nonprofit. - .row - .col-xs-12.col-sm-6.col-sm-offset-3 - p You can give yourself external motivation, and immediately start helping nonprofits. You can do this by pledging a monthly donation to a nonprofit until you’ve earned one of our certifications. This pledge is completely optional. This pledge is entirely between you and the nonprofit, and no money goes to freeCodeCamp. You can change your commitment or stop it at any time. - - .row - .col-xs-12.col-sm-6.col-sm-offset-3.text-center - h3 Pledge to #{displayName}  - .button-spacer - a(href='#{imgUrl}' data-lightbox='img-enlarge' alt='#{imgAlt}') - img.img-responsive(src='#{imgUrl}' alt='#{imgAlt}') - p.large-p - = description - p - a(href='/commit/directory') ...or see other nonprofits - .spacer - form.form(name='commit') - .hidden - input(type='text' value='#{name}' name='nonprofit') - .row - .col-xs-12.col-sm-6.col-sm-offset-3 - h3 Step 1: Which certification do you pledge to complete? - .btn-group-vertical(data-toggle='buttons' role='group') - label.btn.btn-primary.active - input(type='radio' id="respWebDesignCert" value="Responsive Web Design Certification" name='goal' checked="checked") - | Responsive Web Design - label.btn.btn-primary - input(type='radio' id="frontEndLibsCert" value="Front End Libraries Certification" name='goal') - | Front End Libraries - label.btn.btn-primary - input(type='radio' id="jsAlgoDataStructCert" value="JavaScript Algorithms and Data Structures Certification" name='goal') - | JavaScript Algorithms and Data Structures - label.btn.btn-primary - input(type='radio' id="dataVisCert" value="Data Visualization Certification" name='goal') - | Data Visualization - label.btn.btn-primary - input(type='radio' id="apisMicroservicesCert" value="APIs and Microservices Certification" name='goal') - | APIs and Microservices - label.btn.btn-primary - input(type='radio' id="infosecQaCert" value="Information Security and Quality Assurance Certification" name='goal') - | Information Security and Quality Assurance - .spacer - .row - .col-xs-12.col-sm-6.col-sm-offset-3 - h3 Step 2: How much do you want to pledge monthly until you earn that certification? - .btn-group.btn-group-justified(data-toggle='buttons' role='group') - label.btn.btn-primary - input(type='radio' id='5-dollar-pledge' value='5' name='amount') - | $5 per month - label.btn.btn-primary.active - input(type='radio' id='10-dollar-pledge' value='10' name='amount' checked="checked") - | $10 per month - label.btn.btn-primary - input(type='radio' id='25-dollar-pledge' value='25' name='amount') - | $25 per month - label.btn.btn-primary - input(type='radio' id='50-dollar-pledge' value='50' name='amount') - | $50 per month - .spacer - .col-xs-12.col-sm-6.col-sm-offset-3 - h3 Step 3: Set up your monthly donation - .row - .col-xs-12.col-sm-6.col-sm-offset-3.text-center - a#commit-btn-donate.btn.btn-block.btn-lg.btn-primary(href=donateUrl target='_blank') Open the #{displayName} donation page - - .spacer - .col-xs-12.col-sm-6.col-sm-offset-3 - h3#commit-step4-text.disabled - Step 4: Confirm - span#commit-step4-hidden.disabled (Do step 3 first) - span#commit-step4-show.hidden your commitment to your goal - .row - .col-xs-12.col-sm-6.col-sm-offset-3.text-center - button#commit-btn-submit.btn.btn-block.btn-lg.btn-primary.disabled Commit - - if pledge - form.row(name='stop-pledge' action='/commit/stop-commitment' method='post') - input(type='hidden', name='_csrf', value=_csrf) - .col-xs-12.col-sm-6.col-sm-offset-3.text-center - .button-spacer - button.btn.btn-block.btn-lg.btn-default(name='submit' type='submit') Stop my current pledge - else - .row - .col-xs-12.col-sm-6.col-sm-offset-3.text-center - .button-spacer - a.btn.btn-block.btn-lg.btn-default(href='/map') Maybe later - script. - $(function() { - $('#commit-btn-donate').click(function() { - $('#commit-btn-submit').removeClass('disabled'); - $('#commit-step4-text').removeClass('disabled'); - $('#commit-step4-hidden').hide(); - $('#commit-step4-show').removeClass('hidden'); - }); - - $('#commit-btn-submit').click(function() { - - if ( - history && - typeof history.pushState === 'function' - ) { - history.pushState(history.state, null, '/commit/pledge?' + $('form').serialize()); - return null; - } - - window.location.href = '/commit/pledge?' + $('form').serialize(); - }); - }); diff --git a/api-server/server/views/commit/pledge.jade b/api-server/server/views/commit/pledge.jade deleted file mode 100644 index b787551c97..0000000000 --- a/api-server/server/views/commit/pledge.jade +++ /dev/null @@ -1,15 +0,0 @@ -extends ../layout -block content - .panel.panel-info - .panel-body - h3.text-center You've committed! - .row - .col-xs-12.col-sm-6.col-sm-offset-3 - p Congratulations, you have committed to giving - span(style='text-transform: capitalize') #{nonprofit} - | #{amount} dollars a month until you have reached your goal - | of completing your #{goal} - .row - .col-xs-12.col-sm-6.col-sm-offset-3 - img.img-responsive(src='https://cdn-media-1.freecodecamp.org/imgr/U1CyEuA.jpg' alt="Girl Develop It participants coding at tables.") - p Girl Develop It is a nonprofit that provides in-person classes for women to learn to code.