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