feat(api): exam screenshot service (#56940)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Niraj Nandish
2025-02-18 13:24:54 +04:00
committed by GitHub
parent 71e39308a4
commit dac7fa3a14
18 changed files with 1901 additions and 97 deletions

View File

@@ -8,6 +8,7 @@
"@fastify/accepts": "4.3.0",
"@fastify/cookie": "9.4.0",
"@fastify/csrf-protection": "6.4.1",
"@fastify/multipart": "^8.3.0",
"@fastify/oauth2": "7.8.1",
"@fastify/swagger": "8.14.0",
"@fastify/swagger-ui": "1.10.2",

View File

@@ -47,6 +47,7 @@ import {
} from './utils/env';
import { isObjectID } from './utils/validation';
import {
examEnvironmentMultipartRoutes,
examEnvironmentOpenRoutes,
examEnvironmentValidatedTokenRoutes
} from './exam-environment/routes/exam-environment';
@@ -209,6 +210,7 @@ export const build = async (
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
void fastify.register(examEnvironmentValidatedTokenRoutes);
void fastify.register(examEnvironmentMultipartRoutes);
done();
});
void fastify.register(examEnvironmentOpenRoutes);

View File

@@ -2,6 +2,7 @@ import { Static } from '@fastify/type-provider-typebox';
import jwt from 'jsonwebtoken';
import {
createFetchMock,
createSuperRequest,
defaultUserId,
devLogin,
@@ -562,7 +563,98 @@ describe('/exam-environment/', () => {
});
});
xdescribe('POST /exam-environment/screenshot', () => {});
describe('POST /exam-environment/screenshot', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.envExamAttempt.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.envExamAttempt.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.envExamAttempt.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 () => {

View File

@@ -1,12 +1,13 @@
/* 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 jwt from 'jsonwebtoken';
import * as schemas from '../schemas';
import { mapErr, syncMapErr, UpdateReqType } from '../../utils';
import { JWT_SECRET } from '../../utils/env';
import { JWT_SECRET, SCREENSHOT_SERVICE_LOCATION } from '../../utils/env';
import {
checkPrerequisites,
constructUserExam,
@@ -44,16 +45,32 @@ export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
},
postExamAttemptHandler
);
fastify.post(
'/exam-environment/screenshot',
{
schema: schemas.examEnvironmentPostScreenshot
},
postScreenshotHandler
);
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.
*
@@ -544,10 +561,80 @@ async function postExamAttemptHandler(
*/
async function postScreenshotHandler(
this: FastifyInstance,
_req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
reply: FastifyReply
) {
return reply.code(418);
const isMultipart = req.isMultipart();
if (!isMultipart) {
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) {
void reply.code(400);
return reply.send(
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT('No image provided.')
);
}
const maybeAttempt = await mapErr(
this.prisma.envExamAttempt.findMany({
where: {
userId: user.id
}
})
);
if (maybeAttempt.hasError) {
void reply.code(500);
return reply.send(
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempt.error))
);
}
const attempt = maybeAttempt.data;
if (attempt.length === 0) {
void reply.code(404);
return reply.send(
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
`No exam attempts found for user '${user.id}'.`
)
);
}
const imgBinary = await imgData.toBuffer();
// Verify image is JPG using magic number
if (imgBinary[0] !== 0xff || imgBinary[1] !== 0xd8 || imgBinary[2] !== 0xff) {
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: attempt[0]?.id
};
await fetch(`${SCREENSHOT_SERVICE_LOCATION}/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(uploadData)
});
}
async function getExams(

View File

@@ -1,7 +1,12 @@
// import { Type } from '@fastify/type-provider-typebox';
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: {
// 200: Type.Object({})
400: STANDARD_ERROR,
500: STANDARD_ERROR
}
};

View File

@@ -35,7 +35,11 @@ export const ERRORS = {
'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
'%s'
),
FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s')
FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s'),
FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT: createError(
'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT',
'%s'
)
};
/**

View File

@@ -138,6 +138,10 @@ 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.
@@ -204,3 +208,5 @@ function undefinedOrBool(val: string | undefined): undefined | boolean {
return val === 'true';
}
export const SCREENSHOT_SERVICE_LOCATION =
process.env.SCREENSHOT_SERVICE_LOCATION;

View File

@@ -0,0 +1,49 @@
# Define project root argument
ARG PROJECT_DIR=tools/screenshot-service
# Build the app
FROM node:20-alpine AS builder
ARG PROJECT_DIR
RUN npm i -g pnpm@9
USER node
WORKDIR /home/node/build
COPY --chown=node:node *.* .
COPY --chown=node:node ${PROJECT_DIR} ${PROJECT_DIR}
RUN pnpm install --frozen-lockfile --ignore-scripts -F=./${PROJECT_DIR}
RUN pnpm -F=./${PROJECT_DIR} build
# Install production dependencies
FROM node:20-alpine AS deps
ARG PROJECT_DIR
RUN npm i -g pnpm@9
USER node
WORKDIR /home/node/build
COPY --chown=node:node pnpm*.yaml .
COPY --chown=node:node ${PROJECT_DIR} ${PROJECT_DIR}
RUN pnpm install --prod --ignore-scripts --frozen-lockfile -F=./${PROJECT_DIR}
# App runner instance
FROM node:20-alpine AS runner
ARG PROJECT_DIR
USER node
WORKDIR /home/node/fcc
# Copy the built app
COPY --from=builder --chown=node:node /home/node/build/${PROJECT_DIR}/dist ./
# Copy the production dependencies
COPY --from=deps --chown=node:node /home/node/build/node_modules/ node_modules/
COPY --from=deps --chown=node:node /home/node/build/${PROJECT_DIR}/node_modules ${PROJECT_DIR}/node_modules/
ENV PORT 3003
# Run the app
CMD [ "node", "./tools/screenshot-service/index.js" ]

View File

@@ -160,6 +160,7 @@ export default tseslint.config(
'tools/scripts/**/*.ts',
'tools/challenge-helper-scripts/**/*.ts',
'tools/challenge-auditor/**/*.ts',
'tools/screenshot-service/**/*.ts',
'e2e/**/*.ts'
],
extends: [tseslint.configs.recommendedTypeChecked]

1593
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ packages:
- 'tools/challenge-parser'
- 'tools/client-plugins/*'
- 'tools/crowdin'
- 'tools/screenshot-service'
- 'tools/scripts/build'
- 'tools/scripts/seed'
- 'tools/scripts/seed-exams'

View File

@@ -42,6 +42,9 @@ 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
# ---------------------

3
tools/screenshot-service/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
dist/

View File

@@ -0,0 +1,29 @@
# Screenshot Service
## Development
To install dependencies:
```bash
pnpm install
```
To run:
```bash
npm run dev
```
## Deployment
Build the Docker image:
```bash
docker build -t screenshot-service -f ./docker/screenshot-service/Dockerfile .
```
Run the Docker container:
```bash
docker run -d -p 3003:3003 screenshot-service
```

View File

@@ -0,0 +1,59 @@
import {
PutObjectCommand,
S3Client,
type PutObjectCommandInput,
type PutObjectCommandOutput
} from '@aws-sdk/client-s3';
import express, { type Request, type Response } from 'express';
interface ImageUploadRequest {
image: string;
examAttemptId: string;
}
const app = express();
// Parse JSON bodies (in case images are sent as Base64 strings)
app.use(express.json({ limit: '5mb' }));
// Configure S3
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? ''
}
});
const uploadToS3 = (
image: string,
examAttemptId: string
): Promise<PutObjectCommandOutput> => {
const params: PutObjectCommandInput = {
Bucket: process.env.S3_BUCKET_NAME as string,
Key: `${examAttemptId}/${Date.now()}`,
Body: Buffer.from(image, 'base64'),
ContentType: 'image/jpeg'
};
return s3.send(new PutObjectCommand(params));
};
// Route to handle image uploads from another backend
app.post(
'/upload',
async (req: Request<object, object, ImageUploadRequest>, res: Response) => {
try {
await uploadToS3(req.body.image, req.body.examAttemptId);
res.status(200).json({ message: 'Image uploaded successfully' });
} catch (err) {
console.error('Error uploading image:', err);
res.status(500).json({ error: (err as Error).message });
}
}
);
const port = process.env.PORT || 3003;
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});

View File

@@ -0,0 +1,17 @@
{
"name": "@freecodecamp/exam-screenshot-service",
"version": "1.0.0",
"devDependencies": {
"@types/express": "^5.0.0",
"nodemon": "^3.1.7",
"typescript": "^5.7.2"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.632.0",
"express": "5.0.0-beta.3"
},
"scripts": {
"build": "tsc",
"dev": "nodemon index.ts"
}
}

View File

@@ -0,0 +1,5 @@
AWS_REGION=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
S3_BUCKET_NAME=
PORT=3003

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2022",
"module": "Node16",
"moduleResolution": "nodenext",
"allowJs": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "../../"
},
"include": ["**/*.ts"]
}