From 8cd2efe570a31ef9b9f3e0e779bd066656852003 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Tue, 2 Sep 2025 16:20:48 +0200 Subject: [PATCH] feat: base64 encode file contents when making api requests (#62006) Co-authored-by: Shaun Hamilton --- .../routes/helpers/challenge-helpers.test.ts | 84 +++- api/src/routes/helpers/challenge-helpers.ts | 33 ++ api/src/routes/protected/challenge.test.ts | 443 +++++++++++++++--- api/src/routes/protected/challenge.ts | 245 +++++++--- .../Challenges/redux/completion-epic.js | 2 +- client/src/utils/ajax.ts | 2 +- client/src/utils/challenge-request-helpers.ts | 10 +- 7 files changed, 675 insertions(+), 144 deletions(-) diff --git a/api/src/routes/helpers/challenge-helpers.test.ts b/api/src/routes/helpers/challenge-helpers.test.ts index 166fc327b26..65ce015d0cc 100644 --- a/api/src/routes/helpers/challenge-helpers.test.ts +++ b/api/src/routes/helpers/challenge-helpers.test.ts @@ -7,7 +7,10 @@ import type { import { createFetchMock } from '../../../vitest.utils'; import { canSubmitCodeRoadCertProject, - verifyTrophyWithMicrosoft + verifyTrophyWithMicrosoft, + decodeFiles, + decodeBase64, + encodeBase64 } from './challenge-helpers'; const id = 'abc'; @@ -166,4 +169,83 @@ describe('Challenge Helpers', () => { }); }); }); + + describe('decodeFiles', () => { + test('decodes base64 encoded file contents', () => { + const encodedFiles = [ + { + contents: btoa('console.log("Hello, world!");') + }, + { + contents: btoa('

Hello, world!

') + } + ]; + + const decodedFiles = decodeFiles(encodedFiles); + + expect(decodedFiles).toEqual([ + { + contents: 'console.log("Hello, world!");' + }, + { + contents: '

Hello, world!

' + } + ]); + }); + + test('leaves all other file properties unchanged', () => { + const encodedFiles = [ + { + contents: btoa('console.log("Hello, world!");'), + ext: '.js', + history: [], + key: 'file1', + name: 'hello.js' + } + ]; + + const decodedFiles = decodeFiles(encodedFiles); + + expect(decodedFiles).toEqual([ + { + contents: 'console.log("Hello, world!");', + ext: '.js', + history: [], + key: 'file1', + name: 'hello.js' + } + ]); + }); + + test('can handle unicode characters', () => { + const encodedFiles = [ + { + contents: encodeBase64('console.log("Hello, ✅🚀!");') + } + ]; + + const decodedFiles = decodeFiles(encodedFiles); + + expect(decodedFiles).toEqual([ + { + contents: 'console.log("Hello, ✅🚀!");' + } + ]); + }); + }); + + describe('decodeBase64', () => { + test('decodes a base64 encoded string', () => { + const encoded = encodeBase64('Hello, world!'); + const decoded = decodeBase64(encoded); + expect(decoded).toBe('Hello, world!'); + }); + + test('can handle unicode characters', () => { + const original = 'Hello, ✅🚀!'; + const encoded = encodeBase64(original); + const decoded = decodeBase64(encoded); + expect(decoded).toBe(original); + }); + }); }); diff --git a/api/src/routes/helpers/challenge-helpers.ts b/api/src/routes/helpers/challenge-helpers.ts index 0cb3e459601..b71e81ddd8f 100644 --- a/api/src/routes/helpers/challenge-helpers.ts +++ b/api/src/routes/helpers/challenge-helpers.ts @@ -140,3 +140,36 @@ export async function verifyTrophyWithMicrosoft({ } as NoTrophyError; } } + +/** + * Generic helper to decode an array of base64 encoded file objects. + * + * @param files Array of file-like objects each having a base64 encoded `contents` string. + * @returns The same array shape with `contents` decoded. + */ +export function decodeFiles(files: T[]): T[] { + return files.map(file => ({ + ...file, + contents: decodeBase64(file.contents) + })); +} + +/** + * Decodes a base64 encoded string into a UTF-8 string. + * + * @param str The base64 encoded string to decode. + * @returns The decoded UTF-8 string. + */ +export function decodeBase64(str: string): string { + return Buffer.from(str, 'base64').toString('utf-8'); +} + +/** + * Encodes a UTF-8 string into a base64 encoded string. + * + * @param str The UTF-8 string to encode. + * @returns The base64 encoded string. + */ +export function encodeBase64(str: string): string { + return Buffer.from(str, 'utf8').toString('base64'); +} diff --git a/api/src/routes/protected/challenge.test.ts b/api/src/routes/protected/challenge.test.ts index 10ccf842ec4..d3dd44c7604 100644 --- a/api/src/routes/protected/challenge.test.ts +++ b/api/src/routes/protected/challenge.test.ts @@ -105,60 +105,105 @@ const HtmlChallengeBody = { challengeType: challengeTypes.html, id: HtmlChallengeId }; -const JsProjectBody = { + +const baseJsProjectBody = { challengeType: challengeTypes.jsProject, - id: JsProjectId, - files: [ - { - contents: 'console.log("Hello There!")', - key: 'scriptjs', - ext: 'js', - name: 'script', - history: ['script.js'] - } - ] + id: JsProjectId }; -const multiFileCertProjectBody = { + +const jsFiles = [ + { + contents: 'console.log("Hello There!")', + key: 'scriptjs', + ext: 'js', + name: 'script', + history: ['script.js'] + } +]; + +const encodedJsFiles = [ + { + contents: btoa('console.log("Hello There!")'), + key: 'scriptjs', + ext: 'js', + name: 'script', + history: ['script.js'] + } +]; + +const baseMultiFileCertProjectBody = { challengeType: challengeTypes.multifileCertProject, - id: multiFileCertProjectId, - files: [ - { - contents: '

Multi File Project v1

', - key: 'indexhtml', - ext: 'html', - name: 'index', - history: ['index.html'] - }, - { - contents: '.hello-there { general: kenobi; }', - key: 'stylescss', - ext: 'css', - name: 'styles', - history: ['styles.css'] - } - ] -}; -const updatedMultiFileCertProjectBody = { - challengeType: challengeTypes.multifileCertProject, - id: multiFileCertProjectId, - files: [ - { - contents: '

Multi File Project v2

', - key: 'indexhtml', - ext: 'html', - name: 'index', - history: ['index.html'] - }, - { - contents: '.wibbly-wobbly { timey: wimey; }', - key: 'stylescss', - ext: 'css', - name: 'styles', - history: ['styles.css'] - } - ] + id: multiFileCertProjectId }; +const multiFiles = [ + { + contents: '

Multi File Project v1

', + key: 'indexhtml', + ext: 'html', + name: 'index', + history: ['index.html'] + }, + { + contents: '.hello-there { general: kenobi; }', + key: 'stylescss', + ext: 'css', + name: 'styles', + history: ['styles.css'] + } +]; + +const updatedMultiFiles = [ + { + contents: '

Multi File Project v2

', + key: 'indexhtml', + ext: 'html', + name: 'index', + history: ['index.html'] + }, + { + contents: '.wibbly-wobbly { timey: wimey; }', + key: 'stylescss', + ext: 'css', + name: 'styles', + history: ['styles.css'] + } +]; + +const encodedMultiFiles = [ + { + contents: btoa('

Multi File Project v1

'), + key: 'indexhtml', + ext: 'html', + name: 'index', + history: ['index.html'] + }, + { + contents: btoa('.hello-there { general: kenobi; }'), + key: 'stylescss', + ext: 'css', + name: 'styles', + history: ['styles.css'] + } +]; + +const encodedUpdatedMultiFiles = [ + { + contents: btoa('

Multi File Project v2

'), + key: 'indexhtml', + ext: 'html', + name: 'index', + history: ['index.html'] + }, + { + contents: btoa('.wibbly-wobbly { timey: wimey; }'), + key: 'stylescss', + ext: 'css', + name: 'styles', + history: ['styles.css'] + } +]; + const dailyCodingChallengeId = '5900f36e1000cf542c50fe80'; const dailyCodingChallengeBody = { id: dailyCodingChallengeId, @@ -755,6 +800,16 @@ describe('challengeRoutes', () => { }); describe('handling', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + completedChallenges: [], + savedChallenges: [], + progressTimestamps: [] + } + }); + }); afterEach(async () => { await fastifyTestInstance.prisma.user.updateMany({ where: { email: 'foo@bar.com' }, @@ -804,21 +859,22 @@ describe('challengeRoutes', () => { test('POST accepts challenges with files present', async () => { const now = Date.now(); - const response = await superPost('/modern-challenge-completed').send( - JsProjectBody - ); + const response = await superPost('/modern-challenge-completed').send({ + ...baseJsProjectBody, + files: jsFiles + }); const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ where: { email: 'foo@bar.com' } }); - const file = omit(JsProjectBody.files[0], 'history'); + const file = omit(jsFiles[0], 'history'); expect(user).toMatchObject({ completedChallenges: [ { id: JsProjectId, - challengeType: JsProjectBody.challengeType, + challengeType: baseJsProjectBody.challengeType, files: [file], completedDate: expect.any(Number) } @@ -841,15 +897,16 @@ describe('challengeRoutes', () => { test('POST accepts challenges with saved solutions', async () => { const now = Date.now(); - const response = await superPost('/modern-challenge-completed').send( - multiFileCertProjectBody - ); + const response = await superPost('/modern-challenge-completed').send({ + ...baseMultiFileCertProjectBody, + files: multiFiles + }); const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ where: { email: 'foo@bar.com' } }); - const testFiles = multiFileCertProjectBody.files.map( + const testFiles = multiFiles.map( ({ history: _history, ...rest }) => rest ); @@ -858,7 +915,7 @@ describe('challengeRoutes', () => { completedChallenges: [ { id: multiFileCertProjectId, - challengeType: multiFileCertProjectBody.challengeType, + challengeType: baseMultiFileCertProjectBody.challengeType, files: testFiles, completedDate: expect.any(Number), isManuallyApproved: false @@ -868,7 +925,7 @@ describe('challengeRoutes', () => { { id: multiFileCertProjectId, lastSavedDate: expect.any(Number), - files: multiFileCertProjectBody.files + files: multiFiles } ] }); @@ -885,7 +942,7 @@ describe('challengeRoutes', () => { { id: multiFileCertProjectId, lastSavedDate: completedDate, - files: multiFileCertProjectBody.files + files: multiFiles } ] }); @@ -895,14 +952,14 @@ describe('challengeRoutes', () => { test('POST correctly handles multiple requests', async () => { const resOriginal = await superPost( '/modern-challenge-completed' - ).send(multiFileCertProjectBody); + ).send({ ...baseMultiFileCertProjectBody, files: multiFiles }); await superPost('/modern-challenge-completed').send( HtmlChallengeBody ); const resUpdate = await superPost('/modern-challenge-completed').send( - updatedMultiFileCertProjectBody + { ...baseMultiFileCertProjectBody, files: updatedMultiFiles } ); const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ @@ -913,7 +970,7 @@ describe('challengeRoutes', () => { challenge => challenge.completedDate ); - const testFiles = updatedMultiFileCertProjectBody.files.map(file => + const testFiles = updatedMultiFiles.map(file => omit(file, 'history') ); @@ -922,7 +979,7 @@ describe('challengeRoutes', () => { completedChallenges: [ { id: multiFileCertProjectId, - challengeType: updatedMultiFileCertProjectBody.challengeType, + challengeType: baseMultiFileCertProjectBody.challengeType, files: testFiles, completedDate: expect.any(Number), isManuallyApproved: false @@ -936,7 +993,7 @@ describe('challengeRoutes', () => { { id: multiFileCertProjectId, lastSavedDate: expect.any(Number), - files: updatedMultiFileCertProjectBody.files + files: updatedMultiFiles } ], progressTimestamps: expectedProgressTimestamps @@ -957,7 +1014,7 @@ describe('challengeRoutes', () => { { id: multiFileCertProjectId, lastSavedDate: expect.any(Number), - files: updatedMultiFileCertProjectBody.files + files: updatedMultiFiles } ] }); @@ -966,6 +1023,197 @@ describe('challengeRoutes', () => { }); }); + describe('/encoded/modern-challenge-completed', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + completedChallenges: [], + savedChallenges: [], + progressTimestamps: [] + } + }); + }); + afterEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + completedChallenges: [], + savedChallenges: [], + progressTimestamps: [] + } + }); + }); + // JS Project(5), Multi-file Cert Project(14) + test('POST accepts challenges with files present', async () => { + const now = Date.now(); + + const response = await superPost( + '/encoded/modern-challenge-completed' + ).send({ ...baseJsProjectBody, files: encodedJsFiles }); + + const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + const file = omit(jsFiles[0], 'history'); + + expect(user).toMatchObject({ + completedChallenges: [ + { + id: JsProjectId, + challengeType: baseJsProjectBody.challengeType, + files: [file], + completedDate: expect.any(Number) + } + ] + }); + + const completedDate = user.completedChallenges[0]?.completedDate; + expect(completedDate).toBeGreaterThanOrEqual(now); + expect(completedDate).toBeLessThanOrEqual(now + 1000); + + expect(response.body).toStrictEqual({ + alreadyCompleted: false, + points: 1, + completedDate, + savedChallenges: [] + }); + expect(response.statusCode).toBe(200); + }); + + test('POST accepts challenges with saved solutions', async () => { + const now = Date.now(); + + const response = await superPost( + '/encoded/modern-challenge-completed' + ).send({ + ...baseMultiFileCertProjectBody, + files: encodedUpdatedMultiFiles + }); + + const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + const testFiles = updatedMultiFiles.map( + ({ history: _history, ...rest }) => rest + ); + + expect(user).toMatchObject({ + needsModeration: true, + completedChallenges: [ + { + id: multiFileCertProjectId, + challengeType: baseMultiFileCertProjectBody.challengeType, + files: testFiles, + completedDate: expect.any(Number), + isManuallyApproved: false + } + ], + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: expect.any(Number), + files: updatedMultiFiles + } + ] + }); + + const completedDate = user.completedChallenges[0]?.completedDate; + expect(completedDate).toBeGreaterThanOrEqual(now); + expect(completedDate).toBeLessThanOrEqual(now + 1000); + + expect(response.body).toStrictEqual({ + alreadyCompleted: false, + points: 1, + completedDate, + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: completedDate, + files: updatedMultiFiles + } + ] + }); + expect(response.statusCode).toBe(200); + }); + + test('POST correctly handles multiple requests', async () => { + const resOriginal = await superPost( + '/encoded/modern-challenge-completed' + ).send({ + ...baseMultiFileCertProjectBody, + files: encodedMultiFiles + }); + + await superPost('/encoded/modern-challenge-completed').send( + HtmlChallengeBody + ); + + const resUpdate = await superPost( + '/encoded/modern-challenge-completed' + ).send({ + ...baseMultiFileCertProjectBody, + files: encodedUpdatedMultiFiles + }); + + const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + const expectedProgressTimestamps = user.completedChallenges.map( + challenge => challenge.completedDate + ); + + const testFiles = updatedMultiFiles.map(file => omit(file, 'history')); + + expect(user).toMatchObject({ + needsModeration: true, + completedChallenges: [ + { + id: multiFileCertProjectId, + challengeType: baseMultiFileCertProjectBody.challengeType, + files: testFiles, + completedDate: expect.any(Number), + isManuallyApproved: false + }, + { + id: HtmlChallengeId, + completedDate: expect.any(Number) + } + ], + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: expect.any(Number), + files: updatedMultiFiles + } + ], + progressTimestamps: expectedProgressTimestamps + }); + + expect(resUpdate.body.savedChallenges[0].lastSavedDate).toBeGreaterThan( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + resOriginal.body.savedChallenges[0].lastSavedDate + ); + + expect(resUpdate.body).toStrictEqual({ + alreadyCompleted: true, + points: 2, + completedDate: expect.any(Number), + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: expect.any(Number), + files: updatedMultiFiles + } + ] + }); + expect(resUpdate.statusCode).toBe(200); + }); + }); + describe('/daily-coding-challenge-completed', () => { describe('validation', () => { test('POST rejects requests without an id', async () => { @@ -1171,7 +1419,7 @@ describe('challengeRoutes', () => { savedChallenges: { // valid mongo id, but not a saveable one id: 'aaaaaaaaaaaaaaaaaaaaaaa', - files: multiFileCertProjectBody.files + files: multiFiles } }); @@ -1196,7 +1444,7 @@ describe('challengeRoutes', () => { test('rejects requests for challenges that cannot be saved', async () => { const response = await superPost('/save-challenge').send({ id: '66ebd4ae2812430bb883c786', - files: multiFileCertProjectBody.files + files: multiFiles }); const { savedChallenges } = @@ -1212,7 +1460,7 @@ describe('challengeRoutes', () => { test('update the user savedchallenges and return them', async () => { const response = await superPost('/save-challenge').send({ id: multiFileCertProjectId, - files: updatedMultiFileCertProjectBody.files + files: updatedMultiFiles }); const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ @@ -1226,7 +1474,7 @@ describe('challengeRoutes', () => { { id: multiFileCertProjectId, lastSavedDate: savedDate, - files: updatedMultiFileCertProjectBody.files + files: updatedMultiFiles } ] }); @@ -1235,7 +1483,7 @@ describe('challengeRoutes', () => { { id: multiFileCertProjectId, lastSavedDate: savedDate, - files: updatedMultiFileCertProjectBody.files + files: updatedMultiFiles } ] }); @@ -1244,6 +1492,57 @@ describe('challengeRoutes', () => { }); }); + describe('POST /encoded/save-challenge', () => { + test('rejects requests for challenges that cannot be saved', async () => { + const response = await superPost('/encoded/save-challenge').send({ + id: '66ebd4ae2812430bb883c786', + files: encodedUpdatedMultiFiles + }); + + const { savedChallenges } = + await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + expect(response.statusCode).toBe(400); + expect(response.text).toEqual('That challenge type is not saveable.'); + expect(savedChallenges).toHaveLength(0); + }); + + test('update the user savedchallenges and return them', async () => { + const response = await superPost('/encoded/save-challenge').send({ + id: multiFileCertProjectId, + files: encodedUpdatedMultiFiles + }); + + const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + const savedDate = user.savedChallenges[0]?.lastSavedDate; + + expect(user).toMatchObject({ + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: savedDate, + files: updatedMultiFiles + } + ] + }); + expect(response.body).toEqual({ + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: savedDate, + files: updatedMultiFiles + } + ] + }); + expect(response.statusCode).toBe(200); + }); + }); + describe('GET /exam/:id', () => { beforeAll(async () => { await seedExam(); diff --git a/api/src/routes/protected/challenge.ts b/api/src/routes/protected/challenge.ts index 2624536b60c..9c553210acd 100644 --- a/api/src/routes/protected/challenge.ts +++ b/api/src/routes/protected/challenge.ts @@ -1,9 +1,9 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; import jwt from 'jsonwebtoken'; import { uniqBy, matches } from 'lodash'; -import { CompletedExam, ExamResults } from '@prisma/client'; +import { CompletedExam, ExamResults, SavedChallengeFile } from '@prisma/client'; import isURL from 'validator/lib/isURL'; -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyBaseLogger, FastifyInstance, FastifyReply } from 'fastify'; import { challengeTypes } from '../../../../shared/config/challenge-types'; import * as schemas from '../../schemas'; @@ -32,6 +32,7 @@ import { import { generateRandomExam, createExamResults } from '../../utils/exam'; import { canSubmitCodeRoadCertProject, + decodeFiles, verifyTrophyWithMicrosoft } from '../helpers/challenge-helpers'; import { UpdateReqType } from '../../utils'; @@ -237,44 +238,49 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( ); const { id, files, challengeType } = req.body; - - const user = await fastify.prisma.user.findUniqueOrThrow({ - where: { id: req.user?.id }, - select: userChallengeSelect - }); - const RawProgressTimestamp = user.progressTimestamps as - | ProgressTimestamp[] - | null; - const points = getPoints(RawProgressTimestamp); - - const completedChallenge: CompletedChallenge = { + return await postModernChallengeCompleted(fastify, { id, files, - completedDate: Date.now() - }; + challengeType, + userId: req.user!.id + }); + } + ); - if (challengeType === challengeTypes.multifileCertProject) { - completedChallenge.isManuallyApproved = false; - user.needsModeration = true; + fastify.post( + '/encoded/modern-challenge-completed', + { + schema: schemas.modernChallengeCompleted, + errorHandler(error, req, reply) { + if (error.validation) { + const logger = fastify.log.child({ req, res: reply }); + // This is another highly used route, so debug log level is used to + // avoid excessive logging + logger.debug({ validationError: error.validation }); + void reply.code(400); + return formatProjectCompletedValidation(error.validation); + } else { + fastify.errorHandler(error, req, reply); + } } + }, + async (req, reply) => { + const logger = fastify.log.child({ req, res: reply }); + // This is another highly used route, so debug log level is used to + // avoid excessive logging + logger.debug( + { userId: req.user?.id }, + 'User submitted a modern challenge' + ); - if ( - jsCertProjectIds.includes(id) || - multifileCertProjectIds.includes(id) || - multifilePythonCertProjectIds.includes(id) - ) { - completedChallenge.challengeType = challengeType; - } - - const { alreadyCompleted, userSavedChallenges: savedChallenges } = - await updateUserChallengeData(fastify, user, id, completedChallenge); - - return { - alreadyCompleted, - points: alreadyCompleted ? points : points + 1, - completedDate: completedChallenge.completedDate, - savedChallenges - }; + const { id, files: encodedFiles, challengeType } = req.body; + const files = encodedFiles ? decodeFiles(encodedFiles) : undefined; + return await postModernChallengeCompleted(fastify, { + id, + files, + challengeType, + userId: req.user!.id + }); } ); @@ -319,43 +325,42 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( logger.info({ userId: req.user?.id }, 'User saved a challenge'); const { files, id: challengeId } = req.body; - const user = await fastify.prisma.user.findUniqueOrThrow({ - where: { id: req.user?.id } - }); - const challenge = { - id: challengeId, - files - }; - - if ( - !multifileCertProjectIds.includes(challengeId) && - !multifilePythonCertProjectIds.includes(challengeId) - ) { - logger.warn( - { - challengeId - }, - 'User tried to save a challenge that is not saveable' - ); - return void reply - .code(400) - .send('That challenge type is not saveable.'); - } - - const userSavedChallenges = saveUserChallengeData( - challengeId, - user.savedChallenges, - challenge + await postSaveChallenge( + fastify, + { challengeId, files, userId: req.user!.id }, + logger, + reply ); + } + ); - await fastify.prisma.user.update({ - where: { id: user.id }, - data: { - savedChallenges: userSavedChallenges + fastify.post( + '/encoded/save-challenge', + { + schema: schemas.saveChallenge, + errorHandler(error, req, reply) { + const logger = fastify.log.child({ req, res: reply }); + if (error.validation) { + logger.warn({ validationError: error.validation }); + void reply.code(400); + return formatProjectCompletedValidation(error.validation); + } else { + fastify.errorHandler(error, req, reply); } - }); + } + }, + async (req, reply) => { + const logger = fastify.log.child({ req, res: reply }); + logger.info({ userId: req.user?.id }, 'User saved a challenge'); - void reply.send({ savedChallenges: userSavedChallenges }); + const { files: encodedFiles, id: challengeId } = req.body; + const files = decodeFiles(encodedFiles); + await postSaveChallenge( + fastify, + { challengeId, files, userId: req.user!.id }, + logger, + reply + ); } ); @@ -1098,3 +1103,107 @@ async function postDailyCodingChallengeCompleted( }); } } + +async function postSaveChallenge( + fastify: FastifyInstance, + { + challengeId, + userId, + files + }: { + challengeId: string; + userId: string; + files: SavedChallengeFile[]; + }, + logger: FastifyBaseLogger, + reply: FastifyReply +) { + const user = await fastify.prisma.user.findUniqueOrThrow({ + where: { id: userId } + }); + const challenge = { + id: challengeId, + files + }; + + if ( + !multifileCertProjectIds.includes(challengeId) && + !multifilePythonCertProjectIds.includes(challengeId) + ) { + logger.warn( + { + challengeId + }, + 'User tried to save a challenge that is not saveable' + ); + return void reply.code(400).send('That challenge type is not saveable.'); + } + + const userSavedChallenges = saveUserChallengeData( + challengeId, + user.savedChallenges, + challenge + ); + + await fastify.prisma.user.update({ + where: { id: user.id }, + data: { + savedChallenges: userSavedChallenges + } + }); + + void reply.send({ savedChallenges: userSavedChallenges }); +} + +async function postModernChallengeCompleted( + fastify: FastifyInstance, + { + id, + userId, + challengeType, + files + }: { + id: string; + userId: string; + challengeType: number; + files: CompletedChallenge['files']; + } +) { + const user = await fastify.prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: userChallengeSelect + }); + const RawProgressTimestamp = user.progressTimestamps as + | ProgressTimestamp[] + | null; + const points = getPoints(RawProgressTimestamp); + + const completedChallenge: CompletedChallenge = { + id, + files, + completedDate: Date.now() + }; + + if (challengeType === challengeTypes.multifileCertProject) { + completedChallenge.isManuallyApproved = false; + user.needsModeration = true; + } + + if ( + jsCertProjectIds.includes(id) || + multifileCertProjectIds.includes(id) || + multifilePythonCertProjectIds.includes(id) + ) { + completedChallenge.challengeType = challengeType; + } + + const { alreadyCompleted, userSavedChallenges: savedChallenges } = + await updateUserChallengeData(fastify, user, id, completedChallenge); + + return { + alreadyCompleted, + points: alreadyCompleted ? points : points + 1, + completedDate: completedChallenge.completedDate, + savedChallenges + }; +} diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index 7860134ad2f..52f910b0a7a 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -153,7 +153,7 @@ function submitModern(type, state) { } update = { - endpoint: '/modern-challenge-completed', + endpoint: '/encoded/modern-challenge-completed', payload: body }; } diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index bb045e62059..b3d19785cb4 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -308,7 +308,7 @@ export function postSaveChallenge(body: { id: string; files: ChallengeFiles; }): Promise> { - return post('/save-challenge', body); + return post('/encoded/save-challenge', body); } export function postSubmitSurvey(body: { diff --git a/client/src/utils/challenge-request-helpers.ts b/client/src/utils/challenge-request-helpers.ts index ee08c18adb3..012ce87a31b 100644 --- a/client/src/utils/challenge-request-helpers.ts +++ b/client/src/utils/challenge-request-helpers.ts @@ -27,6 +27,14 @@ interface Body { challengeType: number; } +const encodeBase64 = (str: string) => { + const bytes = new TextEncoder().encode(str); + const binaryString = Array.from(bytes, byte => + String.fromCodePoint(byte) + ).join(''); + return btoa(binaryString); +}; + export function standardizeRequestBody({ id, challengeFiles = [], @@ -36,7 +44,7 @@ export function standardizeRequestBody({ id, files: challengeFiles?.map(({ fileKey, contents, ext, name, history }) => { return { - contents, + contents: encodeBase64(contents), ext, history, // TODO(Post-MVP): stop sending history, if possible. The client // already gets it from the curriculum, so it should not be necessary to