freeCodeCamp/api-server/server/boot/challenge.js

352 lines
9.2 KiB
JavaScript

/**
*
* 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 { homeLocation } from '../../../config/env';
import { ifNoUserSend } from '../utils/middleware';
import { dasherize } from '../../../utils/slugs';
import { fixCompletedChallengeItem } from '../../common/utils';
import { getChallenges } from '../utils/get-curriculum';
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
);
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 learnURL = `${homeLocation}/learn`;
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);
}
export function createRedirectToCurrentChallenge(
challengeUrlResolver,
{ _homeLocation = homeLocation, _learnUrl = learnURL } = {}
) {
return async function redirectToCurrentChallenge(req, res, next) {
const { user } = req;
if (!user) {
return res.redirect(_learnUrl);
}
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(`${_homeLocation}${challengeUrl}`);
};
}