feat(client,api): add user tokens and route for submitting coderoad tutorials (#43304)

Co-authored-by: Nicholas Carrigan (he/him) <nhcarrigan@gmail.com>
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Tom
2021-11-17 08:19:24 -06:00
committed by GitHub
parent 4eb036a2bb
commit f0698aa517
25 changed files with 722 additions and 26 deletions

View File

@@ -325,6 +325,11 @@
"type": "hasMany",
"model": "article",
"foreignKey": "externalId"
},
"webhookTokens": {
"type": "hasMany",
"model": "WebhookToken",
"foreignKey": "userId"
}
},
"acls": [

View File

@@ -36,7 +36,8 @@ const {
infosecV7Id,
sciCompPyV7Id,
dataAnalysisPyV7Id,
machineLearningPyV7Id
machineLearningPyV7Id,
relationalDatabasesV8Id
} = certIds;
const log = debug('fcc:certification');
@@ -112,6 +113,10 @@ function createCertTypeIds(allChallenges) {
[certTypes.machineLearningPyV7]: getCertById(
machineLearningPyV7Id,
allChallenges
),
[certTypes.relationalDatabasesV8]: getCertById(
relationalDatabasesV8Id,
allChallenges
)
};
}

View File

@@ -12,6 +12,7 @@ import { Observable } from 'rx';
import isNumeric from 'validator/lib/isNumeric';
import isURL from 'validator/lib/isURL';
import { environment, deploymentEnv } from '../../../../config/env.json';
import { fixCompletedChallengeItem } from '../../common/utils';
import { getChallenges } from '../utils/get-curriculum';
import { ifNoUserSend } from '../utils/middleware';
@@ -59,6 +60,10 @@ export default async function bootChallenge(app, done) {
router.get('/challenges/current-challenge', redirectToCurrentChallenge);
const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app);
api.post('/coderoad-challenge-completed', coderoadChallengeCompleted);
app.use(api);
app.use(router);
done();
@@ -78,7 +83,7 @@ export function buildUserUpdate(
_completedChallenge,
timezone
) {
const { files } = _completedChallenge;
const { files, completedDate = Date.now() } = _completedChallenge;
let completedChallenge = {};
if (jsProjects.includes(challengeId)) {
completedChallenge = {
@@ -108,7 +113,7 @@ export function buildUserUpdate(
} else {
updateData.$push = {
...updateData.$push,
progressTimestamps: Date.now()
progressTimestamps: completedDate
};
finalChallenge = {
...completedChallenge
@@ -328,6 +333,87 @@ function backendChallengeCompleted(req, res, next) {
.subscribe(() => {}, next);
}
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 { WebhookToken, User } = app.models;
return async function coderoadChallengeCompleted(req, res) {
const { 'coderoad-user-token': userWebhookToken } = req.headers;
const { tutorialId } = req.body;
if (!tutorialId) return res.send(`'tutorialId' not found in request body`);
if (!userWebhookToken)
return res.send(`'coderoad-user-token' not found in request headers`);
const tutorialRepoPath = tutorialId?.split(':')[0];
const tutorialSplit = tutorialRepoPath?.split('/');
const tutorialOrg = tutorialSplit?.[0];
const tutorialRepoName = tutorialSplit?.[1];
// this allows any GH account to host the repo in development or staging
// .org submissions should always be from repos hosted on the fCC GH org
if (deploymentEnv !== 'staging' && environment !== 'development') {
if (tutorialOrg !== 'freeCodeCamp')
return res.send('Tutorial not hosted on freeCodeCamp GitHub account');
}
const codeRoadChallenges = getChallenges().filter(
challenge => challenge.challengeType === 12
);
// validate tutorial name is in codeRoadChallenges object
const tutorialInfo = codeRoadChallenges.find(tutorial =>
tutorial.url?.includes(tutorialRepoName)
);
if (!tutorialInfo) return res.send('Tutorial name is not valid');
const tutorialMongoId = tutorialInfo?.id;
try {
// check if webhook token is in database
const tokenInfo = await WebhookToken.findOne({
where: { id: userWebhookToken }
});
if (!tokenInfo) return res.send('User webhook token not found');
const { userId } = tokenInfo;
// check if user exists for webhook token
const user = await User.findOne({
where: { id: userId }
});
if (!user) return res.send('User for webhook token not found');
// submit challenge
const completedDate = Date.now();
const userUpdateInfo = buildUserUpdate(user, tutorialMongoId, {
id: tutorialMongoId,
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(

View File

@@ -24,6 +24,8 @@ function bootUser(app) {
const getSessionUser = createReadSessionUser(app);
const postReportUserProfile = createPostReportUserProfile(app);
const postDeleteAccount = createPostDeleteAccount(app);
const postWebhookToken = createPostWebhookToken(app);
const deleteWebhookToken = createDeleteWebhookToken(app);
api.get('/account', sendNonUserToHome, getAccount);
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
@@ -31,6 +33,7 @@ function bootUser(app) {
api.post('/account/delete', ifNoUser401, postDeleteAccount);
api.post('/account/reset-progress', ifNoUser401, postResetProgress);
api.post('/user/webhook-token', postWebhookToken);
api.post(
'/user/report-user/',
ifNoUser401,
@@ -38,14 +41,60 @@ function bootUser(app) {
postReportUserProfile
);
api.delete('/user/webhook-token', deleteWebhookToken);
app.use(api);
}
function createPostWebhookToken(app) {
const { WebhookToken } = app.models;
return async function postWebhookToken(req, res) {
const ttl = 900 * 24 * 60 * 60 * 1000;
let newToken;
try {
await WebhookToken.destroyAll({ userId: req.user.id });
newToken = await WebhookToken.create({ ttl, userId: req.user.id });
} catch (e) {
return res.status(500).json({
type: 'danger',
message: 'flash.create-token-err'
});
}
return res.json(newToken?.id);
};
}
function createDeleteWebhookToken(app) {
const { WebhookToken } = app.models;
return async function deleteWebhookToken(req, res) {
try {
await WebhookToken.destroyAll({ userId: req.user.id });
} catch (e) {
return res.status(500).json({
type: 'danger',
message: 'flash.delete-token-err'
});
}
return res.json(null);
};
}
function createReadSessionUser(app) {
const { Donation } = app.models;
return function getSessionUser(req, res, next) {
return async function getSessionUser(req, res, next) {
const queryUser = req.user;
const webhookTokenArr = await queryUser.webhookTokens({
userId: queryUser.id
});
const webhookToken = webhookTokenArr[0]?.id;
const source =
queryUser &&
Observable.forkJoin(
@@ -83,7 +132,8 @@ function createReadSessionUser(app) {
isTwitter: !!user.twitter,
isWebsite: !!user.website,
...normaliseUserFields(user),
joinDate: user.id.getTimestamp()
joinDate: user.id.getTimestamp(),
webhookToken
}
},
sessionMeta,
@@ -192,8 +242,20 @@ function postResetProgress(req, res, next) {
}
function createPostDeleteAccount(app) {
const { User } = app.models;
return function postDeleteAccount(req, res, next) {
const { User, WebhookToken } = app.models;
return async function postDeleteAccount(req, res, next) {
const {
user: { id: userId }
} = req;
try {
await WebhookToken.destroyAll({ userId });
} catch (err) {
log(
`An error occurred deleting webhook tokens for user with id ${userId} when they tried to delete their account`
);
}
return User.destroyById(req.user.id, function (err) {
if (err) {
return next(err);

View File

@@ -14,7 +14,9 @@ export default function getCsurf() {
const { path } = req;
if (
// eslint-disable-next-line max-len
/^\/hooks\/update-paypal$|^\/donate\/charge-stripe$/.test(path)
/^\/hooks\/update-paypal$|^\/donate\/charge-stripe$|^\/coderoad-challenge-completed$/.test(
path
)
) {
next();
} else {

View File

@@ -26,6 +26,7 @@ const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
const updateHooksRE = /^\/hooks\/update-paypal$/;
// note: this would be replaced by webhooks later
const donateRE = /^\/donate\/charge-stripe$/;
const submitCoderoadChallengeRE = /^\/coderoad-challenge-completed$/;
const _pathsAllowedREs = [
authRE,
@@ -40,7 +41,8 @@ const _pathsAllowedREs = [
unsubscribedRE,
unsubscribeRE,
updateHooksRE,
donateRE
donateRE,
submitCoderoadChallengeRE
];
export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) {

View File

@@ -58,5 +58,9 @@
"User": {
"dataSource": "db",
"public": false
},
"WebhookToken": {
"dataSource": "db",
"public": false
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "WebhookToken",
"description": "Tokens for submitting curricula through CodeRoad",
"base": "AccessToken",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -13,5 +13,6 @@
"sciCompPyV7": "isSciCompPyCertV7",
"dataAnalysisPyV7": "isDataAnalysisPyCertV7",
"machineLearningPyV7": "isMachineLearningPyCertV7",
"fullStack": "isFullStackCert"
"fullStack": "isFullStackCert",
"relationalDatabasesV8": "isRelationalDatabasesV8"
}