diff --git a/api/package.json b/api/package.json index a5c084f9864..2ca2ebf226d 100644 --- a/api/package.json +++ b/api/package.json @@ -8,7 +8,6 @@ "@fastify/accepts": "5.0.2", "@fastify/cookie": "11.0.1", "@fastify/csrf-protection": "7.1.0", - "@fastify/multipart": "^9.0.3", "@fastify/oauth2": "8.1.2", "@fastify/swagger": "9.4.0", "@fastify/swagger-ui": "5.2.0", diff --git a/api/src/app.ts b/api/src/app.ts index 953f917fc06..a7ac18b41e1 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -48,7 +48,6 @@ import { import { isObjectID } from './utils/validation'; import { getLogger } from './utils/logger'; import { - examEnvironmentMultipartRoutes, examEnvironmentOpenRoutes, examEnvironmentValidatedTokenRoutes } from './exam-environment/routes/exam-environment'; @@ -214,7 +213,6 @@ export const build = async ( fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken); void fastify.register(examEnvironmentValidatedTokenRoutes); - void fastify.register(examEnvironmentMultipartRoutes); done(); }); void fastify.register(examEnvironmentOpenRoutes); diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index ad2d3861746..1ce93ba12b6 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -3,7 +3,6 @@ import { Static } from '@fastify/type-provider-typebox'; import jwt from 'jsonwebtoken'; import { - createFetchMock, createSuperRequest, defaultUserId, devLogin, @@ -587,99 +586,6 @@ describe('/exam-environment/', () => { }); }); - describe('POST /exam-environment/screenshot', () => { - afterEach(async () => { - await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany(); - }); - - it('should return 400 if request is not multipart form data', async () => { - const res = await superPost('/exam-environment/screenshot').set( - 'exam-environment-authorization-token', - examEnvironmentAuthorizationToken - ); - - expect(res.status).toBe(400); - expect(res.body).toStrictEqual({ - code: 'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.any(String) - }); - }); - - it('should return 400 if image is missing', async () => { - const res = await superPost('/exam-environment/screenshot') - .set( - 'exam-environment-authorization-token', - examEnvironmentAuthorizationToken - ) - .attach('file', ''); - - expect(res.status).toBe(400); - expect(res.body).toStrictEqual({ - code: 'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.any(String) - }); - }); - - it('should return 404 if there is no ongoing exam attempt', async () => { - const res = await superPost('/exam-environment/screenshot') - .set( - 'exam-environment-authorization-token', - examEnvironmentAuthorizationToken - ) - .attach('file', Buffer.from([])); - - expect(res.status).toBe(404); - expect(res.body).toStrictEqual({ - code: 'FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.any(String) - }); - }); - - it('should return 400 if image is of wrong format', async () => { - await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({ - data: mock.examAttempt - }); - - const res = await superPost('/exam-environment/screenshot') - .set( - 'exam-environment-authorization-token', - examEnvironmentAuthorizationToken - ) - .attach('file', Buffer.from([])); - - expect(res.status).toBe(400); - expect(res.body).toStrictEqual({ - code: 'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT', - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.any(String) - }); - }); - - it('should return 200 if request is valid and send image to screenshot upload service', async () => { - // Mock image upload service response - const imageUploadRes = createFetchMock({ ok: true }); - jest.spyOn(globalThis, 'fetch').mockImplementation(imageUploadRes); - - await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({ - data: mock.examAttempt - }); - - const res = await superPost('/exam-environment/screenshot') - .set( - 'exam-environment-authorization-token', - examEnvironmentAuthorizationToken - ) - .attach('file', Buffer.from([0xff, 0xd8, 0xff, 0xff])); - - expect(res.status).toBe(200); - expect(res.body).toStrictEqual({}); - expect(globalThis.fetch).toHaveBeenCalled(); - }); - }); - describe('GET /exam-environment/exams', () => { it('should return 200', async () => { const res = await superGet('/exam-environment/exams').set( @@ -1030,17 +936,6 @@ describe('/exam-environment/', () => { }); }); - describe('POST /exam-environment/screenshot', () => { - it('should return 403', async () => { - const res = await superPost('/exam-environment/screenshot').set( - 'exam-environment-authorization-token', - 'invalid-token' - ); - - expect(res.status).toBe(403); - }); - }); - describe('GET /exam-environment/token-meta', () => { it('should reject invalid tokens', async () => { const res = await superGet('/exam-environment/token-meta').set( diff --git a/api/src/exam-environment/routes/exam-environment.ts b/api/src/exam-environment/routes/exam-environment.ts index e290010b3ed..f5f0babaec8 100644 --- a/api/src/exam-environment/routes/exam-environment.ts +++ b/api/src/exam-environment/routes/exam-environment.ts @@ -1,6 +1,5 @@ /* eslint-disable jsdoc/require-returns, jsdoc/require-param */ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; -import fastifyMultipart from '@fastify/multipart'; import { PrismaClientValidationError } from '@prisma/client/runtime/library'; import { type FastifyInstance, type FastifyReply } from 'fastify'; import { ExamEnvironmentExamModerationStatus } from '@prisma/client'; @@ -8,7 +7,7 @@ import jwt from 'jsonwebtoken'; import * as schemas from '../schemas'; import { mapErr, syncMapErr, UpdateReqType } from '../../utils'; -import { JWT_SECRET, SCREENSHOT_SERVICE_LOCATION } from '../../utils/env'; +import { JWT_SECRET } from '../../utils/env'; import { checkPrerequisites, constructEnvExamAttempt, @@ -65,29 +64,6 @@ export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox = done(); }; -/** - * Wrapper for endpoints related to the exam environment desktop app. - * - * Requires multipart form data to be supported. - */ -export const examEnvironmentMultipartRoutes: FastifyPluginCallbackTypebox = ( - fastify, - _options, - done -) => { - void fastify.register(fastifyMultipart); - - fastify.post( - '/exam-environment/screenshot', - { - schema: schemas.examEnvironmentPostScreenshot - // bodyLimit: 1024 * 1024 * 5 // 5MiB - }, - postScreenshotHandler - ); - done(); -}; - /** * Wrapper for endpoints related to the exam environment desktop app. * @@ -684,155 +660,6 @@ async function postExamAttemptHandler( return reply.code(200).send(); } -/** - * Handles screenshots, sending them to the screenshot service for storage. - * - * Requires token to be validated. - */ -async function postScreenshotHandler( - this: FastifyInstance, - req: UpdateReqType, - reply: FastifyReply -) { - const logger = this.log.child({ req }); - logger.info({ userId: req.user?.id }); - const isMultipart = req.isMultipart(); - - if (!isMultipart) { - logger.warn('Request is not multipart form data.'); - void reply.code(400); - return reply.send( - ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT( - 'Request is not multipart form data.' - ) - ); - } - - const user = req.user!; - const imgData = await req.file(); - - if (!imgData) { - logger.warn('No image provided.'); - void reply.code(400); - return reply.send( - ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT('No image provided.') - ); - } - - const maybeAttempts = await mapErr( - this.prisma.examEnvironmentExamAttempt.findMany({ - where: { - userId: user.id - } - }) - ); - - if (maybeAttempts.hasError) { - logger.error(maybeAttempts.error); - this.Sentry.captureException(maybeAttempts.error); - void reply.code(500); - return reply.send( - ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error)) - ); - } - - const attempts = maybeAttempts.data; - - if (attempts.length === 0) { - logger.warn('No exam attempts found for user.'); - void reply.code(404); - return reply.send( - ERRORS.FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT( - `No exam attempts found for user '${user.id}'.` - ) - ); - } - - const latestAttempt = attempts.reduce((latest, current) => - latest.startTimeInMS > current.startTimeInMS ? latest : current - ); - - const maybeExam = await mapErr( - this.prisma.examEnvironmentExam.findUnique({ - where: { - id: latestAttempt.examId - }, - select: { - id: true, - config: true - } - }) - ); - - if (maybeExam.hasError) { - logger.error(maybeExam.error); - this.Sentry.captureException(maybeExam.error); - void reply.code(500); - return reply.send( - ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error)) - ); - } - - const exam = maybeExam.data; - - if (exam === null) { - const error = { - data: { - examId: latestAttempt.examId, - attemptId: latestAttempt.id - }, - message: 'Unreachable. Attempt could not be related to an exam.' - }; - logger.error(error.data, error.message); - this.Sentry.captureException(error.data); - void reply.code(500); - return reply.send( - ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM(error.message) - ); - } - - const isAttemptExpired = - latestAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now(); - if (isAttemptExpired) { - logger.warn( - { examAttemptId: latestAttempt.id }, - 'Attempt has exceeded submission time.' - ); - void reply.code(403); - return reply.send( - ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT( - 'Attempt has exceeded submission time.' - ) - ); - } - - const imgBinary = await imgData.toBuffer(); - - // Verify image is JPG using magic number - if (imgBinary[0] !== 0xff || imgBinary[1] !== 0xd8 || imgBinary[2] !== 0xff) { - logger.warn('Invalid image format'); - void reply.code(400); - return reply.send( - ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT('Invalid image format.') - ); - } - - void reply.code(200).send(); - - const uploadData = { - image: imgBinary.toString('base64'), - examAttemptId: latestAttempt.id - }; - - await fetch(`${SCREENSHOT_SERVICE_LOCATION}/upload`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(uploadData) - }); -} - async function getExams( this: FastifyInstance, req: UpdateReqType, diff --git a/api/src/exam-environment/schemas/index.ts b/api/src/exam-environment/schemas/index.ts index 743edd6bd73..e80221db7b9 100644 --- a/api/src/exam-environment/schemas/index.ts +++ b/api/src/exam-environment/schemas/index.ts @@ -4,6 +4,5 @@ export { examEnvironmentGetExamAttempt } from './exam-environment-exam-attempt'; export { examEnvironmentPostExamGeneratedExam } from './exam-environment-exam-generated-exam'; -export { examEnvironmentPostScreenshot } from './screenshot'; export { examEnvironmentTokenMeta } from './token-meta'; export { examEnvironmentExams } from './exam-environment-exams'; diff --git a/api/src/exam-environment/schemas/screenshot.ts b/api/src/exam-environment/schemas/screenshot.ts deleted file mode 100644 index 4d8f247a317..00000000000 --- a/api/src/exam-environment/schemas/screenshot.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Type } from '@fastify/type-provider-typebox'; -import { STANDARD_ERROR } from '../utils/errors'; - -export const examEnvironmentPostScreenshot = { - headers: Type.Object({ - 'exam-environment-authorization-token': Type.String() - }), - response: { - 400: STANDARD_ERROR, - 500: STANDARD_ERROR - } -}; diff --git a/api/src/exam-environment/utils/errors.ts b/api/src/exam-environment/utils/errors.ts index 2d0099ffaed..2775d7b9bd9 100644 --- a/api/src/exam-environment/utils/errors.ts +++ b/api/src/exam-environment/utils/errors.ts @@ -39,11 +39,7 @@ export const ERRORS = { 'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM', '%s' ), - FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s'), - FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT: createError( - 'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT', - '%s' - ) + FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s') }; /** diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index 4a3982070d1..6e20bc8afdb 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -153,10 +153,6 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') { ); } -if (process.env.FCC_ENABLE_EXAM_ENVIRONMENT === 'true') { - assert.ok(process.env.SCREENSHOT_SERVICE_LOCATION); -} - export const HOME_LOCATION = process.env.HOME_LOCATION; // Mailhog is used in development and test environments, hence the localhost // default. @@ -224,6 +220,4 @@ function undefinedOrBool(val: string | undefined): undefined | boolean { return val === 'true'; } -export const SCREENSHOT_SERVICE_LOCATION = - process.env.SCREENSHOT_SERVICE_LOCATION; export const DEPLOYMENT_VERSION = process.env.DEPLOYMENT_VERSION || 'unknown'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13f055d84be..81f9cd181cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,9 +162,6 @@ importers: '@fastify/csrf-protection': specifier: 7.1.0 version: 7.1.0 - '@fastify/multipart': - specifier: ^9.0.3 - version: 9.0.3 '@fastify/oauth2': specifier: 8.1.2 version: 8.1.2 @@ -2658,9 +2655,6 @@ packages: '@fastify/ajv-compiler@4.0.2': resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==} - '@fastify/busboy@3.1.1': - resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==} - '@fastify/cookie@11.0.1': resolution: {integrity: sha512-n1Ooz4bgQ5LcOlJQboWPfsMNxIrGV0SgU85UkctdpTlCQE0mtA3rlspOPUdqk9ubiiZn053ucnia4DjTquI4/g==} @@ -2670,9 +2664,6 @@ packages: '@fastify/csrf@8.0.1': resolution: {integrity: sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw==} - '@fastify/deepmerge@2.0.2': - resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==} - '@fastify/error@4.0.0': resolution: {integrity: sha512-OO/SA8As24JtT1usTUTKgGH7uLvhfwZPwlptRi2Dp5P4KKmJI3gvsZ8MIHnNwDs4sLf/aai5LzTyl66xr7qMxA==} @@ -2682,9 +2673,6 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - '@fastify/multipart@9.0.3': - resolution: {integrity: sha512-pJogxQCrT12/6I5Fh6jr3narwcymA0pv4B0jbC7c6Bl9wnrxomEUnV0d26w6gUls7gSXmhG8JGRMmHFIPsxt1g==} - '@fastify/oauth2@8.1.2': resolution: {integrity: sha512-XZWFRWTZE2fkZ2pjuHNGtpFn1tOFgcJbU0205kHbfd16dn9xRc/6HmG0gHtN/g/BNkEL3EsQ54+pYEdh8dnBgA==} @@ -14628,7 +14616,7 @@ snapshots: '@babel/traverse': 7.23.7 '@babel/types': 7.23.9 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -16373,7 +16361,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.6 '@babel/types': 7.23.9 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -16397,7 +16385,7 @@ snapshots: '@babel/parser': 7.27.4 '@babel/template': 7.27.2 '@babel/types': 7.27.3 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -16748,8 +16736,6 @@ snapshots: ajv-formats: 3.0.1(ajv@8.12.0) fast-uri: 3.0.6 - '@fastify/busboy@3.1.1': {} - '@fastify/cookie@11.0.1': dependencies: cookie: 1.0.2 @@ -16763,8 +16749,6 @@ snapshots: '@fastify/csrf@8.0.1': {} - '@fastify/deepmerge@2.0.2': {} - '@fastify/error@4.0.0': {} '@fastify/fast-json-stringify-compiler@5.0.3': @@ -16775,14 +16759,6 @@ snapshots: dependencies: dequal: 2.0.3 - '@fastify/multipart@9.0.3': - dependencies: - '@fastify/busboy': 3.1.1 - '@fastify/deepmerge': 2.0.2 - '@fastify/error': 4.0.0 - fastify-plugin: 5.0.1 - secure-json-parse: 3.0.2 - '@fastify/oauth2@8.1.2': dependencies: '@fastify/cookie': 11.0.1 @@ -20335,9 +20311,9 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.27.3 cosmiconfig: 7.1.0 - resolve: 1.22.8 + resolve: 1.22.10 babel-plugin-polyfill-corejs2@0.4.7(@babel/core@7.23.0): dependencies: @@ -21712,6 +21688,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.3.4(supports-color@8.1.1): dependencies: ms: 2.1.2 @@ -25285,7 +25265,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: diff --git a/sample.env b/sample.env index 147253b28c9..b90c3c70133 100644 --- a/sample.env +++ b/sample.env @@ -42,9 +42,6 @@ FORUM_LOCATION=https://forum.freecodecamp.org NEWS_LOCATION=https://www.freecodecamp.org/news RADIO_LOCATION=https://coderadio.freecodecamp.org -# Exam Env application paths -SCREENSHOT_SERVICE_LOCATION=http://localhost:3003 - # --------------------- # Build variants # ---------------------