import debugFactory from 'debug'; import dedent from 'dedent'; import { body } from 'express-validator'; import { pick } from 'lodash'; import fetch from 'node-fetch'; import { fixCompletedChallengeItem, fixCompletedExamItem, fixCompletedSurveyItem, fixPartiallyCompletedChallengeItem, fixSavedChallengeItem } from '../../common/utils'; import { removeCookies } from '../utils/getSetAccessToken'; import { ifNoUser401, ifNoUserRedirectHome } from '../utils/middleware'; import { getProgress, normaliseUserFields, userPropsForSession } from '../utils/publicUserProps'; import { getRedirectParams } from '../utils/redirection'; import { trimTags } from '../utils/validators'; import { createDeleteUserToken, encodeUserToken } from '../middlewares/user-token'; import { createDeleteMsUsername } from '../middlewares/ms-username'; import { validateSurvey, createDeleteUserSurveys } from '../middlewares/survey'; import { deprecatedEndpoint } from '../utils/disabled-endpoints'; const log = debugFactory('fcc:boot:user'); const sendNonUserToHome = ifNoUserRedirectHome(); function bootUser(app) { const api = app.loopback.Router(); const getSessionUser = createReadSessionUser(app); const postReportUserProfile = createPostReportUserProfile(app); const postDeleteAccount = createPostDeleteAccount(app); const postUserToken = createPostUserToken(app); const deleteUserToken = createDeleteUserToken(app); const postMsUsername = createPostMsUsername(app); const deleteMsUsername = createDeleteMsUsername(app); const postSubmitSurvey = createPostSubmitSurvey(app); const deleteUserSurveys = createDeleteUserSurveys(app); api.get('/account', sendNonUserToHome, deprecatedEndpoint); api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial); api.get('/user/get-session-user', getSessionUser); api.post( '/account/delete', ifNoUser401, deleteUserToken, deleteMsUsername, deleteUserSurveys, postDeleteAccount ); api.post( '/account/reset-progress', ifNoUser401, deleteUserToken, deleteMsUsername, deleteUserSurveys, postResetProgress ); api.post( '/user/report-user/', ifNoUser401, body('reportDescription').customSanitizer(trimTags), postReportUserProfile ); api.post('/user/user-token', ifNoUser401, postUserToken); api.delete( '/user/user-token', ifNoUser401, deleteUserToken, deleteUserTokenResponse ); api.post('/user/ms-username', ifNoUser401, postMsUsername); api.delete( '/user/ms-username', ifNoUser401, deleteMsUsername, deleteMsUsernameResponse ); api.post( '/user/submit-survey', ifNoUser401, validateSurvey, postSubmitSurvey ); app.use(api); } function createPostUserToken(app) { const { UserToken } = app.models; return async function postUserToken(req, res) { const ttl = 900 * 24 * 60 * 60 * 1000; let encodedUserToken; try { await UserToken.destroyAll({ userId: req.user.id }); const newUserToken = await UserToken.create({ ttl, userId: req.user.id }); if (!newUserToken?.id) throw new Error(); encodedUserToken = encodeUserToken(newUserToken.id); } catch (e) { return res.status(500).send('Error starting project'); } return res.json({ userToken: encodedUserToken }); }; } function deleteUserTokenResponse(req, res) { if (!req.userTokenDeleted) { return res.status(500).send('Error deleting user token'); } return res.send({ userToken: null }); } export const getMsTranscriptApiUrl = msTranscript => { // example msTranscriptUrl: https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo const url = new URL(msTranscript); const transcriptUrlRegex = /\/transcript\/([^/]+)\/?/; const id = transcriptUrlRegex.exec(url.pathname)?.[1]; return `https://learn.microsoft.com/api/profiles/transcript/share/${ id ?? '' }`; }; function createPostMsUsername(app) { const { MsUsername } = app.models; return async function postMsUsername(req, res) { // example msTranscriptUrl: https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo // the last part is the transcript ID // the username is irrelevant, and retrieved from the MS API response const { msTranscriptUrl } = req.body; if (!msTranscriptUrl) { return res.status(400).json({ type: 'error', message: 'flash.ms.transcript.link-err-1' }); } const msTranscriptApiUrl = getMsTranscriptApiUrl(msTranscriptUrl); try { const msApiRes = await fetch(msTranscriptApiUrl); if (!msApiRes.ok) { return res .status(404) .json({ type: 'error', message: 'flash.ms.transcript.link-err-2' }); } const { userName } = await msApiRes.json(); if (!userName) { return res .status(500) .json({ type: 'error', message: 'flash.ms.transcript.link-err-3' }); } // Don't create if username is used by another fCC account const usernameUsed = await MsUsername.findOne({ where: { msUsername: userName } }); if (usernameUsed) { return res .status(403) .json({ type: 'error', message: 'flash.ms.transcript.link-err-4' }); } await MsUsername.destroyAll({ userId: req.user.id }); const ttl = 900 * 24 * 60 * 60 * 1000; const newMsUsername = await MsUsername.create({ ttl, userId: req.user.id, msUsername: userName }); if (!newMsUsername?.id) { return res .status(500) .json({ type: 'error', message: 'flash.ms.transcript.link-err-5' }); } return res.json({ msUsername: userName }); } catch (e) { log(e); return res .status(500) .json({ type: 'error', message: 'flash.ms.transcript.link-err-6' }); } }; } function deleteMsUsernameResponse(req, res) { if (!req.msUsernameDeleted) { return res.status(500).json({ type: 'error', message: 'flash.ms.transcript.unlink-err' }); } return res.send({ msUsername: null }); } function createPostSubmitSurvey(app) { const { Survey } = app.models; return async function postSubmitSurvey(req, res) { const { user, body } = req; const { surveyResults } = body; const { id: userId } = user; const { title } = surveyResults; const completedSurveys = await Survey.find({ where: { userId } }); const surveyAlreadyTaken = completedSurveys.some(s => s.title === title); if (surveyAlreadyTaken) { return res.status(400).json({ type: 'error', message: 'flash.survey.err-2' }); } try { const newSurvey = { ...surveyResults, userId: user.id }; const createdSurvey = await Survey.create(newSurvey); if (!createdSurvey) { throw new Error('Error creating survey'); } return res.json({ type: 'success', message: 'flash.survey.success' }); } catch (e) { log(e); return res.status(500).json({ type: 'error', message: 'flash.survey.err-3' }); } }; } function createReadSessionUser(app) { const { MsUsername, Survey, UserToken } = app.models; return async function getSessionUser(req, res, next) { const queryUser = req.user; let encodedUserToken; try { const userId = queryUser?.id; const userToken = userId ? await UserToken.findOne({ where: { userId } }) : null; encodedUserToken = userToken ? encodeUserToken(userToken.id) : undefined; } catch (e) { return next(e); } let msUsername; try { const userId = queryUser?.id; const msUser = userId ? await MsUsername.findOne({ where: { userId } }) : null; msUsername = msUser ? msUser.msUsername : undefined; } catch (e) { return next(e); } let completedSurveys; try { const userId = queryUser?.id; completedSurveys = userId ? await Survey.find({ where: { userId } }) : []; } catch (e) { return next(e); } if (!queryUser || !queryUser.toJSON().username) { // TODO: This should return an error status return res.json({ user: {}, result: '' }); } try { const [ completedChallenges, completedExams, partiallyCompletedChallenges, progressTimestamps, savedChallenges ] = await Promise.all( [ queryUser.getCompletedChallenges$(), queryUser.getCompletedExams$(), queryUser.getPartiallyCompletedChallenges$(), queryUser.getPoints$(), queryUser.getSavedChallenges$() ].map(obs => obs.toPromise()) ); const { calendar } = getProgress(progressTimestamps); const user = { ...queryUser.toJSON(), calendar, completedChallenges: completedChallenges.map(fixCompletedChallengeItem), completedExams: completedExams.map(fixCompletedExamItem), partiallyCompletedChallenges: partiallyCompletedChallenges.map( fixPartiallyCompletedChallengeItem ), savedChallenges: savedChallenges.map(fixSavedChallengeItem) }; const response = { user: { [user.username]: { ...pick(user, userPropsForSession), username: user.usernameDisplay || user.username, isEmailVerified: !!user.emailVerified, ...normaliseUserFields(user), joinDate: user.id.getTimestamp(), userToken: encodedUserToken, msUsername, completedSurveys: completedSurveys.map(fixCompletedSurveyItem) } }, result: user.username }; return res.json(response); } catch (e) { // TODO: This should return an error status return res.json({ user: {}, result: '' }); } }; } 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'); return res.redirect('/' + username); } social = social.toLowerCase(); const validSocialAccounts = ['twitter', 'linkedin']; if (validSocialAccounts.indexOf(social) === -1) { req.flash('danger', 'Invalid social account'); return res.redirect('/' + username); } if (!user[social]) { req.flash('danger', `No ${social} account associated`); return res.redirect('/' + username); } const query = { where: { provider: social } }; return user.identities(query, function (err, identities) { if (err) { return next(err); } // assumed user identity is unique by provider let identity = identities.shift(); if (!identity) { req.flash('danger', 'No social account found'); return res.redirect('/' + username); } return identity.destroy(function (err) { if (err) { return next(err); } const updateData = { [social]: null }; return user.updateAttributes(updateData, err => { if (err) { return next(err); } log(`${social} has been unlinked successfully`); req.flash('info', `You've successfully unlinked your ${social}.`); return res.redirectWithFlash(`${origin}/${username}`); }); }); }); } function postResetProgress(req, res, next) { const { user } = req; return user.updateAttributes( { progressTimestamps: [Date.now()], currentChallengeId: '', isRespWebDesignCert: false, is2018DataVisCert: false, isFrontEndLibsCert: false, isJsAlgoDataStructCert: false, isApisMicroservicesCert: false, isInfosecQaCert: false, isQaCertV7: false, isInfosecCertV7: false, is2018FullStackCert: false, isFrontEndCert: false, isBackEndCert: false, isDataVisCert: false, isFullStackCert: false, isSciCompPyCertV7: false, isDataAnalysisPyCertV7: false, isMachineLearningPyCertV7: false, isRelationalDatabaseCertV8: false, isCollegeAlgebraPyCertV8: false, isFoundationalCSharpCertV8: false, isJsAlgoDataStructCertV8: false, completedChallenges: [], completedExams: [], savedChallenges: [], partiallyCompletedChallenges: [], needsModeration: false }, function (err) { if (err) { return next(err); } return res.status(200).json({}); } ); } function createPostDeleteAccount(app) { const { User } = app.models; return async function postDeleteAccount(req, res, next) { return User.destroyById(req.user.id, function (err) { if (err) { return next(err); } req.logout(); removeCookies(req, res); return res.status(200).json({}); }); }; } function createPostReportUserProfile(app) { const { Email } = app.models; return function postReportUserProfile(req, res, next) { const { user } = req; const { username, reportDescription: report } = req.body; const { origin } = getRedirectParams(req); log(username); log(report); if (!username || !report || report === '') { return res.json({ type: 'danger', message: 'flash.provide-username' }); } return Email.send$( { type: 'email', to: 'support@freecodecamp.org', cc: user.email, from: 'team@freecodecamp.org', subject: `Abuse Report : Reporting ${username}'s profile.`, text: dedent(` Hello Team,\n This is to report the profile of ${username}.\n Report Details:\n ${report}\n\n Reported by: Username: ${user.username} Name: ${user.name} Email: ${user.email}\n Thanks and regards, ${user.name} `) }, err => { if (err) { err.redirectTo = `${origin}/${username}`; return next(err); } return res.json({ type: 'info', message: 'flash.report-sent', variables: { email: user.email } }); } ); }; } export default bootUser;