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:
@@ -325,6 +325,11 @@
|
||||
"type": "hasMany",
|
||||
"model": "article",
|
||||
"foreignKey": "externalId"
|
||||
},
|
||||
"webhookTokens": {
|
||||
"type": "hasMany",
|
||||
"model": "WebhookToken",
|
||||
"foreignKey": "userId"
|
||||
}
|
||||
},
|
||||
"acls": [
|
||||
|
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -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);
|
||||
|
@@ -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 {
|
||||
|
@@ -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) {
|
||||
|
@@ -58,5 +58,9 @@
|
||||
"User": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"WebhookToken": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
}
|
||||
}
|
||||
|
20
api-server/src/server/models/webhook-token.json
Normal file
20
api-server/src/server/models/webhook-token.json
Normal 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": {}
|
||||
}
|
@@ -13,5 +13,6 @@
|
||||
"sciCompPyV7": "isSciCompPyCertV7",
|
||||
"dataAnalysisPyV7": "isDataAnalysisPyCertV7",
|
||||
"machineLearningPyV7": "isMachineLearningPyCertV7",
|
||||
"fullStack": "isFullStackCert"
|
||||
"fullStack": "isFullStackCert",
|
||||
"relationalDatabasesV8": "isRelationalDatabasesV8"
|
||||
}
|
||||
|
Reference in New Issue
Block a user