mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat(api): exam screenshot service (#56940)
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
docker/screenshot-service/Dockerfile
Normal file
49
docker/screenshot-service/Dockerfile
Normal 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" ]
|
||||
@@ -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
1593
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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
3
tools/screenshot-service/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
29
tools/screenshot-service/README.md
Normal file
29
tools/screenshot-service/README.md
Normal 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
|
||||
```
|
||||
59
tools/screenshot-service/index.ts
Normal file
59
tools/screenshot-service/index.ts
Normal 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}`);
|
||||
});
|
||||
17
tools/screenshot-service/package.json
Normal file
17
tools/screenshot-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
tools/screenshot-service/sample.env
Normal file
5
tools/screenshot-service/sample.env
Normal file
@@ -0,0 +1,5 @@
|
||||
AWS_REGION=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET_NAME=
|
||||
PORT=3003
|
||||
13
tools/screenshot-service/tsconfig.json
Normal file
13
tools/screenshot-service/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "nodenext",
|
||||
"allowJs": false,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "../../"
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user