From 274680dbdb42841600f2db00c374004f8d0cf932 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Wed, 2 Oct 2024 13:52:02 +0200 Subject: [PATCH] refactor: separate public and private plugins (#56359) --- .prettierignore | 2 +- api/jest.utils.ts | 8 +- api/src/app.ts | 54 +- .../routes/helpers/certificate-utils.test.ts | 48 ++ api/src/routes/helpers/certificate-utils.ts | 52 ++ api/src/routes/helpers/user-utils.ts | 49 ++ .../{ => protected}/certificate.test.ts | 267 +------ api/src/routes/{ => protected}/certificate.ts | 662 ++++++------------ .../routes/{ => protected}/challenge.test.ts | 16 +- api/src/routes/{ => protected}/challenge.ts | 20 +- api/src/routes/{ => protected}/donate.test.ts | 50 +- api/src/routes/{ => protected}/donate.ts | 168 +---- api/src/routes/protected/index.ts | 5 + .../routes/{ => protected}/settings.test.ts | 8 +- api/src/routes/{ => protected}/settings.ts | 12 +- api/src/routes/{ => protected}/user.test.ts | 374 +--------- api/src/routes/{ => protected}/user.ts | 291 +------- api/src/routes/{ => public}/auth-dev.test.ts | 10 +- api/src/routes/{ => public}/auth-dev.ts | 6 +- api/src/routes/{ => public}/auth.test.ts | 8 +- api/src/routes/{ => public}/auth.ts | 8 +- api/src/routes/public/certificate.test.ts | 274 ++++++++ api/src/routes/public/certificate.ts | 238 +++++++ .../{ => public}/deprecated-endpoints.test.ts | 2 +- .../{ => public}/deprecated-endpoints.ts | 2 +- .../deprecated-unsubscribe.test.ts | 2 +- .../{ => public}/deprecated-unsubscribe.ts | 2 +- api/src/routes/public/donate.test.ts | 139 ++++ api/src/routes/public/donate.ts | 167 +++++ .../{ => public}/email-subscription.test.ts | 6 +- .../routes/{ => public}/email-subscription.ts | 4 +- api/src/routes/public/index.ts | 10 + api/src/routes/{ => public}/signout.test.ts | 4 +- api/src/routes/{ => public}/signout.ts | 2 +- api/src/routes/{ => public}/status.test.ts | 2 +- api/src/routes/{ => public}/status.ts | 0 api/src/routes/public/user.test.ts | 614 ++++++++++++++++ api/src/routes/public/user.ts | 240 +++++++ 38 files changed, 2153 insertions(+), 1673 deletions(-) create mode 100644 api/src/routes/helpers/certificate-utils.test.ts create mode 100644 api/src/routes/helpers/certificate-utils.ts create mode 100644 api/src/routes/helpers/user-utils.ts rename api/src/routes/{ => protected}/certificate.test.ts (64%) rename api/src/routes/{ => protected}/certificate.ts (65%) rename api/src/routes/{ => protected}/challenge.test.ts (99%) rename api/src/routes/{ => protected}/challenge.ts (97%) rename api/src/routes/{ => protected}/donate.test.ts (90%) rename api/src/routes/{ => protected}/donate.ts (54%) create mode 100644 api/src/routes/protected/index.ts rename api/src/routes/{ => protected}/settings.test.ts (99%) rename api/src/routes/{ => protected}/settings.ts (98%) rename api/src/routes/{ => protected}/user.test.ts (75%) rename api/src/routes/{ => protected}/user.ts (62%) rename api/src/routes/{ => public}/auth-dev.test.ts (97%) rename api/src/routes/{ => public}/auth-dev.ts (89%) rename api/src/routes/{ => public}/auth.test.ts (96%) rename api/src/routes/{ => public}/auth.ts (95%) create mode 100644 api/src/routes/public/certificate.test.ts create mode 100644 api/src/routes/public/certificate.ts rename api/src/routes/{ => public}/deprecated-endpoints.test.ts (92%) rename api/src/routes/{ => public}/deprecated-endpoints.ts (96%) rename api/src/routes/{ => public}/deprecated-unsubscribe.test.ts (93%) rename api/src/routes/{ => public}/deprecated-unsubscribe.ts (95%) create mode 100644 api/src/routes/public/donate.test.ts create mode 100644 api/src/routes/public/donate.ts rename api/src/routes/{ => public}/email-subscription.test.ts (98%) rename api/src/routes/{ => public}/email-subscription.ts (97%) create mode 100644 api/src/routes/public/index.ts rename api/src/routes/{ => public}/signout.test.ts (91%) rename api/src/routes/{ => public}/signout.ts (91%) rename api/src/routes/{ => public}/status.test.ts (82%) rename api/src/routes/{ => public}/status.ts (100%) create mode 100644 api/src/routes/public/user.test.ts create mode 100644 api/src/routes/public/user.ts diff --git a/.prettierignore b/.prettierignore index ea3e07b8f76..fd1cce2f19d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,11 +1,11 @@ **/.cache **/*fixtures* -**/public api-server/lib client/**/trending.json client/config/*.json client/config/browser-scripts/*.json client/static +client/public curriculum/challenges/_meta/*/* curriculum/challenges/**/* curriculum/i18n-curriculum diff --git a/api/jest.utils.ts b/api/jest.utils.ts index 4608d716af7..6ddb7b706ac 100644 --- a/api/jest.utils.ts +++ b/api/jest.utils.ts @@ -205,9 +205,9 @@ export const defaultUserId = '64c7810107dd4782d32baee7'; export const defaultUserEmail = 'foo@bar.com'; export const defaultUsername = 'fcc-test-user'; -export async function devLogin(): Promise { +export const resetDefaultUser = async (): Promise => { await fastifyTestInstance.prisma.user.deleteMany({ - where: { email: 'foo@bar.com' } + where: { email: defaultUserEmail } }); await fastifyTestInstance.prisma.user.create({ @@ -217,6 +217,10 @@ export async function devLogin(): Promise { username: defaultUsername } }); +}; + +export async function devLogin(): Promise { + await resetDefaultUser(); const res = await superRequest('/signin', { method: 'GET' }); expect(res.status).toBe(302); return res.get('Set-Cookie'); diff --git a/api/src/app.ts b/api/src/app.ts index 8935cc7e57d..665b9effa5b 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -26,21 +26,9 @@ import security from './plugins/security'; import auth from './plugins/auth'; import bouncer from './plugins/bouncer'; import notFound from './plugins/not-found'; -import { authRoutes, mobileAuth0Routes } from './routes/auth'; -import { devAuthRoutes } from './routes/auth-dev'; -import { - protectedCertificateRoutes, - unprotectedCertificateRoutes -} from './routes/certificate'; -import { challengeRoutes } from './routes/challenge'; -import { deprecatedEndpoints } from './routes/deprecated-endpoints'; -import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe'; -import { donateRoutes, chargeStripeRoute } from './routes/donate'; -import { emailSubscribtionRoutes } from './routes/email-subscription'; -import { settingRoutes, settingRedirectRoutes } from './routes/settings'; -import { statusRoute } from './routes/status'; -import { userGetRoutes, userRoutes, userPublicGetRoutes } from './routes/user'; -import { signoutRoute } from './routes/signout'; +import * as publicRoutes from './routes/public'; +import * as protectedRoutes from './routes/protected'; + import { API_LOCATION, EMAIL_PROVIDER, @@ -197,25 +185,25 @@ export const build = async ( fastify.addHook('onRequest', fastify.csrfProtection); fastify.addHook('onRequest', fastify.send401IfNoUser); - await fastify.register(challengeRoutes); - await fastify.register(donateRoutes); - await fastify.register(protectedCertificateRoutes); - await fastify.register(settingRoutes); - await fastify.register(userRoutes); + await fastify.register(protectedRoutes.challengeRoutes); + await fastify.register(protectedRoutes.donateRoutes); + await fastify.register(protectedRoutes.protectedCertificateRoutes); + await fastify.register(protectedRoutes.settingRoutes); + await fastify.register(protectedRoutes.userRoutes); }); // CSRF protection disabled: await fastify.register(async function (fastify, _opts) { fastify.addHook('onRequest', fastify.send401IfNoUser); - await fastify.register(userGetRoutes); + await fastify.register(protectedRoutes.userGetRoutes); }); // Routes that redirect if access is denied: await fastify.register(async function (fastify, _opts) { fastify.addHook('onRequest', fastify.redirectIfNoUser); - await fastify.register(settingRedirectRoutes); + await fastify.register(protectedRoutes.settingRedirectRoutes); }); }); // Routes for signed out users: @@ -223,22 +211,22 @@ export const build = async ( fastify.addHook('onRequest', fastify.authorize); // TODO(Post-MVP): add the redirectIfSignedIn hook here, rather than in the // mobileAuth0Routes and authRoutes plugins. - await fastify.register(mobileAuth0Routes); + await fastify.register(publicRoutes.mobileAuth0Routes); // TODO: consolidate with LOCAL_MOCK_AUTH if (FCC_ENABLE_DEV_LOGIN_MODE) { - await fastify.register(devAuthRoutes); + await fastify.register(publicRoutes.devAuthRoutes); } else { - await fastify.register(authRoutes); + await fastify.register(publicRoutes.authRoutes); } }); - void fastify.register(chargeStripeRoute); - void fastify.register(signoutRoute); - void fastify.register(emailSubscribtionRoutes); - void fastify.register(userPublicGetRoutes); - void fastify.register(unprotectedCertificateRoutes); - void fastify.register(deprecatedEndpoints); - void fastify.register(statusRoute); - void fastify.register(unsubscribeDeprecated); + void fastify.register(publicRoutes.chargeStripeRoute); + void fastify.register(publicRoutes.signoutRoute); + void fastify.register(publicRoutes.emailSubscribtionRoutes); + void fastify.register(publicRoutes.userPublicGetRoutes); + void fastify.register(publicRoutes.unprotectedCertificateRoutes); + void fastify.register(publicRoutes.deprecatedEndpoints); + void fastify.register(publicRoutes.statusRoute); + void fastify.register(publicRoutes.unsubscribeDeprecated); return fastify; }; diff --git a/api/src/routes/helpers/certificate-utils.test.ts b/api/src/routes/helpers/certificate-utils.test.ts new file mode 100644 index 00000000000..d4a4e531ccb --- /dev/null +++ b/api/src/routes/helpers/certificate-utils.test.ts @@ -0,0 +1,48 @@ +import { getFallbackFullStackDate } from './certificate-utils'; + +const fullStackChallenges = [ + { + completedDate: 1585210952511, + id: '5a553ca864b52e1d8bceea14' + }, + { + completedDate: 1585210952511, + id: '561add10cb82ac38a17513bc' + }, + { + completedDate: 1588665778679, + id: '561acd10cb82ac38a17513bc' + }, + { + completedDate: 1685210952511, + id: '561abd10cb81ac38a17513bc' + }, + { + completedDate: 1585210952511, + id: '561add10cb82ac38a17523bc' + }, + { + completedDate: 1588665778679, + id: '561add10cb82ac38a17213bc' + } +]; + +describe('helper functions', () => { + describe('getFallbackFullStackDate', () => { + it('should return the date of the latest completed challenge', () => { + expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe( + 1685210952511 + ); + }); + + it('should fall back to completedDate if no certifications are provided', () => { + expect(getFallbackFullStackDate([], 123)).toBe(123); + }); + + it('should fall back to completedDate if none of the certifications have been completed', () => { + expect( + getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123) + ).toBe(123); + }); + }); +}); diff --git a/api/src/routes/helpers/certificate-utils.ts b/api/src/routes/helpers/certificate-utils.ts new file mode 100644 index 00000000000..bb4a3c8c378 --- /dev/null +++ b/api/src/routes/helpers/certificate-utils.ts @@ -0,0 +1,52 @@ +import { + certSlugTypeMap, + certIds +} from '../../../../shared/config/certification-settings'; + +const { + legacyInfosecQaId, + respWebDesignId, + frontEndDevLibsId, + jsAlgoDataStructId, + dataVis2018Id, + apisMicroservicesId +} = certIds; + +const fullStackCertificateIds = [ + respWebDesignId, + jsAlgoDataStructId, + frontEndDevLibsId, + dataVis2018Id, + apisMicroservicesId, + legacyInfosecQaId +]; + +/** + * Checks if the given certification slug is known. + * + * @param certSlug - The certification slug to check. + * @returns True if the certification slug is known, otherwise false. + */ +export function isKnownCertSlug( + certSlug: string +): certSlug is keyof typeof certSlugTypeMap { + return certSlug in certSlugTypeMap; +} + +/** + * Retrieves the completion date for the full stack certification, if it exists. + * + * @param completedChallenges - The array of completed challenges. + * @param completedDate - The fallback completed date. + * @returns The latest certification date or the completed date if no certification is found. + */ +export function getFallbackFullStackDate( + completedChallenges: { id: string; completedDate: number }[], + completedDate: number +) { + const latestCertDate = completedChallenges + .filter(chal => fullStackCertificateIds.includes(chal.id)) + .sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate; + + return latestCertDate ?? completedDate; +} diff --git a/api/src/routes/helpers/user-utils.ts b/api/src/routes/helpers/user-utils.ts new file mode 100644 index 00000000000..29cb6777bee --- /dev/null +++ b/api/src/routes/helpers/user-utils.ts @@ -0,0 +1,49 @@ +import _ from 'lodash'; + +// user flags that the api-server returns as false if they're missing in the +// user document. Since Prisma returns null for missing fields, we need to +// normalize them to false. +// TODO(Post-MVP): remove this when the database is normalized. +const nullableFlags = [ + 'is2018DataVisCert', + 'is2018FullStackCert', + 'isApisMicroservicesCert', + 'isBackEndCert', + 'isCheater', + 'isCollegeAlgebraPyCertV8', + 'isDataAnalysisPyCertV7', + 'isDataVisCert', + // isDonating doesn't need fixing because it's not nullable + 'isFoundationalCSharpCertV8', + 'isFrontEndCert', + 'isFullStackCert', + 'isFrontEndLibsCert', + 'isHonest', + 'isInfosecCertV7', + 'isInfosecQaCert', + 'isJsAlgoDataStructCert', + 'isJsAlgoDataStructCertV8', + 'isMachineLearningPyCertV7', + 'isQaCertV7', + 'isRelationalDatabaseCertV8', + 'isRespWebDesignCert', + 'isSciCompPyCertV7', + 'isDataAnalysisPyCertV7', + // isUpcomingPythonCertV8 exists in the db, but is not returned by the api-server + // TODO(Post-MVP): delete it from the db? + 'keyboardShortcuts' +] as const; + +type NullableFlags = (typeof nullableFlags)[number]; + +/** + * Splits a user object into two objects: one with nullable flags and one without. + * + * @param user - The user object to split. + * @returns A tuple where the first element is an object with nullable flags and the second element is an object with the remaining properties. + */ +export function splitUser>( + user: U +): [Pick, Omit] { + return [_.pick(user, nullableFlags), _.omit(user, nullableFlags)]; +} diff --git a/api/src/routes/certificate.test.ts b/api/src/routes/protected/certificate.test.ts similarity index 64% rename from api/src/routes/certificate.test.ts rename to api/src/routes/protected/certificate.test.ts index 5e9a5f98c50..9fe1238adce 100644 --- a/api/src/routes/certificate.test.ts +++ b/api/src/routes/protected/certificate.test.ts @@ -1,14 +1,13 @@ import { type PrismaPromise } from '@prisma/client'; -import { Certification } from '../../../shared/config/certification-settings'; +import { Certification } from '../../../../shared/config/certification-settings'; import { defaultUserEmail, defaultUserId, devLogin, setupServer, superRequest -} from '../../jest.utils'; -import { SHOW_UPCOMING_CHANGES } from '../utils/env'; -import { getFallbackFullStackDate } from './certificate'; +} from '../../../jest.utils'; +import { SHOW_UPCOMING_CHANGES } from '../../utils/env'; describe('certificate routes', () => { setupServer(); @@ -435,264 +434,4 @@ describe('certificate routes', () => { }); }); }); - - describe('Unauthenticated user', () => { - describe('GET /certificate/showCert/:username/:certSlug', () => { - beforeEach(async () => { - await fastifyTestInstance.prisma.user.updateMany({ - where: { email: defaultUserEmail }, - data: { - username: 'foobar', - name: 'foobar', - isHonest: true, - isBanned: false, - isCheater: false, - profileUI: { isLocked: false, showCerts: true, showTimeLine: true } - } - }); - }); - test('should return user not found if the user cannot be found', async () => { - const response = await superRequest( - '/certificate/showCert/not-a-valid-user-name/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.username-not-found', - variables: { username: 'not-a-valid-user-name' } - } - ] - }); - expect(response.status).toBe(200); - }); - test('should ask user to add name if there is no name', async () => { - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { name: null } - }); - const response = await superRequest( - '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.add-name' - } - ] - }); - expect(response.status).toBe(200); - }); - test('should return not eligible if user is banned', async () => { - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { isBanned: true } - }); - const response = await superRequest( - '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.not-eligible' - } - ] - }); - expect(response.status).toBe(200); - }); - test('should return not eligible if user is cheater', async () => { - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { isCheater: true } - }); - const response = await superRequest( - '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.not-eligible' - } - ] - }); - expect(response.status).toBe(200); - }); - test('should return not honest if user is not honest', async () => { - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { isHonest: false } - }); - const response = await superRequest( - '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.not-honest', - variables: { username: 'foobar' } - } - ] - }); - expect(response.status).toBe(200); - }); - test('should return profile private if profile is private', async () => { - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { - // All properties need to be defined, as this op SETs `profileUI` - profileUI: { isLocked: true, showTimeLine: true, showCerts: true } - } - }); - const response = await superRequest( - '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.profile-private', - variables: { username: 'foobar' } - } - ] - }); - expect(response.status).toBe(200); - }); - test('should return certs private if certs are private', async () => { - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { - profileUI: { showCerts: false, showTimeLine: true, isLocked: false } - } - }); - const response = await superRequest( - '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.certs-private', - variables: { username: 'foobar' } - } - ] - }); - expect(response.status).toBe(200); - }); - test('should return timeline private if timeline is private', async () => { - await fastifyTestInstance.prisma.user.update({ - where: { id: defaultUserId }, - data: { - profileUI: { showTimeLine: false, showCerts: true, isLocked: false } - } - }); - const response = await superRequest( - '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.timeline-private', - variables: { username: 'foobar' } - } - ] - }); - expect(response.status).toBe(200); - }); - - test('should return cert-not-found if there is no cert with that slug', async () => { - const response = await superRequest( - '/certificate/showCert/foobar/not-a-valid-cert-slug', - { - method: 'GET' - } - ); - expect(response.body).toEqual({ - messages: [ - { - type: 'info', - message: 'flash.cert-not-found', - variables: { certSlug: 'not-a-valid-cert-slug' } - } - ] - }); - expect(response.status).toBe(404); - }); - }); - }); -}); - -const fullStackChallenges = [ - { - completedDate: 1585210952511, - id: '5a553ca864b52e1d8bceea14' - }, - { - completedDate: 1585210952511, - id: '561add10cb82ac38a17513bc' - }, - { - completedDate: 1588665778679, - id: '561acd10cb82ac38a17513bc' - }, - { - completedDate: 1685210952511, - id: '561abd10cb81ac38a17513bc' - }, - { - completedDate: 1585210952511, - id: '561add10cb82ac38a17523bc' - }, - { - completedDate: 1588665778679, - id: '561add10cb82ac38a17213bc' - } -]; - -describe('helper functions', () => { - describe('getFallbackFullStackDate', () => { - it('should return the date of the latest completed challenge', () => { - expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe( - 1685210952511 - ); - }); - - it('should fall back to completedDate if no certifications are provided', () => { - expect(getFallbackFullStackDate([], 123)).toBe(123); - }); - - it('should fall back to completedDate if none of the certifications have been completed', () => { - expect( - getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123) - ).toBe(123); - }); - }); }); diff --git a/api/src/routes/certificate.ts b/api/src/routes/protected/certificate.ts similarity index 65% rename from api/src/routes/certificate.ts rename to api/src/routes/protected/certificate.ts index d6a72827cbe..144dd2464d5 100644 --- a/api/src/routes/certificate.ts +++ b/api/src/routes/protected/certificate.ts @@ -1,9 +1,8 @@ -import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +import type { CompletedChallenge } from '@prisma/client'; import isEmail from 'validator/lib/isEmail'; -import { find } from 'lodash'; -import { CompletedChallenge } from '@prisma/client'; -import * as schemas from '../schemas'; -import { getChallenges } from '../utils/get-challenges'; +import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; + +import { getChallenges } from '../../utils/get-challenges'; import { certIds, certSlugTypeMap, @@ -12,13 +11,14 @@ import { currentCertifications, legacyCertifications, legacyFullStackCertification, - certTypeIdMap, - completionHours, - oldDataVizId, upcomingCertifications -} from '../../../shared/config/certification-settings'; -import { normalizeChallenges, removeNulls } from '../utils/normalize'; -import { SHOW_UPCOMING_CHANGES } from '../utils/env'; +} from '../../../../shared/config/certification-settings'; + +import * as schemas from '../../schemas'; +import { normalizeChallenges, removeNulls } from '../../utils/normalize'; + +import { SHOW_UPCOMING_CHANGES } from '../../utils/env'; +import { isKnownCertSlug } from '../helpers/certificate-utils'; const { legacyFrontEndChallengeId, @@ -43,6 +43,198 @@ const { upcomingPythonV8Id } = certIds; +function isCertAllowed(certSlug: string): boolean { + if ( + currentCertifications.includes(certSlug) || + legacyCertifications.includes(certSlug) || + legacyFullStackCertification.includes(certSlug) + ) { + return true; + } + if (SHOW_UPCOMING_CHANGES && upcomingCertifications.includes(certSlug)) { + return true; + } + return false; +} + +function renderCertifiedEmail({ + username, + name +}: { + username: string; + name: string; +}) { + const certifiedEmailTemplate = `Hi ${name || username}, + +Congratulations on completing all of the freeCodeCamp certifications! + +All of your certifications are now live at at: https://www.freecodecamp.org/${username} + +Please tell me a bit more about you and your near-term goals. + +Are you interested in contributing to our open source projects used by nonprofits? + +Also, check out https://contribute.freecodecamp.org/ for some fun and convenient ways you can contribute to the community. + +Happy coding, + +- Quincy Larson, teacher at freeCodeCamp +`; + return certifiedEmailTemplate; +} + +function hasCompletedTests( + tests: { id: string }[], + completedChallenges: CompletedChallenge[] +) { + return tests.every(({ id }) => + completedChallenges.some(({ id: completedId }) => completedId === id) + ); +} + +function assertTestsExist( + tests: ReturnType[number]['tests'] +): asserts tests is { id: string }[] { + if (!Array.isArray(tests)) { + throw new Error('Tests is not an array'); + } + if (!tests.every(test => typeof test === 'object' && test !== null)) { + throw new Error('Tests contains non-object values'); + } + if (!tests.every(test => typeof test.id === 'string')) { + throw new Error('Tests contain non-string ids'); + } +} + +function getCertById( + challengeId: string, + challenges: ReturnType +): { id: string; tests: { id: string }[]; challengeType: number } { + const challengeById = challenges.filter(({ id }) => id === challengeId)[0]; + if (!challengeById) { + throw new Error(`Challenge with id '${challengeId}' not found`); + } + const { id, tests, challengeType } = challengeById; + assertTestsExist(tests); + return { id, tests, challengeType }; +} + +function createCertTypeIds(challenges: ReturnType) { + return { + // legacy + [certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, challenges), + [certTypes.jsAlgoDataStruct]: getCertById(jsAlgoDataStructId, challenges), + [certTypes.backEnd]: getCertById(legacyBackEndChallengeId, challenges), + [certTypes.dataVis]: getCertById(legacyDataVisId, challenges), + [certTypes.infosecQa]: getCertById(legacyInfosecQaId, challenges), + [certTypes.fullStack]: getCertById(legacyFullStackId, challenges), + + // modern + [certTypes.respWebDesign]: getCertById(respWebDesignId, challenges), + [certTypes.frontEndDevLibs]: getCertById(frontEndDevLibsId, challenges), + [certTypes.dataVis2018]: getCertById(dataVis2018Id, challenges), + [certTypes.jsAlgoDataStructV8]: getCertById( + jsAlgoDataStructV8Id, + challenges + ), + [certTypes.apisMicroservices]: getCertById(apisMicroservicesId, challenges), + [certTypes.qaV7]: getCertById(qaV7Id, challenges), + [certTypes.infosecV7]: getCertById(infosecV7Id, challenges), + [certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, challenges), + [certTypes.dataAnalysisPyV7]: getCertById(dataAnalysisPyV7Id, challenges), + [certTypes.machineLearningPyV7]: getCertById( + machineLearningPyV7Id, + challenges + ), + [certTypes.relationalDatabaseV8]: getCertById( + relationalDatabaseV8Id, + challenges + ), + [certTypes.collegeAlgebraPyV8]: getCertById( + collegeAlgebraPyV8Id, + challenges + ), + [certTypes.foundationalCSharpV8]: getCertById( + foundationalCSharpV8Id, + challenges + ), + + // upcoming + [certTypes.upcomingPythonV8]: getCertById(upcomingPythonV8Id, challenges) + }; +} + +interface CertI { + isRespWebDesignCert?: boolean; + isJsAlgoDataStructCert?: boolean; + isJsAlgoDataStructCertV8?: boolean; + isFrontEndLibsCert?: boolean; + is2018DataVisCert?: boolean; + isApisMicroservicesCert?: boolean; + isInfosecQaCert?: boolean; + isQaCertV7?: boolean; + isInfosecCertV7?: boolean; + isFrontEndCert?: boolean; + isBackEndCert?: boolean; + isDataVisCert?: boolean; + isFullStackCert?: boolean; + isSciCompPyCertV7?: boolean; + isDataAnalysisPyCertV7?: boolean; + isMachineLearningPyCertV7?: boolean; + isRelationalDatabaseCertV8?: boolean; + isCollegeAlgebraPyCertV8?: boolean; + isFoundationalCSharpCertV8?: boolean; + isUpcomingPythonCertV8?: boolean; +} + +function getUserIsCertMap(user: CertI) { + const { + isRespWebDesignCert = false, + isJsAlgoDataStructCert = false, + isJsAlgoDataStructCertV8 = false, + isFrontEndLibsCert = false, + is2018DataVisCert = false, + isApisMicroservicesCert = false, + isInfosecQaCert = false, + isQaCertV7 = false, + isInfosecCertV7 = false, + isFrontEndCert = false, + isBackEndCert = false, + isDataVisCert = false, + isFullStackCert = false, + isSciCompPyCertV7 = false, + isDataAnalysisPyCertV7 = false, + isMachineLearningPyCertV7 = false, + isRelationalDatabaseCertV8 = false, + isCollegeAlgebraPyCertV8 = false, + isFoundationalCSharpCertV8 = false, + isUpcomingPythonCertV8 = false + } = user; + + return { + isRespWebDesignCert, + isJsAlgoDataStructCert, + isJsAlgoDataStructCertV8, + isFrontEndLibsCert, + is2018DataVisCert, + isApisMicroservicesCert, + isInfosecQaCert, + isQaCertV7, + isInfosecCertV7, + isFrontEndCert, + isBackEndCert, + isDataVisCert, + isFullStackCert, + isSciCompPyCertV7, + isDataAnalysisPyCertV7, + isMachineLearningPyCertV7, + isRelationalDatabaseCertV8, + isCollegeAlgebraPyCertV8, + isFoundationalCSharpCertV8, + isUpcomingPythonCertV8 + }; +} + /** * Plugin for the protected certificate endpoints. * @@ -257,451 +449,3 @@ export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = ( done(); }; - -/** - * Plugin for the unprotected certificate endpoints. - * - * @param fastify The Fastify instance. - * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. - * @param done The callback to signal that the plugin is ready. - */ -export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = ( - fastify, - _options, - done -) => { - fastify.get( - '/certificate/showCert/:username/:certSlug', - { - schema: schemas.certSlug - }, - async (req, reply) => { - const username = req.params.username.toLowerCase(); - const certSlug = req.params.certSlug; - - fastify.log.info(`certSlug: ${certSlug}`); - - if (!isKnownCertSlug(certSlug)) { - void reply.code(404); - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.cert-not-found', - variables: { certSlug } - } - ] - }); - } - - const certType = certSlugTypeMap[certSlug]; - const certId = certTypeIdMap[certType]; - const certTitle = certTypeTitleMap[certType]; - const completionTime = completionHours[certType] || 300; - const user = await fastify.prisma.user.findFirst({ - where: { username }, - select: { - isBanned: true, - isCheater: true, - isFrontEndCert: true, - isBackEndCert: true, - isFullStackCert: true, - isRespWebDesignCert: true, - isFrontEndLibsCert: true, - isJsAlgoDataStructCert: true, - isJsAlgoDataStructCertV8: true, - isDataVisCert: true, - is2018DataVisCert: true, - isApisMicroservicesCert: true, - isInfosecQaCert: true, - isQaCertV7: true, - isInfosecCertV7: true, - isSciCompPyCertV7: true, - isDataAnalysisPyCertV7: true, - isMachineLearningPyCertV7: true, - isRelationalDatabaseCertV8: true, - isCollegeAlgebraPyCertV8: true, - isFoundationalCSharpCertV8: true, - isUpcomingPythonCertV8: true, - isHonest: true, - username: true, - name: true, - completedChallenges: true, - profileUI: true - } - }); - - if (user === null) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.username-not-found', - variables: { username } - } - ] - }); - } - - if (user.isCheater || user.isBanned) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.not-eligible' - } - ] - }); - } - - if (!user.isHonest) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.not-honest', - variables: { username } - } - ] - }); - } - - if (user.profileUI?.isLocked) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.profile-private', - variables: { username } - } - ] - }); - } - - if (!user.name) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.add-name' - } - ] - }); - } - - if (!user.profileUI?.showCerts) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.certs-private', - variables: { username } - } - ] - }); - } - - if (!user.profileUI?.showTimeLine) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.timeline-private', - variables: { username } - } - ] - }); - } - - if (!user[certType]) { - return reply.send({ - messages: [ - { - type: 'info', - message: 'flash.user-not-certified', - variables: { username, cert: certTypeTitleMap[certType] } - } - ] - }); - } - - const { completedChallenges } = user; - const certChallenge = find( - completedChallenges, - ({ id }) => certId === id - ); - - let { completedDate = Date.now() } = certChallenge || {}; - - // the challenge id has been rotated for isDataVisCert - if (certType === 'isDataVisCert' && !certChallenge) { - const oldDataVisIdChall = find( - completedChallenges, - ({ id }) => oldDataVizId === id - ); - - if (oldDataVisIdChall) { - completedDate = oldDataVisIdChall.completedDate || completedDate; - } - } - - // if fullcert is not found, return the latest completedDate - if (certType === 'isFullStackCert' && !certChallenge) { - completedDate = getFallbackFullStackDate( - completedChallenges, - completedDate - ); - } - - const { name } = user; - - if (!user.profileUI.showName) { - void reply.code(200); - return reply.send({ - certSlug, - certTitle, - username, - date: completedDate, - completionTime - }); - } - - void reply.code(200); - return reply.send({ - certSlug, - certTitle, - username, - name, - date: completedDate, - completionTime - }); - } - ); - - done(); -}; - -function isCertAllowed(certSlug: string): boolean { - if ( - currentCertifications.includes(certSlug) || - legacyCertifications.includes(certSlug) || - legacyFullStackCertification.includes(certSlug) - ) { - return true; - } - if (SHOW_UPCOMING_CHANGES && upcomingCertifications.includes(certSlug)) { - return true; - } - return false; -} - -function renderCertifiedEmail({ - username, - name -}: { - username: string; - name: string; -}) { - const certifiedEmailTemplate = `Hi ${name || username}, - -Congratulations on completing all of the freeCodeCamp certifications! - -All of your certifications are now live at at: https://www.freecodecamp.org/${username} - -Please tell me a bit more about you and your near-term goals. - -Are you interested in contributing to our open source projects used by nonprofits? - -Also, check out https://contribute.freecodecamp.org/ for some fun and convenient ways you can contribute to the community. - -Happy coding, - -- Quincy Larson, teacher at freeCodeCamp -`; - return certifiedEmailTemplate; -} - -function hasCompletedTests( - tests: { id: string }[], - completedChallenges: CompletedChallenge[] -) { - return tests.every(({ id }) => - completedChallenges.some(({ id: completedId }) => completedId === id) - ); -} - -function isKnownCertSlug( - certSlug: string -): certSlug is keyof typeof certSlugTypeMap { - return certSlug in certSlugTypeMap; -} - -function createCertTypeIds(challenges: ReturnType) { - return { - // legacy - [certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, challenges), - [certTypes.jsAlgoDataStruct]: getCertById(jsAlgoDataStructId, challenges), - [certTypes.backEnd]: getCertById(legacyBackEndChallengeId, challenges), - [certTypes.dataVis]: getCertById(legacyDataVisId, challenges), - [certTypes.infosecQa]: getCertById(legacyInfosecQaId, challenges), - [certTypes.fullStack]: getCertById(legacyFullStackId, challenges), - - // modern - [certTypes.respWebDesign]: getCertById(respWebDesignId, challenges), - [certTypes.frontEndDevLibs]: getCertById(frontEndDevLibsId, challenges), - [certTypes.dataVis2018]: getCertById(dataVis2018Id, challenges), - [certTypes.jsAlgoDataStructV8]: getCertById( - jsAlgoDataStructV8Id, - challenges - ), - [certTypes.apisMicroservices]: getCertById(apisMicroservicesId, challenges), - [certTypes.qaV7]: getCertById(qaV7Id, challenges), - [certTypes.infosecV7]: getCertById(infosecV7Id, challenges), - [certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, challenges), - [certTypes.dataAnalysisPyV7]: getCertById(dataAnalysisPyV7Id, challenges), - [certTypes.machineLearningPyV7]: getCertById( - machineLearningPyV7Id, - challenges - ), - [certTypes.relationalDatabaseV8]: getCertById( - relationalDatabaseV8Id, - challenges - ), - [certTypes.collegeAlgebraPyV8]: getCertById( - collegeAlgebraPyV8Id, - challenges - ), - [certTypes.foundationalCSharpV8]: getCertById( - foundationalCSharpV8Id, - challenges - ), - - // upcoming - [certTypes.upcomingPythonV8]: getCertById(upcomingPythonV8Id, challenges) - }; -} - -function getCertById( - challengeId: string, - challenges: ReturnType -): { id: string; tests: { id: string }[]; challengeType: number } { - const challengeById = challenges.filter(({ id }) => id === challengeId)[0]; - if (!challengeById) { - throw new Error(`Challenge with id '${challengeId}' not found`); - } - const { id, tests, challengeType } = challengeById; - assertTestsExist(tests); - return { id, tests, challengeType }; -} - -function assertTestsExist( - tests: ReturnType[number]['tests'] -): asserts tests is { id: string }[] { - if (!Array.isArray(tests)) { - throw new Error('Tests is not an array'); - } - if (!tests.every(test => typeof test === 'object' && test !== null)) { - throw new Error('Tests contains non-object values'); - } - if (!tests.every(test => typeof test.id === 'string')) { - throw new Error('Tests contain non-string ids'); - } -} - -interface CertI { - isRespWebDesignCert?: boolean; - isJsAlgoDataStructCert?: boolean; - isJsAlgoDataStructCertV8?: boolean; - isFrontEndLibsCert?: boolean; - is2018DataVisCert?: boolean; - isApisMicroservicesCert?: boolean; - isInfosecQaCert?: boolean; - isQaCertV7?: boolean; - isInfosecCertV7?: boolean; - isFrontEndCert?: boolean; - isBackEndCert?: boolean; - isDataVisCert?: boolean; - isFullStackCert?: boolean; - isSciCompPyCertV7?: boolean; - isDataAnalysisPyCertV7?: boolean; - isMachineLearningPyCertV7?: boolean; - isRelationalDatabaseCertV8?: boolean; - isCollegeAlgebraPyCertV8?: boolean; - isFoundationalCSharpCertV8?: boolean; - isUpcomingPythonCertV8?: boolean; -} - -function getUserIsCertMap(user: CertI) { - const { - isRespWebDesignCert = false, - isJsAlgoDataStructCert = false, - isJsAlgoDataStructCertV8 = false, - isFrontEndLibsCert = false, - is2018DataVisCert = false, - isApisMicroservicesCert = false, - isInfosecQaCert = false, - isQaCertV7 = false, - isInfosecCertV7 = false, - isFrontEndCert = false, - isBackEndCert = false, - isDataVisCert = false, - isFullStackCert = false, - isSciCompPyCertV7 = false, - isDataAnalysisPyCertV7 = false, - isMachineLearningPyCertV7 = false, - isRelationalDatabaseCertV8 = false, - isCollegeAlgebraPyCertV8 = false, - isFoundationalCSharpCertV8 = false, - isUpcomingPythonCertV8 = false - } = user; - - return { - isRespWebDesignCert, - isJsAlgoDataStructCert, - isJsAlgoDataStructCertV8, - isFrontEndLibsCert, - is2018DataVisCert, - isApisMicroservicesCert, - isInfosecQaCert, - isQaCertV7, - isInfosecCertV7, - isFrontEndCert, - isBackEndCert, - isDataVisCert, - isFullStackCert, - isSciCompPyCertV7, - isDataAnalysisPyCertV7, - isMachineLearningPyCertV7, - isRelationalDatabaseCertV8, - isCollegeAlgebraPyCertV8, - isFoundationalCSharpCertV8, - isUpcomingPythonCertV8 - }; -} - -/** - * Retrieves the completion date for the full stack certification, if it exists. - * - * @param completedChallenges - The array of completed challenges. - * @param completedDate - The fallback completed date. - * @returns The latest certification date or the completed date if no certification is found. - */ -export function getFallbackFullStackDate( - completedChallenges: { id: string; completedDate: number }[], - completedDate: number -) { - const chalIds = [ - respWebDesignId, - jsAlgoDataStructId, - frontEndDevLibsId, - dataVis2018Id, - apisMicroservicesId, - legacyInfosecQaId - ]; - - const latestCertDate = completedChallenges - .filter(chal => chalIds.includes(chal.id)) - .sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate; - - return latestCertDate ?? completedDate; -} diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/protected/challenge.test.ts similarity index 99% rename from api/src/routes/challenge.test.ts rename to api/src/routes/protected/challenge.test.ts index b584770b76c..c9f464a0212 100644 --- a/api/src/routes/challenge.test.ts +++ b/api/src/routes/protected/challenge.test.ts @@ -6,7 +6,7 @@ const mockVerifyTrophyWithMicrosoft = jest.fn(); import { omit } from 'lodash'; import { Static } from '@fastify/type-provider-typebox'; -import { challengeTypes } from '../../../shared/config/challenge-types'; +import { challengeTypes } from '../../../../shared/config/challenge-types'; import { defaultUserId, devLogin, @@ -16,7 +16,7 @@ import { defaultUserEmail, createSuperRequest, defaultUsername -} from '../../jest.utils'; +} from '../../../jest.utils'; import { completedExamChallengeOneCorrect, completedExamChallengeTwoCorrect, @@ -31,14 +31,14 @@ import { examWithTwoCorrect, examWithAllCorrect, type ExamSubmission -} from '../../__mocks__/exam'; -import { Answer } from '../utils/exam-types'; -import type { getSessionUser } from '../schemas/user/get-session-user'; +} from '../../../__mocks__/exam'; +import { Answer } from '../../utils/exam-types'; +import type { getSessionUser } from '../../schemas/user/get-session-user'; -jest.mock('./helpers/challenge-helpers', () => { +jest.mock('../helpers/challenge-helpers', () => { const originalModule = jest.requireActual< - typeof import('./helpers/challenge-helpers') - >('./helpers/challenge-helpers'); + typeof import('../helpers/challenge-helpers') + >('../helpers/challenge-helpers'); return { __esModule: true, diff --git a/api/src/routes/challenge.ts b/api/src/routes/protected/challenge.ts similarity index 97% rename from api/src/routes/challenge.ts rename to api/src/routes/protected/challenge.ts index de4ae019f02..99848b7385c 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/protected/challenge.ts @@ -4,8 +4,8 @@ import { uniqBy } from 'lodash'; import { CompletedExam, ExamResults } from '@prisma/client'; import isURL from 'validator/lib/isURL'; -import { challengeTypes } from '../../../shared/config/challenge-types'; -import * as schemas from '../schemas'; +import { challengeTypes } from '../../../../shared/config/challenge-types'; +import * as schemas from '../../schemas'; import { jsCertProjectIds, multifileCertProjectIds, @@ -14,25 +14,25 @@ import { type CompletedChallenge, saveUserChallengeData, msTrophyChallenges -} from '../utils/common-challenge-functions'; -import { JWT_SECRET } from '../utils/env'; +} from '../../utils/common-challenge-functions'; +import { JWT_SECRET } from '../../utils/env'; import { formatCoderoadChallengeCompletedValidation, formatProjectCompletedValidation -} from '../utils/error-formatting'; -import { getChallenges } from '../utils/get-challenges'; -import { ProgressTimestamp, getPoints } from '../utils/progress'; +} from '../../utils/error-formatting'; +import { getChallenges } from '../../utils/get-challenges'; +import { ProgressTimestamp, getPoints } from '../../utils/progress'; import { validateExamFromDbSchema, validateGeneratedExamSchema, validateUserCompletedExamSchema, validateExamResultsSchema -} from '../utils/exam-schemas'; -import { generateRandomExam, createExamResults } from '../utils/exam'; +} from '../../utils/exam-schemas'; +import { generateRandomExam, createExamResults } from '../../utils/exam'; import { canSubmitCodeRoadCertProject, verifyTrophyWithMicrosoft -} from './helpers/challenge-helpers'; +} from '../helpers/challenge-helpers'; interface JwtPayload { userToken: string; diff --git a/api/src/routes/donate.test.ts b/api/src/routes/protected/donate.test.ts similarity index 90% rename from api/src/routes/donate.test.ts rename to api/src/routes/protected/donate.test.ts index 6efe35529f5..7dafd19c30c 100644 --- a/api/src/routes/donate.test.ts +++ b/api/src/routes/protected/donate.test.ts @@ -3,11 +3,10 @@ import { createSuperRequest, devLogin, setupServer, - superRequest, defaultUserEmail, defaultUserId -} from '../../jest.utils'; -import { createUserInput } from '../utils/create-user'; +} from '../../../jest.utils'; +import { createUserInput } from '../../utils/create-user'; const testEWalletEmail = 'baz@bar.com'; const testSubscriptionId = 'sub_test_id'; @@ -490,49 +489,4 @@ describe('Donate', () => { }); }); }); - - describe('Unauthenticated User', () => { - // Get the CSRF cookies from an unprotected route - beforeAll(async () => { - const res = await superRequest('/status/ping', { method: 'GET' }); - setCookies = res.get('Set-Cookie'); - }); - - const endpoints: { path: string; method: 'POST' | 'PUT' }[] = [ - { path: '/donate/add-donation', method: 'POST' }, - { path: '/donate/charge-stripe-card', method: 'POST' }, - { path: '/donate/update-stripe-card', method: 'PUT' } - ]; - - endpoints.forEach(({ path, method }) => { - test(`${method} ${path} returns 401 status code with error message`, async () => { - const response = await superRequest(path, { - method, - setCookies - }); - expect(response.statusCode).toBe(401); - }); - }); - - test('POST /donate/create-stripe-payment-intent should return 200', async () => { - mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors')); - const response = await superRequest( - '/donate/create-stripe-payment-intent', - { - method: 'POST', - setCookies - } - ).send(createStripePaymentIntentReqBody); - expect(response.status).toBe(200); - }); - - test('POST /donate/charge-stripe should return 200', async () => { - mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors')); - const response = await superRequest('/donate/charge-stripe', { - method: 'POST', - setCookies - }).send(chargeStripeReqBody); - expect(response.status).toBe(200); - }); - }); }); diff --git a/api/src/routes/donate.ts b/api/src/routes/protected/donate.ts similarity index 54% rename from api/src/routes/donate.ts rename to api/src/routes/protected/donate.ts index 4607f6aacb6..0003124d380 100644 --- a/api/src/routes/donate.ts +++ b/api/src/routes/protected/donate.ts @@ -1,13 +1,9 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; import Stripe from 'stripe'; -import { - donationSubscriptionConfig, - allStripeProductIdsArray -} from '../../../shared/config/donation-settings'; -import * as schemas from '../schemas'; -import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../utils/env'; -import { inLastFiveMinutes } from '../utils/validate-donation'; -import { findOrCreateUser } from './helpers/auth-helpers'; + +import * as schemas from '../../schemas'; +import { donationSubscriptionConfig } from '../../../../shared/config/donation-settings'; +import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../../utils/env'; /** * Plugin for the donation endpoints requiring auth. @@ -225,159 +221,3 @@ export const donateRoutes: FastifyPluginCallbackTypebox = ( done(); }; - -/** - * Plugin for public donation endpoints. - * - * @param fastify The Fastify instance. - * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. - * @param done The callback to signal that the plugin is ready. - */ -export const chargeStripeRoute: FastifyPluginCallbackTypebox = ( - fastify, - _options, - done -) => { - // Stripe plugin - const stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: '2024-06-20', - typescript: true - }); - - fastify.post( - '/donate/create-stripe-payment-intent', - { - schema: schemas.createStripePaymentIntent - }, - async (req, reply) => { - const { email, name, amount, duration } = req.body; - - if (!donationSubscriptionConfig.plans[duration].includes(amount)) { - void reply.code(400); - return { - error: 'The donation form had invalid values for this submission.' - } as const; - } - - try { - const stripeCustomer = await stripe.customers.create({ - email, - name - }); - - const stripeSubscription = await stripe.subscriptions.create({ - customer: stripeCustomer.id, - items: [ - { - plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}` - } - ], - payment_behavior: 'default_incomplete', - payment_settings: { save_default_payment_method: 'on_subscription' }, - expand: ['latest_invoice.payment_intent'] - }); - - if ( - stripeSubscription.latest_invoice && - typeof stripeSubscription.latest_invoice !== 'string' && - stripeSubscription.latest_invoice.payment_intent && - typeof stripeSubscription.latest_invoice.payment_intent !== - 'string' && - stripeSubscription.latest_invoice.payment_intent.client_secret !== - null - ) { - const clientSecret = - stripeSubscription.latest_invoice.payment_intent.client_secret; - return reply.send({ - subscriptionId: stripeSubscription.id, - clientSecret - }); - } else { - throw new Error('Stripe payment intent client secret is missing'); - } - } catch (error) { - fastify.log.error(error); - fastify.Sentry.captureException(error); - void reply.code(500); - return reply.send({ - error: 'Donation failed due to a server error.' - }); - } - } - ); - - fastify.post( - '/donate/charge-stripe', - { - schema: schemas.chargeStripe - }, - async (req, reply) => { - try { - const { email, amount, duration, subscriptionId } = req.body; - - const subscription = - await stripe.subscriptions.retrieve(subscriptionId); - const isSubscriptionActive = subscription.status === 'active'; - const productId = subscription.items.data[0]?.plan.product?.toString(); - const isStartedRecently = inLastFiveMinutes( - subscription.current_period_start - ); - const isProductIdValid = - productId && allStripeProductIdsArray.includes(productId); - const isValidCustomer = typeof subscription.customer === 'string'; - - if (!isSubscriptionActive) - throw new Error( - `Stripe subscription information is invalid: ${subscriptionId}` - ); - if (!isProductIdValid) - throw new Error(`Product ID is invalid: ${subscriptionId}`); - if (!isStartedRecently) - throw new Error(`Subscription is not recent: ${subscriptionId}`); - if (!isValidCustomer) - throw new Error(`Customer ID is invalid: ${subscriptionId}`); - else { - // TODO(Post-MVP) new users should not be created if user is not found - const user = await findOrCreateUser(fastify, email); - const donation = { - userId: user.id, - email, - amount, - duration, - provider: 'stripe', - subscriptionId, - customerId: subscription.customer as string, - // TODO(Post-MVP) migrate to startDate: new Date() - startDate: { - date: new Date().toISOString(), - when: new Date().toISOString().replace(/.$/, '+00:00') - } - }; - - await fastify.prisma.donation.create({ - data: donation - }); - - await fastify.prisma.user.update({ - where: { id: user.id }, - data: { - isDonating: true - } - }); - return reply.send({ - isDonating: true - }); - } - } catch (error) { - fastify.log.error(error); - fastify.Sentry.captureException(error); - void reply.code(500); - return { - error: 'Donation failed due to a server error.' - } as const; - } - } - ); - - done(); -}; diff --git a/api/src/routes/protected/index.ts b/api/src/routes/protected/index.ts new file mode 100644 index 00000000000..a09f3664d7e --- /dev/null +++ b/api/src/routes/protected/index.ts @@ -0,0 +1,5 @@ +export * from './certificate'; +export * from './challenge'; +export * from './donate'; +export * from './settings'; +export * from './user'; diff --git a/api/src/routes/settings.test.ts b/api/src/routes/protected/settings.test.ts similarity index 99% rename from api/src/routes/settings.test.ts rename to api/src/routes/protected/settings.test.ts index 2eafedebc8f..34952be9623 100644 --- a/api/src/routes/settings.test.ts +++ b/api/src/routes/protected/settings.test.ts @@ -7,10 +7,10 @@ import { createSuperRequest, defaultUserId, defaultUserEmail -} from '../../jest.utils'; -import { formatMessage } from '../plugins/redirect-with-message'; -import { createUserInput } from '../utils/create-user'; -import { API_LOCATION, HOME_LOCATION } from '../utils/env'; +} from '../../../jest.utils'; +import { formatMessage } from '../../plugins/redirect-with-message'; +import { createUserInput } from '../../utils/create-user'; +import { API_LOCATION, HOME_LOCATION } from '../../utils/env'; import { isPictureWithProtocol, getWaitMessage, diff --git a/api/src/routes/settings.ts b/api/src/routes/protected/settings.ts similarity index 98% rename from api/src/routes/settings.ts rename to api/src/routes/protected/settings.ts index 065df49a18b..b62ae3dbf4b 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/protected/settings.ts @@ -17,12 +17,12 @@ import { ResolveFastifyReplyType } from 'fastify/types/type-provider'; import { differenceInMinutes } from 'date-fns'; import validator from 'validator'; -import { isValidUsername } from '../../../shared/utils/validate'; -import * as schemas from '../schemas'; -import { createAuthToken, isExpired } from '../utils/tokens'; -import { API_LOCATION } from '../utils/env'; -import { getRedirectParams } from '../utils/redirection'; -import { isRestricted } from './helpers/is-restricted'; +import { isValidUsername } from '../../../../shared/utils/validate'; +import * as schemas from '../../schemas'; +import { createAuthToken, isExpired } from '../../utils/tokens'; +import { API_LOCATION } from '../../utils/env'; +import { getRedirectParams } from '../../utils/redirection'; +import { isRestricted } from '../helpers/is-restricted'; const { isEmail } = validator; diff --git a/api/src/routes/user.test.ts b/api/src/routes/protected/user.test.ts similarity index 75% rename from api/src/routes/user.test.ts rename to api/src/routes/protected/user.test.ts index d0c71e34cf0..546d936de58 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -6,7 +6,7 @@ import type { Prisma } from '@prisma/client'; import { ObjectId } from 'mongodb'; import _ from 'lodash'; -import { createUserInput } from '../utils/create-user'; +import { createUserInput } from '../../utils/create-user'; import { defaultUserId, defaultUserEmail, @@ -14,9 +14,9 @@ import { setupServer, superRequest, createSuperRequest -} from '../../jest.utils'; -import { JWT_SECRET } from '../utils/env'; -import { getMsTranscriptApiUrl, replacePrivateData } from './user'; +} from '../../../jest.utils'; +import { JWT_SECRET } from '../../utils/env'; +import { getMsTranscriptApiUrl } from './user'; const mockedFetch = jest.fn(); jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch); @@ -1162,228 +1162,6 @@ Thanks and regards, }); }); }); - - describe('Public', () => { - let superGet: ReturnType; - - beforeEach(() => { - superGet = createSuperRequest({ method: 'GET' }); - }); - - describe('/api/users/get-public-profile', () => { - const profilelessUsername = 'profileless-user'; - const lockedUsername = 'locked-user'; - const publicUsername = 'public-user'; - const lockedUserProfileUI = { - isLocked: true, - showAbout: true, - showPortfolio: false - }; - const unlockedUserProfileUI = { - isLocked: false, - showAbout: true, - showCerts: true, - showDonation: true, - showHeatMap: true, - showLocation: true, - showName: true, - showPoints: true, - showPortfolio: true, - showTimeLine: true - }; - const users = [profilelessUsername, lockedUsername, publicUsername]; - beforeAll(async () => { - await fastifyTestInstance.prisma.user.create({ - data: { - ...minimalUserData, - email: profilelessUsername, - username: profilelessUsername - } - }); - await fastifyTestInstance.prisma.user.create({ - data: { - ...minimalUserData, - email: lockedUsername, - username: lockedUsername, - profileUI: lockedUserProfileUI - } - }); - await fastifyTestInstance.prisma.user.create({ - data: { - ...testUserData, - email: publicUsername, - username: publicUsername, - profileUI: unlockedUserProfileUI - } - }); - }); - - afterAll(async () => { - await fastifyTestInstance.prisma.user.deleteMany({ - where: { - OR: users.map(username => ({ username })) - } - }); - }); - - describe('GET', () => { - test('returns 400 status code if the user agent is blocked', async () => { - const response = await superGet( - '/api/users/get-public-profile?username=public-user' - ).set('User-Agent', 'curl'); - - expect(response.text).toBe( - 'This endpoint is no longer available outside of the freeCodeCamp ecosystem' - ); - expect(response.statusCode).toBe(400); - }); - - test('returns 400 status code if the username param is missing', async () => { - const res = await superGet('/api/users/get-public-profile'); - // TODO(Post-MVP): return something more informative - expect(res.body).toStrictEqual({}); - expect(res.statusCode).toBe(400); - }); - - test('returns 400 status code if the username param is empty', async () => { - const res = await superGet('/api/users/get-public-profile?username='); - // TODO(Post-MVP): return something more informative - expect(res.body).toStrictEqual({}); - expect(res.statusCode).toBe(400); - }); - - test('returns 404 status code for non-existent user', async () => { - const response = await superGet( - '/api/users/get-public-profile?username=non-existent' - ); - // TODO(Post-MVP): return something more informative - expect(response.body).toStrictEqual({}); - expect(response.statusCode).toBe(404); - }); - - test('returns 200 status code with a locked profile if the profile is private', async () => { - const response = await superGet( - `/api/users/get-public-profile?username=${lockedUsername}` - ); - - expect(response.body).toStrictEqual({ - entities: { - user: { - [lockedUsername]: { - isLocked: true, - profileUI: lockedUserProfileUI, - username: lockedUsername - } - } - }, - result: lockedUsername - }); - expect(response.statusCode).toBe(200); - }); - - test('returns 200 status code locked profile if the profile is missing', async () => { - const response = await superGet( - `/api/users/get-public-profile?username=${profilelessUsername}` - ); - - expect(response.body).toStrictEqual({ - entities: { - user: { - [profilelessUsername]: { - isLocked: true, - profileUI: lockedProfileUI, - username: profilelessUsername - } - } - }, - result: profilelessUsername - }); - expect(response.statusCode).toBe(200); - }); - // TODO: create a list of public properties like the api-server and use that - // to restrict the output of this and get-session-user. - test('returns 200 status code with public user object', async () => { - const testUser = - await fastifyTestInstance.prisma.user.findFirstOrThrow({ - where: { email: publicUsername } - }); - const response = await superGet( - `/api/users/get-public-profile?username=${publicUsername}` - ); - - // TODO: create a fixture for this without 'completedSurveys', ideally - // it should contain the entire body. - const publicUser = { - // TODO(Post-MVP, maybe): return completedSurveys? - ..._.omit(publicUserData, 'completedSurveys'), - username: publicUsername, - joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(), - profileUI: unlockedUserProfileUI - }; - - expect(response.body).toStrictEqual({ - entities: { - user: { - [publicUsername]: publicUser - } - }, - result: publicUsername - }); - expect(response.statusCode).toBe(200); - }); - }); - }); - describe('GET /api/users/exists', () => { - beforeAll(async () => { - await fastifyTestInstance.prisma.user.create({ - data: minimalUserData - }); - }); - - it('should return { exists: true } with a 400 status code if the username param is missing or empty', async () => { - const res = await superGet('/api/users/exists'); - - expect(res.body).toStrictEqual({ exists: true }); - expect(res.statusCode).toBe(400); - - const res2 = await superGet('/api/users/exists?username='); - - expect(res2.body).toStrictEqual({ exists: true }); - expect(res2.statusCode).toBe(400); - }); - - it('should return { exists: true } if the username exists', async () => { - const res = await superGet('/api/users/exists?username=testuser'); - - expect(res.body).toStrictEqual({ exists: true }); - expect(res.statusCode).toBe(200); - }); - - it('should ignore case when checking for username existence', async () => { - const res = await superGet('/api/users/exists?username=TeStUsEr'); - - expect(res.body).toStrictEqual({ exists: true }); - expect(res.statusCode).toBe(200); - }); - - it('should return { exists: false } if the username does not exist', async () => { - const res = await superGet('/api/users/exists?username=nonexistent'); - - expect(res.body).toStrictEqual({ exists: false }); - expect(res.statusCode).toBe(200); - }); - - it('should return { exists: true } if the username is restricted (ignoring case)', async () => { - const res = await superGet('/api/users/exists?username=pRofIle'); - - expect(res.body).toStrictEqual({ exists: true }); - - const res2 = await superGet('/api/users/exists?username=flAnge'); - - expect(res2.body).toStrictEqual({ exists: true }); - }); - }); - }); }); describe('Microsoft helpers', () => { @@ -1413,147 +1191,3 @@ describe('Microsoft helpers', () => { }); }); }); - -describe('get-public-profile helpers', () => { - describe('replacePrivateData', () => { - const user = { - about: 'about', - calendar: { 1: 1, 2: 1 } as const, - completedChallenges: [ - { id: '123', completedDate: 123, files: [] }, - { id: '456', completedDate: 456, challengeType: 7, files: [] } - ], - id: '5f5b1b3b1c9d440000d9e3b4', - isDonating: false, - location: 'location', - joinDate: 'joinDate', - name: 'name', - points: 2, - portfolio: [ - { - id: '789', - title: 'portfolio', - url: 'url', - image: 'image', - description: 'description' - } - ], - profileUI: { - isLocked: false, - showAbout: true, - showCerts: true, - showDonation: true, - showHeatMap: true, - showLocation: true, - showName: true, - showPoints: true, - showPortfolio: true, - showTimeLine: true - } - }; - - test(`returns "" for 'about' if showAbout is not true`, () => { - const userWithoutAbout = { - ...user, - profileUI: { ...user.profileUI, showAbout: false } - }; - expect(replacePrivateData(userWithoutAbout)).toMatchObject({ - about: '' - }); - }); - - test('returns {} for calendar if showHeatMap is not true', () => { - const userWithoutHeatMap = { - ...user, - profileUI: { ...user.profileUI, showHeatMap: false } - }; - expect(replacePrivateData(userWithoutHeatMap).calendar).toEqual({}); - }); - - test(`returns [] for completeChallenges if showTimeLine is not true`, () => { - const userWithoutTimeLine = { - ...user, - profileUI: { ...user.profileUI, showTimeLine: false } - }; - expect(replacePrivateData(userWithoutTimeLine)).toMatchObject({ - completedChallenges: [] - }); - }); - - test('omits certifications from completedChallenges if showCerts is not true', () => { - const userWithoutCerts = { - ...user, - profileUI: { ...user.profileUI, showCerts: false } - }; - expect(replacePrivateData(userWithoutCerts)).toMatchObject({ - completedChallenges: [{ id: '123', completedDate: 123, files: [] }] - }); - }); - - test('returns null for isDonating if showDonation is not true', () => { - const userWithoutDonation = { - ...user, - profileUI: { ...user.profileUI, showDonation: false } - }; - expect(replacePrivateData(userWithoutDonation)).toMatchObject({ - isDonating: null - }); - }); - - test('returns "" for joinDate if showAbout is not true', () => { - const userWithoutAbout = { - ...user, - profileUI: { ...user.profileUI, showAbout: false } - }; - expect(replacePrivateData(userWithoutAbout)).toMatchObject({ - joinDate: '' - }); - }); - - test(`returns "" for 'location' if showLocation is not true`, () => { - const userWithoutLocation = { - ...user, - profileUI: { ...user.profileUI, showLocation: false } - }; - expect(replacePrivateData(userWithoutLocation)).toMatchObject({ - location: '' - }); - }); - - test(`returns "" for 'name' if showName is not true`, () => { - const userWithoutName = { - ...user, - profileUI: { ...user.profileUI, showName: false } - }; - expect(replacePrivateData(userWithoutName)).toMatchObject({ - name: '' - }); - }); - - test('returns null for points if showPoints is not true', () => { - const userWithoutPoints = { - ...user, - profileUI: { ...user.profileUI, showPoints: false } - }; - expect(replacePrivateData(userWithoutPoints)).toMatchObject({ - points: null - }); - }); - - test('returns [] for portfolio if showPortfolio is not true', () => { - const userWithoutPortfolio = { - ...user, - profileUI: { ...user.profileUI, showPortfolio: false } - }; - expect(replacePrivateData(userWithoutPortfolio)).toMatchObject({ - portfolio: [] - }); - }); - - test('returns the expected public user object if all showX flags are true', () => { - expect(replacePrivateData(user)).toEqual( - _.omit(user, ['id', 'profileUI']) - ); - }); - }); -}); diff --git a/api/src/routes/user.ts b/api/src/routes/protected/user.ts similarity index 62% rename from api/src/routes/user.ts rename to api/src/routes/protected/user.ts index ff4b10dbf8d..2e8d294b716 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/protected/user.ts @@ -1,71 +1,26 @@ -import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; -import { Portfolio } from '@prisma/client'; +import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; import { ObjectId } from 'mongodb'; -import _ from 'lodash'; -import * as schemas from '../schemas'; -// Loopback creates a 64 character string for the user id, this customizes -// nanoid to do the same. Any unique key _should_ be fine, though. -import { customNanoid } from '../utils/ids'; +import * as schemas from '../../schemas'; +import { createResetProperties } from '../../utils/create-user'; +import { customNanoid } from '../../utils/ids'; +import { encodeUserToken } from '../../utils/tokens'; +import { trimTags } from '../../utils/validation'; +import { generateReportEmail } from '../../utils/email-templates'; +import { splitUser } from '../helpers/user-utils'; import { normalizeChallenges, normalizeFlags, normalizeProfileUI, - normalizeTwitter, - removeNulls, normalizeSurveys, - NormalizedChallenge -} from '../utils/normalize'; + normalizeTwitter, + removeNulls +} from '../../utils/normalize'; import { - Calendar, getCalendar, getPoints, - type ProgressTimestamp -} from '../utils/progress'; -import { encodeUserToken } from '../utils/tokens'; -import { trimTags } from '../utils/validation'; -import { generateReportEmail } from '../utils/email-templates'; -import { createResetProperties } from '../utils/create-user'; -import { challengeTypes } from '../../../shared/config/challenge-types'; -import { isRestricted } from './helpers/is-restricted'; - -// user flags that the api-server returns as false if they're missing in the -// user document. Since Prisma returns null for missing fields, we need to -// normalize them to false. -// TODO(Post-MVP): remove this when the database is normalized. -const nullableFlags = [ - 'is2018DataVisCert', - 'is2018FullStackCert', - 'isApisMicroservicesCert', - 'isBackEndCert', - 'isCheater', - 'isCollegeAlgebraPyCertV8', - 'isDataAnalysisPyCertV7', - 'isDataVisCert', - // isDonating doesn't need fixing because it's not nullable - 'isFoundationalCSharpCertV8', - 'isFrontEndCert', - 'isFullStackCert', - 'isFrontEndLibsCert', - 'isHonest', - 'isInfosecCertV7', - 'isInfosecQaCert', - 'isJsAlgoDataStructCert', - 'isJsAlgoDataStructCertV8', - 'isMachineLearningPyCertV7', - 'isQaCertV7', - 'isRelationalDatabaseCertV8', - 'isRespWebDesignCert', - 'isSciCompPyCertV7', - 'isDataAnalysisPyCertV7', - // isUpcomingPythonCertV8 exists in the db, but is not returned by the api-server - // TODO(Post-MVP): delete it from the db? - 'keyboardShortcuts' -] as const; - -const blockedUserAgentParts = ['python', 'google-apps-script', 'curl']; - -type NullableFlag = (typeof nullableFlags)[number]; + ProgressTimestamp +} from '../../utils/progress'; /** * Helper function to get the api url from the shared transcript link. @@ -510,8 +465,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( ? encodeUserToken(userToken.id) : undefined; - const flags = _.pick(user, nullableFlags); - const rest = _.omit(user, nullableFlags); + const [flags, rest] = splitUser(user); const { username, @@ -570,220 +524,3 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( done(); }; - -type ProfileUI = Partial<{ - isLocked: boolean; - showAbout: boolean; - showCerts: boolean; - showDonation: boolean; - showHeatMap: boolean; - showLocation: boolean; - showName: boolean; - showPoints: boolean; - showPortfolio: boolean; - showTimeLine: boolean; -}>; - -type RawUser = { - about: string; - completedChallenges: NormalizedChallenge[]; - calendar: Calendar; - id: string; - isDonating: boolean; - joinDate: string; - location: string; - name: string; - points: number; - portfolio: Portfolio[]; - profileUI: ProfileUI; -}; - -/** - * Creates an object with the properties that are shared with the public. - * @param user The raw user object. - * @returns The shared user object. - */ -export const replacePrivateData = (user: RawUser) => { - const { - showAbout, - showHeatMap, - showCerts, - showDonation, - showLocation, - showName, - showPoints, - showPortfolio, - showTimeLine - } = user.profileUI; - - return { - about: showAbout ? user.about : '', - calendar: showHeatMap ? user.calendar : {}, - completedChallenges: showTimeLine - ? showCerts - ? user.completedChallenges - : user.completedChallenges.filter( - c => c.challengeType !== challengeTypes.step - ) - : [], - isDonating: showDonation ? user.isDonating : null, - joinDate: showAbout ? user.joinDate : '', - location: showLocation ? user.location : '', - name: showName ? user.name : '', - points: showPoints ? user.points : null, - portfolio: showPortfolio ? user.portfolio : [] - }; -}; - -/** - * Plugin containing public GET routes for user account management. They are kept - * separate because they do not require CSRF protection or authorization. - * - * @param fastify The Fastify instance. - * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. - * @param done Callback to signal that the logic has completed. - */ -export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( - fastify, - _options, - done -) => { - fastify.get( - '/api/users/get-public-profile', - { - schema: schemas.getPublicProfile, - onRequest: (req, reply, done) => { - const userAgent = req.headers['user-agent']; - - if ( - userAgent && - blockedUserAgentParts.some(ua => userAgent.toLowerCase().includes(ua)) - ) { - void reply.code(400); - void reply.send( - 'This endpoint is no longer available outside of the freeCodeCamp ecosystem' - ); - } - done(); - } - }, - async (req, reply) => { - // TODO(Post-MVP): look for duplicates unless we can make username unique in the db. - const user = await fastify.prisma.user.findFirst({ - where: { username: req.query.username } - // TODO: only select desired fields, then stop 'omit'ing the undesired - // ones. - }); - - if (!user) { - void reply.code(404); - return reply.send({}); - } - - const flags = _.pick(user, nullableFlags); - const rest = _.omit(user, nullableFlags); - - const publicUser = _.omit(rest, [ - 'currentChallengeId', - 'email', - 'emailVerified', - 'sendQuincyEmail', - 'theme', - // keyboardShortcuts is included in flags. - // 'keyboardShortcuts', - 'acceptedPrivacyTerms', - 'progressTimestamps', - 'unsubscribeId', - 'donationEmails', - 'externalId', - 'usernameDisplay', - 'isBanned' - ]); - - const normalizedProfileUI = normalizeProfileUI(user.profileUI); - - void reply.code(200); - if (normalizedProfileUI.isLocked) { - // TODO(Post-MVP): just return isLocked: true and either a null user - // or no user at all. (see other TODO in the else branch below) - return reply.send({ - entities: { - user: { - [user.username]: { - isLocked: true, - profileUI: normalizedProfileUI, - username: user.username - } - } - }, - result: user.username - }); - } else { - const progressTimestamps = user.progressTimestamps as - | ProgressTimestamp[] - | null; - const sharedUser = replacePrivateData({ - ...user, - calendar: getCalendar(progressTimestamps), - completedChallenges: normalizeChallenges(user.completedChallenges), - location: user.location ?? '', - joinDate: new ObjectId(user.id).getTimestamp().toISOString(), - name: user.name ?? '', - points: getPoints(progressTimestamps), - profileUI: normalizedProfileUI - }); - - const returnedUser = { - ...removeNulls(publicUser), - ...normalizeFlags(flags), - ...sharedUser, - profileUI: normalizedProfileUI, - // TODO: should this always be returned? Shouldn't some privacy - // setting control it? Same applies to website, githubProfile, - // and linkedin. - twitter: normalizeTwitter(user.twitter), - yearsTopContributor: user.yearsTopContributor - }; - return reply.send({ - // TODO(Post-MVP): just return a user object (i.e. returnedUser) and - // isLocked: false. The there should be no need for Type.Union in the - // schema. Alternatively, have the user object be nullable and don't - // bother with isLocked. - entities: { - user: { [user.username]: returnedUser } - }, - result: user.username - }); - } - } - ); - - fastify.get( - '/api/users/exists', - { - schema: schemas.userExists, - attachValidation: true - }, - async (req, reply) => { - if (req.validationError) { - void reply.code(400); - // TODO(Post-MVP): return a message telling the requester that their - // request was malformed. - return await reply.send({ exists: true }); - } - - const username = req.query.username.toLowerCase(); - - if (isRestricted(username)) return await reply.send({ exists: true }); - - const exists = - (await fastify.prisma.user.count({ - where: { username } - })) > 0; - - await reply.send({ exists }); - } - ); - - done(); -}; diff --git a/api/src/routes/auth-dev.test.ts b/api/src/routes/public/auth-dev.test.ts similarity index 97% rename from api/src/routes/auth-dev.test.ts rename to api/src/routes/public/auth-dev.test.ts index 5af6e629343..47d66a236aa 100644 --- a/api/src/routes/auth-dev.test.ts +++ b/api/src/routes/public/auth-dev.test.ts @@ -1,8 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { defaultUserEmail, setupServer, superRequest } from '../../jest.utils'; -import { HOME_LOCATION } from '../utils/env'; -import { nanoidCharSet } from '../utils/create-user'; +import { + defaultUserEmail, + setupServer, + superRequest +} from '../../../jest.utils'; +import { HOME_LOCATION } from '../../utils/env'; +import { nanoidCharSet } from '../../utils/create-user'; describe('dev login', () => { setupServer(); diff --git a/api/src/routes/auth-dev.ts b/api/src/routes/public/auth-dev.ts similarity index 89% rename from api/src/routes/auth-dev.ts rename to api/src/routes/public/auth-dev.ts index d848f255a66..8e4e76c3109 100644 --- a/api/src/routes/auth-dev.ts +++ b/api/src/routes/public/auth-dev.ts @@ -1,12 +1,12 @@ import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; -import { createAccessToken } from '../utils/tokens'; +import { createAccessToken } from '../../utils/tokens'; import { getPrefixedLandingPath, getRedirectParams, haveSamePath -} from '../utils/redirection'; -import { findOrCreateUser } from './helpers/auth-helpers'; +} from '../../utils/redirection'; +import { findOrCreateUser } from '../helpers/auth-helpers'; const trimTrailingSlash = (str: string) => str.endsWith('/') ? str.slice(0, -1) : str; diff --git a/api/src/routes/auth.test.ts b/api/src/routes/public/auth.test.ts similarity index 96% rename from api/src/routes/auth.test.ts rename to api/src/routes/public/auth.test.ts index db06598562c..8a0f105a111 100644 --- a/api/src/routes/auth.test.ts +++ b/api/src/routes/public/auth.test.ts @@ -3,8 +3,8 @@ import { setupServer, superRequest, createSuperRequest -} from '../../jest.utils'; -import { AUTH0_DOMAIN } from '../utils/env'; +} from '../../../jest.utils'; +import { AUTH0_DOMAIN } from '../../utils/env'; const mockedFetch = jest.fn(); jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch); @@ -25,10 +25,10 @@ const mockAuth0ValidEmail = () => ({ json: () => ({ email: newUserEmail }) }); -jest.mock('../utils/env', () => { +jest.mock('../../utils/env', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return { - ...jest.requireActual('../utils/env'), + ...jest.requireActual('../../utils/env'), FCC_ENABLE_DEV_LOGIN_MODE: false }; }); diff --git a/api/src/routes/auth.ts b/api/src/routes/public/auth.ts similarity index 95% rename from api/src/routes/auth.ts rename to api/src/routes/public/auth.ts index 75940e9ae10..6096597ea26 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/public/auth.ts @@ -9,10 +9,10 @@ import rateLimit, { type FastifyRateLimitStore } from '@fastify/rate-limit'; import MongoStoreRL from 'rate-limit-mongo'; import isEmail from 'validator/lib/isEmail'; -import { AUTH0_DOMAIN, MONGOHQ_URL } from '../utils/env'; -import { auth0Client } from '../plugins/auth0'; -import { createAccessToken } from '../utils/tokens'; -import { findOrCreateUser } from './helpers/auth-helpers'; +import { AUTH0_DOMAIN, MONGOHQ_URL } from '../../utils/env'; +import { auth0Client } from '../../plugins/auth0'; +import { createAccessToken } from '../../utils/tokens'; +import { findOrCreateUser } from '../helpers/auth-helpers'; const getEmailFromAuth0 = async ( req: FastifyRequest diff --git a/api/src/routes/public/certificate.test.ts b/api/src/routes/public/certificate.test.ts new file mode 100644 index 00000000000..568c6155e96 --- /dev/null +++ b/api/src/routes/public/certificate.test.ts @@ -0,0 +1,274 @@ +import { + defaultUserEmail, + defaultUserId, + resetDefaultUser, + setupServer, + superRequest +} from '../../../jest.utils'; +import { getFallbackFullStackDate } from '../helpers/certificate-utils'; + +describe('certificate routes', () => { + setupServer(); + + describe('Unauthenticated user', () => { + beforeAll(async () => resetDefaultUser()); + + describe('GET /certificate/showCert/:username/:certSlug', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: defaultUserEmail }, + data: { + username: 'foobar', + name: 'foobar', + isHonest: true, + isBanned: false, + isCheater: false, + profileUI: { isLocked: false, showCerts: true, showTimeLine: true } + } + }); + }); + test('should return user not found if the user cannot be found', async () => { + const response = await superRequest( + '/certificate/showCert/not-a-valid-user-name/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.username-not-found', + variables: { username: 'not-a-valid-user-name' } + } + ] + }); + expect(response.status).toBe(200); + }); + test('should ask user to add name if there is no name', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { name: null } + }); + const response = await superRequest( + '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.add-name' + } + ] + }); + expect(response.status).toBe(200); + }); + test('should return not eligible if user is banned', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { isBanned: true } + }); + const response = await superRequest( + '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.not-eligible' + } + ] + }); + expect(response.status).toBe(200); + }); + test('should return not eligible if user is cheater', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { isCheater: true } + }); + const response = await superRequest( + '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.not-eligible' + } + ] + }); + expect(response.status).toBe(200); + }); + test('should return not honest if user is not honest', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { isHonest: false } + }); + const response = await superRequest( + '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.not-honest', + variables: { username: 'foobar' } + } + ] + }); + expect(response.status).toBe(200); + }); + test('should return profile private if profile is private', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { + // All properties need to be defined, as this op SETs `profileUI` + profileUI: { isLocked: true, showTimeLine: true, showCerts: true } + } + }); + const response = await superRequest( + '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.profile-private', + variables: { username: 'foobar' } + } + ] + }); + expect(response.status).toBe(200); + }); + test('should return certs private if certs are private', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { + profileUI: { showCerts: false, showTimeLine: true, isLocked: false } + } + }); + const response = await superRequest( + '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.certs-private', + variables: { username: 'foobar' } + } + ] + }); + expect(response.status).toBe(200); + }); + test('should return timeline private if timeline is private', async () => { + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { + profileUI: { showTimeLine: false, showCerts: true, isLocked: false } + } + }); + const response = await superRequest( + '/certificate/showCert/foobar/javascript-algorithms-and-data-structures', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.timeline-private', + variables: { username: 'foobar' } + } + ] + }); + expect(response.status).toBe(200); + }); + + test('should return cert-not-found if there is no cert with that slug', async () => { + const response = await superRequest( + '/certificate/showCert/foobar/not-a-valid-cert-slug', + { + method: 'GET' + } + ); + expect(response.body).toEqual({ + messages: [ + { + type: 'info', + message: 'flash.cert-not-found', + variables: { certSlug: 'not-a-valid-cert-slug' } + } + ] + }); + expect(response.status).toBe(404); + }); + }); + }); +}); + +const fullStackChallenges = [ + { + completedDate: 1585210952511, + id: '5a553ca864b52e1d8bceea14' + }, + { + completedDate: 1585210952511, + id: '561add10cb82ac38a17513bc' + }, + { + completedDate: 1588665778679, + id: '561acd10cb82ac38a17513bc' + }, + { + completedDate: 1685210952511, + id: '561abd10cb81ac38a17513bc' + }, + { + completedDate: 1585210952511, + id: '561add10cb82ac38a17523bc' + }, + { + completedDate: 1588665778679, + id: '561add10cb82ac38a17213bc' + } +]; + +describe('helper functions', () => { + describe('getFallbackFullStackDate', () => { + it('should return the date of the latest completed challenge', () => { + expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe( + 1685210952511 + ); + }); + + it('should fall back to completedDate if no certifications are provided', () => { + expect(getFallbackFullStackDate([], 123)).toBe(123); + }); + + it('should fall back to completedDate if none of the certifications have been completed', () => { + expect( + getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123) + ).toBe(123); + }); + }); +}); diff --git a/api/src/routes/public/certificate.ts b/api/src/routes/public/certificate.ts new file mode 100644 index 00000000000..df245479ed9 --- /dev/null +++ b/api/src/routes/public/certificate.ts @@ -0,0 +1,238 @@ +import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; + +import { find } from 'lodash'; +import * as schemas from '../../schemas'; +import { + certSlugTypeMap, + certTypeTitleMap, + certTypeIdMap, + completionHours, + oldDataVizId +} from '../../../../shared/config/certification-settings'; +import { + getFallbackFullStackDate, + isKnownCertSlug +} from '../helpers/certificate-utils'; + +/** + * Plugin for the unprotected certificate endpoints. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ +export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + fastify.get( + '/certificate/showCert/:username/:certSlug', + { + schema: schemas.certSlug + }, + async (req, reply) => { + const username = req.params.username.toLowerCase(); + const certSlug = req.params.certSlug; + + fastify.log.info(`certSlug: ${certSlug}`); + + if (!isKnownCertSlug(certSlug)) { + void reply.code(404); + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.cert-not-found', + variables: { certSlug } + } + ] + }); + } + + const certType = certSlugTypeMap[certSlug]; + const certId = certTypeIdMap[certType]; + const certTitle = certTypeTitleMap[certType]; + const completionTime = completionHours[certType] || 300; + const user = await fastify.prisma.user.findFirst({ + where: { username }, + select: { + isBanned: true, + isCheater: true, + isFrontEndCert: true, + isBackEndCert: true, + isFullStackCert: true, + isRespWebDesignCert: true, + isFrontEndLibsCert: true, + isJsAlgoDataStructCert: true, + isJsAlgoDataStructCertV8: true, + isDataVisCert: true, + is2018DataVisCert: true, + isApisMicroservicesCert: true, + isInfosecQaCert: true, + isQaCertV7: true, + isInfosecCertV7: true, + isSciCompPyCertV7: true, + isDataAnalysisPyCertV7: true, + isMachineLearningPyCertV7: true, + isRelationalDatabaseCertV8: true, + isCollegeAlgebraPyCertV8: true, + isFoundationalCSharpCertV8: true, + isUpcomingPythonCertV8: true, + isHonest: true, + username: true, + name: true, + completedChallenges: true, + profileUI: true + } + }); + + if (user === null) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.username-not-found', + variables: { username } + } + ] + }); + } + + if (user.isCheater || user.isBanned) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.not-eligible' + } + ] + }); + } + + if (!user.isHonest) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.not-honest', + variables: { username } + } + ] + }); + } + + if (user.profileUI?.isLocked) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.profile-private', + variables: { username } + } + ] + }); + } + + if (!user.name) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.add-name' + } + ] + }); + } + + if (!user.profileUI?.showCerts) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.certs-private', + variables: { username } + } + ] + }); + } + + if (!user.profileUI?.showTimeLine) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.timeline-private', + variables: { username } + } + ] + }); + } + + if (!user[certType]) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.user-not-certified', + variables: { username, cert: certTypeTitleMap[certType] } + } + ] + }); + } + + const { completedChallenges } = user; + const certChallenge = find( + completedChallenges, + ({ id }) => certId === id + ); + + let { completedDate = Date.now() } = certChallenge || {}; + + // the challenge id has been rotated for isDataVisCert + if (certType === 'isDataVisCert' && !certChallenge) { + const oldDataVisIdChall = find( + completedChallenges, + ({ id }) => oldDataVizId === id + ); + + if (oldDataVisIdChall) { + completedDate = oldDataVisIdChall.completedDate || completedDate; + } + } + + // if fullcert is not found, return the latest completedDate + if (certType === 'isFullStackCert' && !certChallenge) { + completedDate = getFallbackFullStackDate( + completedChallenges, + completedDate + ); + } + + const { name } = user; + + if (!user.profileUI.showName) { + void reply.code(200); + return reply.send({ + certSlug, + certTitle, + username, + date: completedDate, + completionTime + }); + } + + void reply.code(200); + return reply.send({ + certSlug, + certTitle, + username, + name, + date: completedDate, + completionTime + }); + } + ); + + done(); +}; diff --git a/api/src/routes/deprecated-endpoints.test.ts b/api/src/routes/public/deprecated-endpoints.test.ts similarity index 92% rename from api/src/routes/deprecated-endpoints.test.ts rename to api/src/routes/public/deprecated-endpoints.test.ts index d54a17dfb44..2daa8b22da1 100644 --- a/api/src/routes/deprecated-endpoints.test.ts +++ b/api/src/routes/public/deprecated-endpoints.test.ts @@ -1,6 +1,6 @@ import request from 'supertest'; -import { setupServer } from '../../jest.utils'; +import { setupServer } from '../../../jest.utils'; import { endpoints } from './deprecated-endpoints'; describe('Deprecated endpoints', () => { diff --git a/api/src/routes/deprecated-endpoints.ts b/api/src/routes/public/deprecated-endpoints.ts similarity index 96% rename from api/src/routes/deprecated-endpoints.ts rename to api/src/routes/public/deprecated-endpoints.ts index c6035255542..757a140eaa2 100644 --- a/api/src/routes/deprecated-endpoints.ts +++ b/api/src/routes/public/deprecated-endpoints.ts @@ -1,6 +1,6 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; -import * as schemas from '../schemas'; +import * as schemas from '../../schemas'; type Endpoints = [string, 'GET' | 'POST'][]; diff --git a/api/src/routes/deprecated-unsubscribe.test.ts b/api/src/routes/public/deprecated-unsubscribe.test.ts similarity index 93% rename from api/src/routes/deprecated-unsubscribe.test.ts rename to api/src/routes/public/deprecated-unsubscribe.test.ts index c37e2b6009a..e1b421f741b 100644 --- a/api/src/routes/deprecated-unsubscribe.test.ts +++ b/api/src/routes/public/deprecated-unsubscribe.test.ts @@ -1,4 +1,4 @@ -import { setupServer, superRequest } from '../../jest.utils'; +import { setupServer, superRequest } from '../../../jest.utils'; import { unsubscribeEndpoints } from './deprecated-unsubscribe'; diff --git a/api/src/routes/deprecated-unsubscribe.ts b/api/src/routes/public/deprecated-unsubscribe.ts similarity index 95% rename from api/src/routes/deprecated-unsubscribe.ts rename to api/src/routes/public/deprecated-unsubscribe.ts index e4a2693a8b2..5d8a0a65b95 100644 --- a/api/src/routes/deprecated-unsubscribe.ts +++ b/api/src/routes/public/deprecated-unsubscribe.ts @@ -1,6 +1,6 @@ import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; -import { getRedirectParams } from '../utils/redirection'; +import { getRedirectParams } from '../../utils/redirection'; type Endpoint = [string, 'GET' | 'POST']; diff --git a/api/src/routes/public/donate.test.ts b/api/src/routes/public/donate.test.ts new file mode 100644 index 00000000000..a3e6e097e34 --- /dev/null +++ b/api/src/routes/public/donate.test.ts @@ -0,0 +1,139 @@ +import { setupServer, superRequest } from '../../../jest.utils'; + +const testEWalletEmail = 'baz@bar.com'; +const testSubscriptionId = 'sub_test_id'; +const testCustomerId = 'cust_test_id'; + +const sharedDonationReqBody = { + amount: 500, + duration: 'month' +}; +const chargeStripeReqBody = { + email: testEWalletEmail, + subscriptionId: 'sub_test_id', + ...sharedDonationReqBody +}; +const createStripePaymentIntentReqBody = { + email: testEWalletEmail, + name: 'Baz Bar', + token: { id: 'tok_123' }, + ...sharedDonationReqBody +}; +const mockSubCreate = jest.fn(); +const mockAttachPaymentMethod = jest.fn(() => + Promise.resolve({ + id: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF', + object: 'payment_method' + }) +); +const mockCustomerCreate = jest.fn(() => + Promise.resolve({ + id: testCustomerId, + name: 'Jest_User', + currency: 'sgd', + description: 'Jest User Account created' + }) +); +const mockSubRetrieveObj = { + id: testSubscriptionId, + items: { + data: [ + { + plan: { + product: 'prod_GD1GGbJsqQaupl' + } + } + ] + }, + // 1 Jan 2040 + current_period_start: Math.floor(Date.now() / 1000), + customer: testCustomerId, + status: 'active' +}; +const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj)); +const mockCheckoutSessionCreate = jest.fn(() => + Promise.resolve({ id: 'checkout_session_id' }) +); +const mockCustomerUpdate = jest.fn(); +const generateMockSubCreate = (status: string) => () => + Promise.resolve({ + id: testSubscriptionId, + latest_invoice: { + payment_intent: { + client_secret: 'superSecret', + status + } + } + }); +jest.mock('stripe', () => { + return jest.fn().mockImplementation(() => { + return { + customers: { + create: mockCustomerCreate, + update: mockCustomerUpdate + }, + paymentMethods: { + attach: mockAttachPaymentMethod + }, + subscriptions: { + create: mockSubCreate, + retrieve: mockSubRetrieve + }, + checkout: { + sessions: { + create: mockCheckoutSessionCreate + } + } + }; + }); +}); + +describe('Donate', () => { + let setCookies: string[]; + setupServer(); + + describe('Unauthenticated User', () => { + // Get the CSRF cookies from an unprotected route + beforeAll(async () => { + const res = await superRequest('/status/ping', { method: 'GET' }); + setCookies = res.get('Set-Cookie'); + }); + + const endpoints: { path: string; method: 'POST' | 'PUT' }[] = [ + { path: '/donate/add-donation', method: 'POST' }, + { path: '/donate/charge-stripe-card', method: 'POST' }, + { path: '/donate/update-stripe-card', method: 'PUT' } + ]; + + endpoints.forEach(({ path, method }) => { + test(`${method} ${path} returns 401 status code with error message`, async () => { + const response = await superRequest(path, { + method, + setCookies + }); + expect(response.statusCode).toBe(401); + }); + }); + + test('POST /donate/create-stripe-payment-intent should return 200', async () => { + mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors')); + const response = await superRequest( + '/donate/create-stripe-payment-intent', + { + method: 'POST', + setCookies + } + ).send(createStripePaymentIntentReqBody); + expect(response.status).toBe(200); + }); + + test('POST /donate/charge-stripe should return 200', async () => { + mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors')); + const response = await superRequest('/donate/charge-stripe', { + method: 'POST', + setCookies + }).send(chargeStripeReqBody); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/api/src/routes/public/donate.ts b/api/src/routes/public/donate.ts new file mode 100644 index 00000000000..4187674be4d --- /dev/null +++ b/api/src/routes/public/donate.ts @@ -0,0 +1,167 @@ +import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +import Stripe from 'stripe'; + +import { STRIPE_SECRET_KEY } from '../../utils/env'; +import { + donationSubscriptionConfig, + allStripeProductIdsArray +} from '../../../../shared/config/donation-settings'; +import * as schemas from '../../schemas'; +import { inLastFiveMinutes } from '../../utils/validate-donation'; +import { findOrCreateUser } from '../helpers/auth-helpers'; + +/** + * Plugin for public donation endpoints. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ +export const chargeStripeRoute: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + // Stripe plugin + const stripe = new Stripe(STRIPE_SECRET_KEY, { + apiVersion: '2024-06-20', + typescript: true + }); + + fastify.post( + '/donate/create-stripe-payment-intent', + { + schema: schemas.createStripePaymentIntent + }, + async (req, reply) => { + const { email, name, amount, duration } = req.body; + + if (!donationSubscriptionConfig.plans[duration].includes(amount)) { + void reply.code(400); + return { + error: 'The donation form had invalid values for this submission.' + } as const; + } + + try { + const stripeCustomer = await stripe.customers.create({ + email, + name + }); + + const stripeSubscription = await stripe.subscriptions.create({ + customer: stripeCustomer.id, + items: [ + { + plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}` + } + ], + payment_behavior: 'default_incomplete', + payment_settings: { save_default_payment_method: 'on_subscription' }, + expand: ['latest_invoice.payment_intent'] + }); + + if ( + stripeSubscription.latest_invoice && + typeof stripeSubscription.latest_invoice !== 'string' && + stripeSubscription.latest_invoice.payment_intent && + typeof stripeSubscription.latest_invoice.payment_intent !== + 'string' && + stripeSubscription.latest_invoice.payment_intent.client_secret !== + null + ) { + const clientSecret = + stripeSubscription.latest_invoice.payment_intent.client_secret; + return reply.send({ + subscriptionId: stripeSubscription.id, + clientSecret + }); + } else { + throw new Error('Stripe payment intent client secret is missing'); + } + } catch (error) { + fastify.log.error(error); + fastify.Sentry.captureException(error); + void reply.code(500); + return reply.send({ + error: 'Donation failed due to a server error.' + }); + } + } + ); + + fastify.post( + '/donate/charge-stripe', + { + schema: schemas.chargeStripe + }, + async (req, reply) => { + try { + const { email, amount, duration, subscriptionId } = req.body; + + const subscription = + await stripe.subscriptions.retrieve(subscriptionId); + const isSubscriptionActive = subscription.status === 'active'; + const productId = subscription.items.data[0]?.plan.product?.toString(); + const isStartedRecently = inLastFiveMinutes( + subscription.current_period_start + ); + const isProductIdValid = + productId && allStripeProductIdsArray.includes(productId); + const isValidCustomer = typeof subscription.customer === 'string'; + + if (!isSubscriptionActive) + throw new Error( + `Stripe subscription information is invalid: ${subscriptionId}` + ); + if (!isProductIdValid) + throw new Error(`Product ID is invalid: ${subscriptionId}`); + if (!isStartedRecently) + throw new Error(`Subscription is not recent: ${subscriptionId}`); + if (!isValidCustomer) + throw new Error(`Customer ID is invalid: ${subscriptionId}`); + else { + // TODO(Post-MVP) new users should not be created if user is not found + const user = await findOrCreateUser(fastify, email); + const donation = { + userId: user.id, + email, + amount, + duration, + provider: 'stripe', + subscriptionId, + customerId: subscription.customer as string, + // TODO(Post-MVP) migrate to startDate: new Date() + startDate: { + date: new Date().toISOString(), + when: new Date().toISOString().replace(/.$/, '+00:00') + } + }; + + await fastify.prisma.donation.create({ + data: donation + }); + + await fastify.prisma.user.update({ + where: { id: user.id }, + data: { + isDonating: true + } + }); + return reply.send({ + isDonating: true + }); + } + } catch (error) { + fastify.log.error(error); + fastify.Sentry.captureException(error); + void reply.code(500); + return { + error: 'Donation failed due to a server error.' + } as const; + } + } + ); + + done(); +}; diff --git a/api/src/routes/email-subscription.test.ts b/api/src/routes/public/email-subscription.test.ts similarity index 98% rename from api/src/routes/email-subscription.test.ts rename to api/src/routes/public/email-subscription.test.ts index dce1ddd1b33..cd833befc3b 100644 --- a/api/src/routes/email-subscription.test.ts +++ b/api/src/routes/public/email-subscription.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import type { Prisma } from '@prisma/client'; -import { setupServer, superRequest } from '../../jest.utils'; -import { HOME_LOCATION } from '../utils/env'; -import { createUserInput } from '../utils/create-user'; +import { setupServer, superRequest } from '../../../jest.utils'; +import { HOME_LOCATION } from '../../utils/env'; +import { createUserInput } from '../../utils/create-user'; const urlEncodedInfoMessage1 = '?messages=info%5B0%5D%3DWe%2520could%2520not%2520find%2520an%2520account%2520to%2520unsubscribe.'; diff --git a/api/src/routes/email-subscription.ts b/api/src/routes/public/email-subscription.ts similarity index 97% rename from api/src/routes/email-subscription.ts rename to api/src/routes/public/email-subscription.ts index 1b734f86339..2b37e72af8b 100644 --- a/api/src/routes/email-subscription.ts +++ b/api/src/routes/public/email-subscription.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; -import * as schemas from '../schemas'; -import { getRedirectParams } from '../utils/redirection'; +import * as schemas from '../../schemas'; +import { getRedirectParams } from '../../utils/redirection'; /** * Endpoints to set 'sendQuincyEmail' to true or false using 'unsubscribeId'. diff --git a/api/src/routes/public/index.ts b/api/src/routes/public/index.ts new file mode 100644 index 00000000000..95ad20bb343 --- /dev/null +++ b/api/src/routes/public/index.ts @@ -0,0 +1,10 @@ +export * from './auth-dev'; +export * from './auth'; +export * from './certificate'; +export * from './deprecated-endpoints'; +export * from './deprecated-unsubscribe'; +export * from './donate'; +export * from './email-subscription'; +export * from './signout'; +export * from './status'; +export * from './user'; diff --git a/api/src/routes/signout.test.ts b/api/src/routes/public/signout.test.ts similarity index 91% rename from api/src/routes/signout.test.ts rename to api/src/routes/public/signout.test.ts index 9c13a0f3e48..6d601c187ee 100644 --- a/api/src/routes/signout.test.ts +++ b/api/src/routes/public/signout.test.ts @@ -1,5 +1,5 @@ -import { devLogin, setupServer, superRequest } from '../../jest.utils'; -import { HOME_LOCATION } from '../utils/env'; +import { devLogin, setupServer, superRequest } from '../../../jest.utils'; +import { HOME_LOCATION } from '../../utils/env'; describe('GET /signout', () => { setupServer(); diff --git a/api/src/routes/signout.ts b/api/src/routes/public/signout.ts similarity index 91% rename from api/src/routes/signout.ts rename to api/src/routes/public/signout.ts index 5efcdecf0eb..6a96d436eba 100644 --- a/api/src/routes/signout.ts +++ b/api/src/routes/public/signout.ts @@ -1,6 +1,6 @@ import type { FastifyPluginCallback } from 'fastify'; -import { getRedirectParams } from '../utils/redirection'; +import { getRedirectParams } from '../../utils/redirection'; /** * Route handler for signing out. diff --git a/api/src/routes/status.test.ts b/api/src/routes/public/status.test.ts similarity index 82% rename from api/src/routes/status.test.ts rename to api/src/routes/public/status.test.ts index efe7e4ec22e..e6852a695e4 100644 --- a/api/src/routes/status.test.ts +++ b/api/src/routes/public/status.test.ts @@ -1,4 +1,4 @@ -import { setupServer, superRequest } from '../../jest.utils'; +import { setupServer, superRequest } from '../../../jest.utils'; describe('/status', () => { setupServer(); diff --git a/api/src/routes/status.ts b/api/src/routes/public/status.ts similarity index 100% rename from api/src/routes/status.ts rename to api/src/routes/public/status.ts diff --git a/api/src/routes/public/user.test.ts b/api/src/routes/public/user.test.ts new file mode 100644 index 00000000000..5f957fe8eb4 --- /dev/null +++ b/api/src/routes/public/user.test.ts @@ -0,0 +1,614 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import type { Prisma } from '@prisma/client'; +import { ObjectId } from 'mongodb'; +import _ from 'lodash'; + +import { createUserInput } from '../../utils/create-user'; +import { + defaultUserEmail, + setupServer, + createSuperRequest +} from '../../../jest.utils'; +import { getMsTranscriptApiUrl } from '../protected/user'; +import { replacePrivateData } from './user'; + +const mockedFetch = jest.fn(); +jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch); + +// This is used to build a test user. +const testUserData: Prisma.userCreateInput = { + ...createUserInput(defaultUserEmail), + username: 'foobar', + usernameDisplay: 'Foo Bar', + progressTimestamps: [1520002973119, 1520440323273], + completedChallenges: [ + { + id: 'a6b0bb188d873cb2c8729495', + completedDate: 1520002973119, + solution: null, + challengeType: 5, + files: [ + { + contents: 'test', + ext: 'js', + key: 'indexjs', + name: 'test', + path: 'path-test' + }, + { + contents: 'test2', + ext: 'html', + key: 'html-test', + name: 'test2' + } + ] + }, + { + id: 'a5229172f011153519423690', + completedDate: 1520440323273, + solution: null, + challengeType: 5, + files: [] + }, + { + id: 'a5229172f011153519423692', + completedDate: 1520440323274, + githubLink: '', + challengeType: 5, + examResults: { + numberOfCorrectAnswers: 0, + numberOfQuestionsInExam: 0, + percentCorrect: 0, + passingPercent: 0, + passed: false, + examTimeInSeconds: 0 + } + } + ], + partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }], + completedExams: [], + githubProfile: 'github.com/foobar', + website: 'https://www.freecodecamp.org', + donationEmails: ['an@add.ress'], + portfolio: [ + { + description: 'A portfolio', + id: 'a6b0bb188d873cb2c8729495', + image: 'https://www.freecodecamp.org/cat.png', + title: 'A portfolio', + url: 'https://www.freecodecamp.org' + } + ], + savedChallenges: [ + { + id: 'a6b0bb188d873cb2c8729495', + lastSavedDate: 123, + files: [ + { + contents: 'test-contents', + ext: 'js', + history: ['indexjs'], + key: 'indexjs', + name: 'test-name' + } + ] + } + ], + yearsTopContributor: ['2018'], + twitter: '@foobar', + linkedin: 'linkedin.com/foobar' +}; + +const minimalUserData: Prisma.userCreateInput = { + about: 'I am a test user', + acceptedPrivacyTerms: true, + email: testUserData.email, + emailVerified: true, + externalId: '1234567890', + isDonating: false, + picture: 'https://www.freecodecamp.org/cat.png', + sendQuincyEmail: true, + username: 'testuser', + unsubscribeId: '1234567890' +}; + +const lockedProfileUI = { + isLocked: true, + showAbout: false, + showCerts: false, + showDonation: false, + showHeatMap: false, + showLocation: false, + showName: false, + showPoints: false, + showPortfolio: false, + showTimeLine: false +}; + +const publicUserData = { + about: testUserData.about, + calendar: { 1520002973: 1, 1520440323: 1 }, + // testUserData.completedChallenges, with nulls removed + completedChallenges: [ + { + id: 'a6b0bb188d873cb2c8729495', + completedDate: 1520002973119, + challengeType: 5, + files: [ + { + contents: 'test', + ext: 'js', + key: 'indexjs', + name: 'test', + path: 'path-test' + }, + { + contents: 'test2', + ext: 'html', + key: 'html-test', + name: 'test2' + } + ] + }, + { + id: 'a5229172f011153519423690', + completedDate: 1520440323273, + challengeType: 5, + files: [] + }, + { + id: 'a5229172f011153519423692', + completedDate: 1520440323274, + githubLink: '', + challengeType: 5, + files: [], + examResults: { + numberOfCorrectAnswers: 0, + numberOfQuestionsInExam: 0, + percentCorrect: 0, + passingPercent: 0, + passed: false, + examTimeInSeconds: 0 + } + } + ], + completedExams: testUserData.completedExams, + completedSurveys: [], // TODO: add surveys + githubProfile: testUserData.githubProfile, + is2018DataVisCert: testUserData.is2018DataVisCert, + is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment. + isApisMicroservicesCert: testUserData.isApisMicroservicesCert, + isBackEndCert: testUserData.isBackEndCert, + isCheater: testUserData.isCheater, + isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8, + isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7, + isDataVisCert: testUserData.isDataVisCert, + isDonating: testUserData.isDonating, + isFoundationalCSharpCertV8: testUserData.isFoundationalCSharpCertV8, + isFrontEndCert: testUserData.isFrontEndCert, + isFrontEndLibsCert: testUserData.isFrontEndLibsCert, + isFullStackCert: testUserData.isFullStackCert, + isHonest: testUserData.isHonest, + isInfosecCertV7: testUserData.isInfosecCertV7, + isInfosecQaCert: testUserData.isInfosecQaCert, + isJsAlgoDataStructCert: testUserData.isJsAlgoDataStructCert, + isJsAlgoDataStructCertV8: testUserData.isJsAlgoDataStructCertV8, + isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7, + isQaCertV7: testUserData.isQaCertV7, + isRelationalDatabaseCertV8: testUserData.isRelationalDatabaseCertV8, + isRespWebDesignCert: testUserData.isRespWebDesignCert, + isSciCompPyCertV7: testUserData.isSciCompPyCertV7, + linkedin: testUserData.linkedin, + location: testUserData.location, + name: testUserData.name, + partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }], + picture: testUserData.picture, + points: 2, + portfolio: testUserData.portfolio, + profileUI: testUserData.profileUI, + savedChallenges: testUserData.savedChallenges, + twitter: 'https://twitter.com/foobar', + username: testUserData.usernameDisplay, // It defaults to usernameDisplay + website: testUserData.website, + yearsTopContributor: testUserData.yearsTopContributor +}; + +describe('userRoutes', () => { + setupServer(); + + describe('Public', () => { + let superGet: ReturnType; + + beforeEach(() => { + superGet = createSuperRequest({ method: 'GET' }); + }); + + describe('/api/users/get-public-profile', () => { + const profilelessUsername = 'profileless-user'; + const lockedUsername = 'locked-user'; + const publicUsername = 'public-user'; + const lockedUserProfileUI = { + isLocked: true, + showAbout: true, + showPortfolio: false + }; + const unlockedUserProfileUI = { + isLocked: false, + showAbout: true, + showCerts: true, + showDonation: true, + showHeatMap: true, + showLocation: true, + showName: true, + showPoints: true, + showPortfolio: true, + showTimeLine: true + }; + const users = [profilelessUsername, lockedUsername, publicUsername]; + beforeAll(async () => { + await fastifyTestInstance.prisma.user.create({ + data: { + ...minimalUserData, + email: profilelessUsername, + username: profilelessUsername + } + }); + await fastifyTestInstance.prisma.user.create({ + data: { + ...minimalUserData, + email: lockedUsername, + username: lockedUsername, + profileUI: lockedUserProfileUI + } + }); + await fastifyTestInstance.prisma.user.create({ + data: { + ...testUserData, + email: publicUsername, + username: publicUsername, + profileUI: unlockedUserProfileUI + } + }); + }); + + afterAll(async () => { + await fastifyTestInstance.prisma.user.deleteMany({ + where: { + OR: users.map(username => ({ username })) + } + }); + }); + + describe('GET', () => { + test('returns 400 status code if the user agent is blocked', async () => { + const response = await superGet( + '/api/users/get-public-profile?username=public-user' + ).set('User-Agent', 'curl'); + + expect(response.text).toBe( + 'This endpoint is no longer available outside of the freeCodeCamp ecosystem' + ); + expect(response.statusCode).toBe(400); + }); + + test('returns 400 status code if the username param is missing', async () => { + const res = await superGet('/api/users/get-public-profile'); + // TODO(Post-MVP): return something more informative + expect(res.body).toStrictEqual({}); + expect(res.statusCode).toBe(400); + }); + + test('returns 400 status code if the username param is empty', async () => { + const res = await superGet('/api/users/get-public-profile?username='); + // TODO(Post-MVP): return something more informative + expect(res.body).toStrictEqual({}); + expect(res.statusCode).toBe(400); + }); + + test('returns 404 status code for non-existent user', async () => { + const response = await superGet( + '/api/users/get-public-profile?username=non-existent' + ); + // TODO(Post-MVP): return something more informative + expect(response.body).toStrictEqual({}); + expect(response.statusCode).toBe(404); + }); + + test('returns 200 status code with a locked profile if the profile is private', async () => { + const response = await superGet( + `/api/users/get-public-profile?username=${lockedUsername}` + ); + + expect(response.body).toStrictEqual({ + entities: { + user: { + [lockedUsername]: { + isLocked: true, + profileUI: lockedUserProfileUI, + username: lockedUsername + } + } + }, + result: lockedUsername + }); + expect(response.statusCode).toBe(200); + }); + + test('returns 200 status code locked profile if the profile is missing', async () => { + const response = await superGet( + `/api/users/get-public-profile?username=${profilelessUsername}` + ); + + expect(response.body).toStrictEqual({ + entities: { + user: { + [profilelessUsername]: { + isLocked: true, + profileUI: lockedProfileUI, + username: profilelessUsername + } + } + }, + result: profilelessUsername + }); + expect(response.statusCode).toBe(200); + }); + // TODO: create a list of public properties like the api-server and use that + // to restrict the output of this and get-session-user. + test('returns 200 status code with public user object', async () => { + const testUser = + await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: publicUsername } + }); + const response = await superGet( + `/api/users/get-public-profile?username=${publicUsername}` + ); + + // TODO: create a fixture for this without 'completedSurveys', ideally + // it should contain the entire body. + const publicUser = { + // TODO(Post-MVP, maybe): return completedSurveys? + ..._.omit(publicUserData, 'completedSurveys'), + username: publicUsername, + joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(), + profileUI: unlockedUserProfileUI + }; + + expect(response.body).toStrictEqual({ + entities: { + user: { + [publicUsername]: publicUser + } + }, + result: publicUsername + }); + expect(response.statusCode).toBe(200); + }); + }); + }); + describe('GET /api/users/exists', () => { + beforeAll(async () => { + await fastifyTestInstance.prisma.user.create({ + data: minimalUserData + }); + }); + + it('should return { exists: true } with a 400 status code if the username param is missing or empty', async () => { + const res = await superGet('/api/users/exists'); + + expect(res.body).toStrictEqual({ exists: true }); + expect(res.statusCode).toBe(400); + + const res2 = await superGet('/api/users/exists?username='); + + expect(res2.body).toStrictEqual({ exists: true }); + expect(res2.statusCode).toBe(400); + }); + + it('should return { exists: true } if the username exists', async () => { + const res = await superGet('/api/users/exists?username=testuser'); + + expect(res.body).toStrictEqual({ exists: true }); + expect(res.statusCode).toBe(200); + }); + + it('should ignore case when checking for username existence', async () => { + const res = await superGet('/api/users/exists?username=TeStUsEr'); + + expect(res.body).toStrictEqual({ exists: true }); + expect(res.statusCode).toBe(200); + }); + + it('should return { exists: false } if the username does not exist', async () => { + const res = await superGet('/api/users/exists?username=nonexistent'); + + expect(res.body).toStrictEqual({ exists: false }); + expect(res.statusCode).toBe(200); + }); + + it('should return { exists: true } if the username is restricted (ignoring case)', async () => { + const res = await superGet('/api/users/exists?username=pRofIle'); + + expect(res.body).toStrictEqual({ exists: true }); + + const res2 = await superGet('/api/users/exists?username=flAnge'); + + expect(res2.body).toStrictEqual({ exists: true }); + }); + }); + }); +}); + +describe('Microsoft helpers', () => { + describe('getMsTranscriptApiUrl', () => { + const expectedUrl = + 'https://learn.microsoft.com/api/profiles/transcript/share/8u6awert43q1plo'; + + const urlWithoutSlash = + 'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo'; + const urlWithSlash = `${urlWithoutSlash}/`; + const urlWithQueryParams = `${urlWithoutSlash}?foo=bar`; + const urlWithQueryParamsAndSlash = `${urlWithSlash}?foo=bar`; + + it('should extract the transcript id from the url', () => { + expect(getMsTranscriptApiUrl(urlWithoutSlash)).toBe(expectedUrl); + }); + + it('should handle trailing slashes', () => { + expect(getMsTranscriptApiUrl(urlWithSlash)).toBe(expectedUrl); + }); + + it('should ignore query params', () => { + expect(getMsTranscriptApiUrl(urlWithQueryParams)).toBe(expectedUrl); + expect(getMsTranscriptApiUrl(urlWithQueryParamsAndSlash)).toBe( + expectedUrl + ); + }); + }); +}); + +describe('get-public-profile helpers', () => { + describe('replacePrivateData', () => { + const user = { + about: 'about', + calendar: { 1: 1, 2: 1 } as const, + completedChallenges: [ + { id: '123', completedDate: 123, files: [] }, + { id: '456', completedDate: 456, challengeType: 7, files: [] } + ], + id: '5f5b1b3b1c9d440000d9e3b4', + isDonating: false, + location: 'location', + joinDate: 'joinDate', + name: 'name', + points: 2, + portfolio: [ + { + id: '789', + title: 'portfolio', + url: 'url', + image: 'image', + description: 'description' + } + ], + profileUI: { + isLocked: false, + showAbout: true, + showCerts: true, + showDonation: true, + showHeatMap: true, + showLocation: true, + showName: true, + showPoints: true, + showPortfolio: true, + showTimeLine: true + } + }; + + test(`returns "" for 'about' if showAbout is not true`, () => { + const userWithoutAbout = { + ...user, + profileUI: { ...user.profileUI, showAbout: false } + }; + expect(replacePrivateData(userWithoutAbout)).toMatchObject({ + about: '' + }); + }); + + test('returns {} for calendar if showHeatMap is not true', () => { + const userWithoutHeatMap = { + ...user, + profileUI: { ...user.profileUI, showHeatMap: false } + }; + expect(replacePrivateData(userWithoutHeatMap).calendar).toEqual({}); + }); + + test(`returns [] for completeChallenges if showTimeLine is not true`, () => { + const userWithoutTimeLine = { + ...user, + profileUI: { ...user.profileUI, showTimeLine: false } + }; + expect(replacePrivateData(userWithoutTimeLine)).toMatchObject({ + completedChallenges: [] + }); + }); + + test('omits certifications from completedChallenges if showCerts is not true', () => { + const userWithoutCerts = { + ...user, + profileUI: { ...user.profileUI, showCerts: false } + }; + expect(replacePrivateData(userWithoutCerts)).toMatchObject({ + completedChallenges: [{ id: '123', completedDate: 123, files: [] }] + }); + }); + + test('returns null for isDonating if showDonation is not true', () => { + const userWithoutDonation = { + ...user, + profileUI: { ...user.profileUI, showDonation: false } + }; + expect(replacePrivateData(userWithoutDonation)).toMatchObject({ + isDonating: null + }); + }); + + test('returns "" for joinDate if showAbout is not true', () => { + const userWithoutAbout = { + ...user, + profileUI: { ...user.profileUI, showAbout: false } + }; + expect(replacePrivateData(userWithoutAbout)).toMatchObject({ + joinDate: '' + }); + }); + + test(`returns "" for 'location' if showLocation is not true`, () => { + const userWithoutLocation = { + ...user, + profileUI: { ...user.profileUI, showLocation: false } + }; + expect(replacePrivateData(userWithoutLocation)).toMatchObject({ + location: '' + }); + }); + + test(`returns "" for 'name' if showName is not true`, () => { + const userWithoutName = { + ...user, + profileUI: { ...user.profileUI, showName: false } + }; + expect(replacePrivateData(userWithoutName)).toMatchObject({ + name: '' + }); + }); + + test('returns null for points if showPoints is not true', () => { + const userWithoutPoints = { + ...user, + profileUI: { ...user.profileUI, showPoints: false } + }; + expect(replacePrivateData(userWithoutPoints)).toMatchObject({ + points: null + }); + }); + + test('returns [] for portfolio if showPortfolio is not true', () => { + const userWithoutPortfolio = { + ...user, + profileUI: { ...user.profileUI, showPortfolio: false } + }; + expect(replacePrivateData(userWithoutPortfolio)).toMatchObject({ + portfolio: [] + }); + }); + + test('returns the expected public user object if all showX flags are true', () => { + expect(replacePrivateData(user)).toEqual( + _.omit(user, ['id', 'profileUI']) + ); + }); + }); +}); diff --git a/api/src/routes/public/user.ts b/api/src/routes/public/user.ts new file mode 100644 index 00000000000..883039a0425 --- /dev/null +++ b/api/src/routes/public/user.ts @@ -0,0 +1,240 @@ +import { Portfolio } from '@prisma/client'; +import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +import { ObjectId } from 'mongodb'; +import _ from 'lodash'; + +import { isRestricted } from '../helpers/is-restricted'; +import * as schemas from '../../schemas'; +import { splitUser } from '../helpers/user-utils'; +import { + normalizeChallenges, + NormalizedChallenge, + normalizeFlags, + normalizeProfileUI, + normalizeTwitter, + removeNulls +} from '../../utils/normalize'; +import { + Calendar, + getCalendar, + getPoints, + ProgressTimestamp +} from '../../utils/progress'; +import { challengeTypes } from '../../../../shared/config/challenge-types'; + +type ProfileUI = Partial<{ + isLocked: boolean; + showAbout: boolean; + showCerts: boolean; + showDonation: boolean; + showHeatMap: boolean; + showLocation: boolean; + showName: boolean; + showPoints: boolean; + showPortfolio: boolean; + showTimeLine: boolean; +}>; + +type RawUser = { + about: string; + completedChallenges: NormalizedChallenge[]; + calendar: Calendar; + id: string; + isDonating: boolean; + joinDate: string; + location: string; + name: string; + points: number; + portfolio: Portfolio[]; + profileUI: ProfileUI; +}; + +/** + * Creates an object with the properties that are shared with the public. + * @param user The raw user object. + * @returns The shared user object. + */ +export const replacePrivateData = (user: RawUser) => { + const { + showAbout, + showHeatMap, + showCerts, + showDonation, + showLocation, + showName, + showPoints, + showPortfolio, + showTimeLine + } = user.profileUI; + + return { + about: showAbout ? user.about : '', + calendar: showHeatMap ? user.calendar : {}, + completedChallenges: showTimeLine + ? showCerts + ? user.completedChallenges + : user.completedChallenges.filter( + c => c.challengeType !== challengeTypes.step + ) + : [], + isDonating: showDonation ? user.isDonating : null, + joinDate: showAbout ? user.joinDate : '', + location: showLocation ? user.location : '', + name: showName ? user.name : '', + points: showPoints ? user.points : null, + portfolio: showPortfolio ? user.portfolio : [] + }; +}; + +const blockedUserAgentParts = ['python', 'google-apps-script', 'curl']; +/** + * Plugin containing public GET routes for user account management. They are kept + * separate because they do not require CSRF protection or authorization. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done Callback to signal that the logic has completed. + */ +export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + fastify.get( + '/api/users/get-public-profile', + { + schema: schemas.getPublicProfile, + onRequest: (req, reply, done) => { + const userAgent = req.headers['user-agent']; + + if ( + userAgent && + blockedUserAgentParts.some(ua => userAgent.toLowerCase().includes(ua)) + ) { + void reply.code(400); + void reply.send( + 'This endpoint is no longer available outside of the freeCodeCamp ecosystem' + ); + } + done(); + } + }, + async (req, reply) => { + // TODO(Post-MVP): look for duplicates unless we can make username unique in the db. + const user = await fastify.prisma.user.findFirst({ + where: { username: req.query.username } + // TODO: only select desired fields, then stop 'omit'ing the undesired + // ones. + }); + + if (!user) { + void reply.code(404); + return reply.send({}); + } + + const [flags, rest] = splitUser(user); + + const publicUser = _.omit(rest, [ + 'currentChallengeId', + 'email', + 'emailVerified', + 'sendQuincyEmail', + 'theme', + // keyboardShortcuts is included in flags. + // 'keyboardShortcuts', + 'acceptedPrivacyTerms', + 'progressTimestamps', + 'unsubscribeId', + 'donationEmails', + 'externalId', + 'usernameDisplay', + 'isBanned' + ]); + + const normalizedProfileUI = normalizeProfileUI(user.profileUI); + + void reply.code(200); + if (normalizedProfileUI.isLocked) { + // TODO(Post-MVP): just return isLocked: true and either a null user + // or no user at all. (see other TODO in the else branch below) + return reply.send({ + entities: { + user: { + [user.username]: { + isLocked: true, + profileUI: normalizedProfileUI, + username: user.username + } + } + }, + result: user.username + }); + } else { + const progressTimestamps = user.progressTimestamps as + | ProgressTimestamp[] + | null; + const sharedUser = replacePrivateData({ + ...user, + calendar: getCalendar(progressTimestamps), + completedChallenges: normalizeChallenges(user.completedChallenges), + location: user.location ?? '', + joinDate: new ObjectId(user.id).getTimestamp().toISOString(), + name: user.name ?? '', + points: getPoints(progressTimestamps), + profileUI: normalizedProfileUI + }); + + const returnedUser = { + ...removeNulls(publicUser), + ...normalizeFlags(flags), + ...sharedUser, + profileUI: normalizedProfileUI, + // TODO: should this always be returned? Shouldn't some privacy + // setting control it? Same applies to website, githubProfile, + // and linkedin. + twitter: normalizeTwitter(user.twitter), + yearsTopContributor: user.yearsTopContributor + }; + return reply.send({ + // TODO(Post-MVP): just return a user object (i.e. returnedUser) and + // isLocked: false. The there should be no need for Type.Union in the + // schema. Alternatively, have the user object be nullable and don't + // bother with isLocked. + entities: { + user: { [user.username]: returnedUser } + }, + result: user.username + }); + } + } + ); + + fastify.get( + '/api/users/exists', + { + schema: schemas.userExists, + attachValidation: true + }, + async (req, reply) => { + if (req.validationError) { + void reply.code(400); + // TODO(Post-MVP): return a message telling the requester that their + // request was malformed. + return await reply.send({ exists: true }); + } + + const username = req.query.username.toLowerCase(); + + if (isRestricted(username)) return await reply.send({ exists: true }); + + const exists = + (await fastify.prisma.user.count({ + where: { username } + })) > 0; + + await reply.send({ exists }); + } + ); + + done(); +};