breaking(api): remove screenshot api (#61300)

This commit is contained in:
Shaun Hamilton
2025-07-22 17:28:13 +02:00
committed by GitHub
parent e807d58a0b
commit d88691bc8a
10 changed files with 12 additions and 339 deletions

View File

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

View File

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

View File

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

View File

@@ -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<typeof schemas.examEnvironmentPostScreenshot>,
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<typeof schemas.examEnvironmentExams>,

View File

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

View File

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

View File

@@ -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')
};
/**

View File

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

40
pnpm-lock.yaml generated
View File

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

View File

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