mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-30 16:01:14 -04:00
893 lines
24 KiB
JavaScript
893 lines
24 KiB
JavaScript
/**
|
|
*
|
|
* Any ref to fixCompletedChallengesItem should be removed post
|
|
* a db migration to fix all completedChallenges
|
|
*
|
|
* NOTE: it's been 4 years, so any active users will have been migrated. We
|
|
* should still try to migrate the rest at some point.
|
|
*
|
|
*/
|
|
import debug from 'debug';
|
|
import dedent from 'dedent';
|
|
import { isEmpty, pick, omit, uniqBy } from 'lodash';
|
|
import { ObjectID } from 'mongodb';
|
|
import isNumeric from 'validator/lib/isNumeric';
|
|
import isURL from 'validator/lib/isURL';
|
|
import fetch from 'node-fetch';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
import { jwtSecret } from '../../../../config/secrets';
|
|
|
|
import {
|
|
fixPartiallyCompletedChallengeItem,
|
|
fixCompletedExamItem
|
|
} from '../../common/utils';
|
|
import { getChallenges } from '../utils/get-curriculum';
|
|
import { ifNoUserSend } from '../utils/middleware';
|
|
import {
|
|
getRedirectParams,
|
|
normalizeParams,
|
|
getPrefixedLandingPath
|
|
} from '../utils/redirection';
|
|
import { generateRandomExam, createExamResults } from '../utils/exam';
|
|
import {
|
|
validateExamFromDbSchema,
|
|
validateExamResultsSchema,
|
|
validateGeneratedExamSchema,
|
|
validateUserCompletedExamSchema
|
|
} from '../utils/exam-schemas';
|
|
import { isMicrosoftLearnLink } from '../../../../utils/validate';
|
|
import { getApiUrlFromTrophy } from '../utils/ms-learn-utils';
|
|
|
|
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(
|
|
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
|
|
);
|
|
|
|
const generateExam = createGenerateExam(app);
|
|
|
|
api.get('/exam/:id', send200toNonUser, generateExam);
|
|
|
|
const examChallengeCompleted = createExamChallengeCompleted(app);
|
|
|
|
api.post(
|
|
'/exam-challenge-completed',
|
|
send200toNonUser,
|
|
examChallengeCompleted
|
|
);
|
|
|
|
api.post(
|
|
'/save-challenge',
|
|
send200toNonUser,
|
|
isValidChallengeCompletion,
|
|
saveChallenge
|
|
);
|
|
|
|
router.get('/challenges/current-challenge', redirectToCurrentChallenge);
|
|
|
|
const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app);
|
|
|
|
api.post('/coderoad-challenge-completed', coderoadChallengeCompleted);
|
|
|
|
app.use(api);
|
|
app.use(router);
|
|
done();
|
|
}
|
|
|
|
const jsCertProjectIds = [
|
|
'aaa48de84e1ecc7c742e1124',
|
|
'a7f4d8f2483413a6ce226cac',
|
|
'56533eb9ac21ba0edf2244e2',
|
|
'aff0395860f5d3034dc0bfc9',
|
|
'aa2e6f85cab2ab736c9a9b24'
|
|
];
|
|
|
|
const multifileCertProjectIds = getChallenges()
|
|
.filter(challenge => challenge.challengeType === 14)
|
|
.map(challenge => challenge.id);
|
|
|
|
const savableChallenges = getChallenges()
|
|
.filter(challenge => challenge.challengeType === 14)
|
|
.map(challenge => challenge.id);
|
|
|
|
export function buildUserUpdate(
|
|
user,
|
|
challengeId,
|
|
_completedChallenge,
|
|
timezone
|
|
) {
|
|
const { files, completedDate = Date.now() } = _completedChallenge;
|
|
let completedChallenge = {};
|
|
if (
|
|
jsCertProjectIds.includes(challengeId) ||
|
|
multifileCertProjectIds.includes(challengeId)
|
|
) {
|
|
completedChallenge = {
|
|
..._completedChallenge,
|
|
files: files?.map(file =>
|
|
pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext'])
|
|
)
|
|
};
|
|
} else {
|
|
completedChallenge = omit(_completedChallenge, ['files']);
|
|
}
|
|
let finalChallenge;
|
|
const $push = {},
|
|
$set = {},
|
|
$pull = {};
|
|
const {
|
|
timezone: userTimezone,
|
|
completedChallenges = [],
|
|
needsModeration = false,
|
|
savedChallenges = []
|
|
} = user;
|
|
|
|
const oldIndex = completedChallenges.findIndex(
|
|
({ id }) => challengeId === id
|
|
);
|
|
|
|
const alreadyCompleted = oldIndex !== -1;
|
|
const oldChallenge = alreadyCompleted ? completedChallenges[oldIndex] : null;
|
|
|
|
if (alreadyCompleted) {
|
|
finalChallenge = {
|
|
...completedChallenge,
|
|
completedDate: oldChallenge.completedDate
|
|
};
|
|
$set[`completedChallenges.${oldIndex}`] = finalChallenge;
|
|
} else {
|
|
finalChallenge = {
|
|
...completedChallenge
|
|
};
|
|
$push.progressTimestamps = completedDate;
|
|
$push.completedChallenges = finalChallenge;
|
|
}
|
|
|
|
if (savableChallenges.includes(challengeId)) {
|
|
const challengeToSave = {
|
|
id: challengeId,
|
|
lastSavedDate: completedDate,
|
|
files: files?.map(file =>
|
|
pick(file, ['contents', 'key', 'name', 'ext', 'history'])
|
|
)
|
|
};
|
|
|
|
const savedIndex = savedChallenges.findIndex(
|
|
({ id }) => challengeId === id
|
|
);
|
|
|
|
if (savedIndex >= 0) {
|
|
$set[`savedChallenges.${savedIndex}`] = challengeToSave;
|
|
savedChallenges[savedIndex] = challengeToSave;
|
|
} else {
|
|
$push.savedChallenges = challengeToSave;
|
|
savedChallenges.push(challengeToSave);
|
|
}
|
|
}
|
|
|
|
// remove from partiallyCompleted on submit
|
|
$pull.partiallyCompletedChallenges = { id: challengeId };
|
|
|
|
if (
|
|
timezone &&
|
|
timezone !== 'UTC' &&
|
|
(!userTimezone || userTimezone === 'UTC')
|
|
) {
|
|
$set.timezone = userTimezone;
|
|
}
|
|
|
|
if (needsModeration) $set.needsModeration = true;
|
|
|
|
const updateData = {};
|
|
if (!isEmpty($set)) updateData.$set = $set;
|
|
if (!isEmpty($push)) updateData.$push = $push;
|
|
if (!isEmpty($pull)) updateData.$pull = $pull;
|
|
|
|
return {
|
|
alreadyCompleted,
|
|
updateData,
|
|
completedDate: finalChallenge.completedDate,
|
|
savedChallenges
|
|
};
|
|
}
|
|
|
|
export function buildExamUserUpdate(user, _completedChallenge) {
|
|
const {
|
|
id,
|
|
challengeType,
|
|
completedDate = Date.now(),
|
|
examResults
|
|
} = _completedChallenge;
|
|
|
|
let finalChallenge = { id, challengeType, completedDate, examResults };
|
|
|
|
const { completedChallenges = [] } = user;
|
|
const $push = {},
|
|
$set = {};
|
|
|
|
// Always push to completedExams[] to keep a record of all submissions, it may come in handy.
|
|
$push.completedExams = fixCompletedExamItem(_completedChallenge);
|
|
|
|
let alreadyCompleted = false;
|
|
let addPoint = false;
|
|
|
|
// completedChallenges[] should have their best exam
|
|
if (examResults.passed) {
|
|
const alreadyCompletedIndex = completedChallenges.findIndex(
|
|
challenge => challenge.id === id
|
|
);
|
|
|
|
alreadyCompleted = alreadyCompletedIndex !== -1;
|
|
|
|
if (alreadyCompleted) {
|
|
const { percentCorrect } = examResults;
|
|
const oldChallenge = completedChallenges[alreadyCompletedIndex];
|
|
const oldResults = oldChallenge.examResults;
|
|
|
|
// only update if it's a better result
|
|
if (percentCorrect > oldResults.percentCorrect) {
|
|
finalChallenge.completedDate = oldChallenge.completedDate;
|
|
$set[`completedChallenges.${alreadyCompletedIndex}`] = finalChallenge;
|
|
}
|
|
} else {
|
|
addPoint = true;
|
|
$push.completedChallenges = finalChallenge;
|
|
}
|
|
}
|
|
|
|
const updateData = {};
|
|
if (!isEmpty($set)) updateData.$set = $set;
|
|
if (!isEmpty($push)) updateData.$push = $push;
|
|
|
|
return {
|
|
alreadyCompleted,
|
|
addPoint,
|
|
updateData,
|
|
completedDate: finalChallenge.completedDate
|
|
};
|
|
}
|
|
|
|
export function buildChallengeUrl(challenge) {
|
|
const { superBlock, block, dashedName } = challenge;
|
|
return `/learn/${superBlock}/${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 === 0 && 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;
|
|
|
|
// ToDO: Validate other things (challengeFiles, etc)
|
|
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 async function modernChallengeCompleted(req, res, next) {
|
|
const user = req.user;
|
|
|
|
try {
|
|
// This is an ugly way to update `user.completedChallenges`
|
|
await user.getCompletedChallenges$().toPromise();
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
const completedDate = Date.now();
|
|
const { id, files, challengeType } = req.body;
|
|
|
|
const completedChallenge = {
|
|
id,
|
|
files,
|
|
completedDate
|
|
};
|
|
|
|
// if multifile cert project
|
|
if (challengeType === 14) {
|
|
completedChallenge.isManuallyApproved = false;
|
|
user.needsModeration = true;
|
|
}
|
|
|
|
// We only need to know the challenge type if it's a project. If it's a
|
|
// step or normal challenge we can avoid storing in the database.
|
|
if (jsCertProjectIds.includes(id) || multifileCertProjectIds.includes(id)) {
|
|
completedChallenge.challengeType = challengeType;
|
|
}
|
|
|
|
const { alreadyCompleted, savedChallenges, updateData } = buildUserUpdate(
|
|
user,
|
|
id,
|
|
completedChallenge
|
|
);
|
|
|
|
const points = alreadyCompleted ? user.points : user.points + 1;
|
|
|
|
user.updateAttributes(updateData, err => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
|
|
return res.json({
|
|
points,
|
|
alreadyCompleted,
|
|
completedDate,
|
|
savedChallenges
|
|
});
|
|
});
|
|
}
|
|
|
|
async 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.'
|
|
});
|
|
}
|
|
|
|
// CodeRoad cert project
|
|
if (completedChallenge.challengeType === 13) {
|
|
const { partiallyCompletedChallenges = [], completedChallenges = [] } =
|
|
user;
|
|
|
|
const isPartiallyCompleted = partiallyCompletedChallenges.some(
|
|
challenge => challenge.id === completedChallenge.id
|
|
);
|
|
|
|
const isCompleted = completedChallenges.some(
|
|
challenge => challenge.id === completedChallenge.id
|
|
);
|
|
|
|
if (!isPartiallyCompleted && !isCompleted) {
|
|
return res.status(403).json({
|
|
type: 'error',
|
|
message: 'You have to complete the project before you can submit a URL.'
|
|
});
|
|
}
|
|
}
|
|
|
|
const isMSTrophyProject = completedChallenge.challengeType === 18;
|
|
let isTrophyMissing = false;
|
|
if (isMSTrophyProject) {
|
|
if (!isMicrosoftLearnLink(completedChallenge.solution)) {
|
|
return res.status(403).json({
|
|
type: 'error',
|
|
message:
|
|
'You have not provided the valid links for us to inspect your work.'
|
|
});
|
|
}
|
|
try {
|
|
const mSLearnAPIUrl = getApiUrlFromTrophy(completedChallenge.solution);
|
|
isTrophyMissing = mSLearnAPIUrl ? !(await fetch(mSLearnAPIUrl)).ok : true;
|
|
} catch {
|
|
isTrophyMissing = true;
|
|
log(`Error verifying trophy: ${completedChallenge.solution}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
// This is an ugly hack to update `user.completedChallenges`
|
|
await user.getCompletedChallenges$().toPromise();
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
const { alreadyCompleted, updateData } = buildUserUpdate(
|
|
user,
|
|
completedChallenge.id,
|
|
completedChallenge
|
|
);
|
|
|
|
user.updateAttributes(updateData, err => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
|
|
return res.json({
|
|
alreadyCompleted,
|
|
points: alreadyCompleted ? user.points : user.points + 1,
|
|
completedDate: completedChallenge.completedDate,
|
|
...(isMSTrophyProject && { isTrophyMissing })
|
|
});
|
|
});
|
|
}
|
|
|
|
async function backendChallengeCompleted(req, res, next) {
|
|
const { user, body = {} } = req;
|
|
|
|
const completedChallenge = pick(body, ['id', 'solution']);
|
|
completedChallenge.completedDate = Date.now();
|
|
|
|
try {
|
|
await user.getCompletedChallenges$().toPromise();
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
const { alreadyCompleted, updateData } = buildUserUpdate(
|
|
user,
|
|
completedChallenge.id,
|
|
completedChallenge
|
|
);
|
|
|
|
user.updateAttributes(updateData, err => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
|
|
return res.json({
|
|
alreadyCompleted,
|
|
points: alreadyCompleted ? user.points : user.points + 1,
|
|
completedDate: completedChallenge.completedDate
|
|
});
|
|
});
|
|
}
|
|
|
|
// TODO: send flash message keys to client so they can be i18n
|
|
function createGenerateExam(app) {
|
|
const { Exam } = app.models;
|
|
|
|
return async function generateExam(req, res, next) {
|
|
const {
|
|
user,
|
|
params: { id }
|
|
} = req;
|
|
|
|
try {
|
|
await user.getCompletedChallenges$().toPromise();
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
try {
|
|
const examFromDb = await Exam.findById(id);
|
|
if (!examFromDb) {
|
|
res.status(500);
|
|
throw new Error(
|
|
`An error occurred trying to get the exam from the database.`
|
|
);
|
|
}
|
|
|
|
// This is cause there was struggles validating the exam directly from the db/loopback
|
|
const examJson = JSON.parse(JSON.stringify(examFromDb));
|
|
|
|
const validExamFromDbSchema = validateExamFromDbSchema(examJson);
|
|
|
|
if (validExamFromDbSchema.error) {
|
|
res.status(500);
|
|
log(validExamFromDbSchema.error);
|
|
throw new Error(
|
|
`An error occurred validating the exam information from the database.`
|
|
);
|
|
}
|
|
|
|
const { prerequisites, numberOfQuestionsInExam, title } = examJson;
|
|
|
|
// Validate User has completed prerequisite challenges
|
|
prerequisites?.forEach(prerequisite => {
|
|
const prerequisiteCompleted = user.completedChallenges.find(
|
|
challenge => challenge.id === prerequisite.id
|
|
);
|
|
|
|
if (!prerequisiteCompleted) {
|
|
res.status(403);
|
|
throw new Error(
|
|
`You have not completed the required challenges to start the '${title}'.`
|
|
);
|
|
}
|
|
});
|
|
|
|
const randomizedExam = generateRandomExam(examJson);
|
|
|
|
const validGeneratedExamSchema = validateGeneratedExamSchema(
|
|
randomizedExam,
|
|
numberOfQuestionsInExam
|
|
);
|
|
|
|
if (validGeneratedExamSchema.error) {
|
|
res.status(500);
|
|
log(validGeneratedExamSchema.error);
|
|
throw new Error(`An error occurred trying to randomize the exam.`);
|
|
}
|
|
|
|
return res.send({ generatedExam: randomizedExam });
|
|
} catch (err) {
|
|
log(err);
|
|
return res.send({ error: err.message });
|
|
}
|
|
};
|
|
}
|
|
|
|
function createExamChallengeCompleted(app) {
|
|
const { Exam } = app.models;
|
|
|
|
return async function examChallengeCompleted(req, res, next) {
|
|
const { body = {}, user } = req;
|
|
|
|
try {
|
|
await user.getCompletedChallenges$().toPromise();
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
const { userCompletedExam = [], id } = body;
|
|
|
|
try {
|
|
const examFromDb = await Exam.findById(id);
|
|
if (!examFromDb) {
|
|
res.status(500);
|
|
throw new Error(
|
|
`An error occurred tryng to get the exam from the database.`
|
|
);
|
|
}
|
|
|
|
// This is cause there was struggles validating the exam directly from the db/loopback
|
|
const examJson = JSON.parse(JSON.stringify(examFromDb));
|
|
|
|
const validExamFromDbSchema = validateExamFromDbSchema(examJson);
|
|
if (validExamFromDbSchema.error) {
|
|
res.status(500);
|
|
log(validExamFromDbSchema.error);
|
|
throw new Error(
|
|
`An error occurred validating the exam information from the database.`
|
|
);
|
|
}
|
|
|
|
const { prerequisites, numberOfQuestionsInExam, title } = examJson;
|
|
|
|
// Validate User has completed prerequisite challenges
|
|
prerequisites?.forEach(prerequisite => {
|
|
const prerequisiteCompleted = user.completedChallenges.find(
|
|
challenge => challenge.id === prerequisite.id
|
|
);
|
|
|
|
if (!prerequisiteCompleted) {
|
|
res.status(403);
|
|
throw new Error(
|
|
`You have not completed the required challenges to start the '${title}'.`
|
|
);
|
|
}
|
|
});
|
|
|
|
// Validate user completed exam
|
|
const validUserCompletedExam = validateUserCompletedExamSchema(
|
|
userCompletedExam,
|
|
numberOfQuestionsInExam
|
|
);
|
|
if (validUserCompletedExam.error) {
|
|
res.status(400);
|
|
log(validUserCompletedExam.error);
|
|
throw new Error(`An error occurred validating the submitted exam.`);
|
|
}
|
|
|
|
const examResults = createExamResults(userCompletedExam, examJson);
|
|
|
|
const validExamResults = validateExamResultsSchema(examResults);
|
|
if (validExamResults.error) {
|
|
res.status(500);
|
|
log(validExamResults.error);
|
|
throw new Error(`An error occurred validating the submitted exam.`);
|
|
}
|
|
|
|
const completedChallenge = pick(body, ['id', 'challengeType']);
|
|
completedChallenge.completedDate = Date.now();
|
|
completedChallenge.examResults = examResults;
|
|
|
|
const { addPoint, alreadyCompleted, updateData, completedDate } =
|
|
buildExamUserUpdate(user, completedChallenge);
|
|
|
|
user.updateAttributes(updateData, err => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
|
|
const points = addPoint ? user.points + 1 : user.points;
|
|
|
|
return res.json({
|
|
alreadyCompleted,
|
|
points,
|
|
completedDate,
|
|
examResults
|
|
});
|
|
});
|
|
} catch (err) {
|
|
log(err);
|
|
return res.send({ error: err.message });
|
|
}
|
|
};
|
|
}
|
|
|
|
async function saveChallenge(req, res, next) {
|
|
const user = req.user;
|
|
const { savedChallenges = [] } = user;
|
|
const { id: challengeId, files = [] } = req.body;
|
|
|
|
if (!savableChallenges.includes(challengeId)) {
|
|
return res.status(403).send('That challenge type is not savable');
|
|
}
|
|
|
|
const challengeToSave = {
|
|
id: challengeId,
|
|
lastSavedDate: Date.now(),
|
|
files: files?.map(file =>
|
|
pick(file, ['contents', 'key', 'name', 'ext', 'history'])
|
|
)
|
|
};
|
|
|
|
try {
|
|
await user.getSavedChallenges$().toPromise();
|
|
} catch (e) {
|
|
return next(e);
|
|
}
|
|
|
|
const savedIndex = savedChallenges.findIndex(({ id }) => challengeId === id);
|
|
const $push = {},
|
|
$set = {};
|
|
|
|
if (savedIndex >= 0) {
|
|
$set[`savedChallenges.${savedIndex}`] = challengeToSave;
|
|
savedChallenges[savedIndex] = challengeToSave;
|
|
} else {
|
|
$push.savedChallenges = challengeToSave;
|
|
savedChallenges.push(challengeToSave);
|
|
}
|
|
|
|
const updateData = {};
|
|
if (!isEmpty($set)) updateData.$set = $set;
|
|
if (!isEmpty($push)) updateData.$push = $push;
|
|
|
|
user.updateAttributes(updateData, err => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
|
|
return res.json({
|
|
savedChallenges
|
|
});
|
|
});
|
|
}
|
|
|
|
const codeRoadChallenges = getChallenges().filter(
|
|
({ challengeType }) => challengeType === 12 || challengeType === 13
|
|
);
|
|
|
|
function createCoderoadChallengeCompleted(app) {
|
|
/* Example request coming from CodeRoad:
|
|
* req.body: { tutorialId: 'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0' }
|
|
* req.headers: { coderoad-user-token: '8kFIlZiwMioY6hqqt...' }
|
|
*/
|
|
|
|
const { UserToken, User } = app.models;
|
|
|
|
return async function coderoadChallengeCompleted(req, res) {
|
|
const { 'coderoad-user-token': encodedUserToken } = req.headers;
|
|
const { tutorialId } = req.body;
|
|
|
|
if (!tutorialId) return res.send(`'tutorialId' not found in request body`);
|
|
|
|
if (!encodedUserToken)
|
|
return res.send(`'coderoad-user-token' not found in request headers`);
|
|
|
|
let userToken;
|
|
|
|
try {
|
|
userToken = jwt.verify(encodedUserToken, jwtSecret)?.userToken;
|
|
} catch {
|
|
return res.send(`invalid user token`);
|
|
}
|
|
|
|
const tutorialRepo = tutorialId?.split(':')[0];
|
|
const tutorialOrg = tutorialRepo?.split('/')?.[0];
|
|
|
|
if (tutorialOrg !== 'freeCodeCamp')
|
|
return res.send('Tutorial not hosted on freeCodeCamp GitHub account');
|
|
|
|
// validate tutorial name is in codeRoadChallenges object
|
|
const challenge = codeRoadChallenges.find(challenge =>
|
|
challenge.url?.endsWith(tutorialRepo)
|
|
);
|
|
|
|
if (!challenge) return res.send('Tutorial name is not valid');
|
|
|
|
const { id: challengeId, challengeType } = challenge;
|
|
|
|
try {
|
|
// check if user token is in database
|
|
const tokenInfo = await UserToken.findOne({
|
|
where: { id: userToken }
|
|
});
|
|
|
|
if (!tokenInfo) return res.send('User token not found');
|
|
|
|
const { userId } = tokenInfo;
|
|
|
|
// check if user exists for user token
|
|
const user = await User.findOne({
|
|
where: { id: userId }
|
|
});
|
|
|
|
if (!user) return res.send('User for user token not found');
|
|
|
|
// submit challenge
|
|
const completedDate = Date.now();
|
|
const { completedChallenges = [], partiallyCompletedChallenges = [] } =
|
|
user;
|
|
|
|
let userUpdateInfo = {};
|
|
|
|
const isCompleted = completedChallenges.some(
|
|
challenge => challenge.id === challengeId
|
|
);
|
|
|
|
// if CodeRoad cert project and not in completedChallenges,
|
|
// add to partiallyCompletedChallenges
|
|
if (challengeType === 13 && !isCompleted) {
|
|
const finalChallenge = {
|
|
id: challengeId,
|
|
completedDate
|
|
};
|
|
|
|
userUpdateInfo.updateData = {};
|
|
userUpdateInfo.updateData.$set = {
|
|
partiallyCompletedChallenges: uniqBy(
|
|
[
|
|
finalChallenge,
|
|
...partiallyCompletedChallenges.map(
|
|
fixPartiallyCompletedChallengeItem
|
|
)
|
|
],
|
|
'id'
|
|
)
|
|
};
|
|
|
|
// else, add to or update completedChallenges
|
|
} else {
|
|
userUpdateInfo = buildUserUpdate(user, challengeId, {
|
|
id: challengeId,
|
|
completedDate
|
|
});
|
|
}
|
|
const updatedUser = await user.updateAttributes(
|
|
userUpdateInfo?.updateData
|
|
);
|
|
|
|
if (!updatedUser)
|
|
return res.send('An error occurred trying to submit the challenge');
|
|
} catch (e) {
|
|
return res.send('An error occurred trying to submit the challenge');
|
|
}
|
|
|
|
return res.send('Successfully submitted challenge');
|
|
};
|
|
}
|
|
|
|
// 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 = getPrefixedLandingPath(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}`);
|
|
};
|
|
}
|