feat: base64 encode file contents when making api requests (#62006)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Oliver Eyton-Williams
2025-09-02 16:20:48 +02:00
committed by GitHub
parent 10c565828e
commit 8cd2efe570
7 changed files with 675 additions and 144 deletions

View File

@@ -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('<h1>Hello, world!</h1>')
}
];
const decodedFiles = decodeFiles(encodedFiles);
expect(decodedFiles).toEqual([
{
contents: 'console.log("Hello, world!");'
},
{
contents: '<h1>Hello, world!</h1>'
}
]);
});
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);
});
});
});

View File

@@ -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<T extends { contents: string }>(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');
}

View File

@@ -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: '<h1>Multi File Project v1</h1>',
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: '<h1>Multi File Project v2</h1>',
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: '<h1>Multi File Project v1</h1>',
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: '<h1>Multi File Project v2</h1>',
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('<h1>Multi File Project v1</h1>'),
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('<h1>Multi File Project v2</h1>'),
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();

View File

@@ -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
};
}

View File

@@ -153,7 +153,7 @@ function submitModern(type, state) {
}
update = {
endpoint: '/modern-challenge-completed',
endpoint: '/encoded/modern-challenge-completed',
payload: body
};
}

View File

@@ -308,7 +308,7 @@ export function postSaveChallenge(body: {
id: string;
files: ChallengeFiles;
}): Promise<ResponseWithData<void>> {
return post('/save-challenge', body);
return post('/encoded/save-challenge', body);
}
export function postSubmitSurvey(body: {

View File

@@ -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