mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00: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/accepts": "4.3.0",
|
||||||
"@fastify/cookie": "9.4.0",
|
"@fastify/cookie": "9.4.0",
|
||||||
"@fastify/csrf-protection": "6.4.1",
|
"@fastify/csrf-protection": "6.4.1",
|
||||||
|
"@fastify/multipart": "^8.3.0",
|
||||||
"@fastify/oauth2": "7.8.1",
|
"@fastify/oauth2": "7.8.1",
|
||||||
"@fastify/swagger": "8.14.0",
|
"@fastify/swagger": "8.14.0",
|
||||||
"@fastify/swagger-ui": "1.10.2",
|
"@fastify/swagger-ui": "1.10.2",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
} from './utils/env';
|
} from './utils/env';
|
||||||
import { isObjectID } from './utils/validation';
|
import { isObjectID } from './utils/validation';
|
||||||
import {
|
import {
|
||||||
|
examEnvironmentMultipartRoutes,
|
||||||
examEnvironmentOpenRoutes,
|
examEnvironmentOpenRoutes,
|
||||||
examEnvironmentValidatedTokenRoutes
|
examEnvironmentValidatedTokenRoutes
|
||||||
} from './exam-environment/routes/exam-environment';
|
} from './exam-environment/routes/exam-environment';
|
||||||
@@ -209,6 +210,7 @@ export const build = async (
|
|||||||
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
|
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
|
||||||
|
|
||||||
void fastify.register(examEnvironmentValidatedTokenRoutes);
|
void fastify.register(examEnvironmentValidatedTokenRoutes);
|
||||||
|
void fastify.register(examEnvironmentMultipartRoutes);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
void fastify.register(examEnvironmentOpenRoutes);
|
void fastify.register(examEnvironmentOpenRoutes);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Static } from '@fastify/type-provider-typebox';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createFetchMock,
|
||||||
createSuperRequest,
|
createSuperRequest,
|
||||||
defaultUserId,
|
defaultUserId,
|
||||||
devLogin,
|
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', () => {
|
describe('GET /exam-environment/exams', () => {
|
||||||
it('should return 200', async () => {
|
it('should return 200', async () => {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
|
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
|
||||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||||
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
import { PrismaClientValidationError } from '@prisma/client/runtime/library';
|
import { PrismaClientValidationError } from '@prisma/client/runtime/library';
|
||||||
import { type FastifyInstance, type FastifyReply } from 'fastify';
|
import { type FastifyInstance, type FastifyReply } from 'fastify';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
import * as schemas from '../schemas';
|
import * as schemas from '../schemas';
|
||||||
import { mapErr, syncMapErr, UpdateReqType } from '../../utils';
|
import { mapErr, syncMapErr, UpdateReqType } from '../../utils';
|
||||||
import { JWT_SECRET } from '../../utils/env';
|
import { JWT_SECRET, SCREENSHOT_SERVICE_LOCATION } from '../../utils/env';
|
||||||
import {
|
import {
|
||||||
checkPrerequisites,
|
checkPrerequisites,
|
||||||
constructUserExam,
|
constructUserExam,
|
||||||
@@ -44,16 +45,32 @@ export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
|
|||||||
},
|
},
|
||||||
postExamAttemptHandler
|
postExamAttemptHandler
|
||||||
);
|
);
|
||||||
fastify.post(
|
|
||||||
'/exam-environment/screenshot',
|
|
||||||
{
|
|
||||||
schema: schemas.examEnvironmentPostScreenshot
|
|
||||||
},
|
|
||||||
postScreenshotHandler
|
|
||||||
);
|
|
||||||
done();
|
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.
|
* Wrapper for endpoints related to the exam environment desktop app.
|
||||||
*
|
*
|
||||||
@@ -544,10 +561,80 @@ async function postExamAttemptHandler(
|
|||||||
*/
|
*/
|
||||||
async function postScreenshotHandler(
|
async function postScreenshotHandler(
|
||||||
this: FastifyInstance,
|
this: FastifyInstance,
|
||||||
_req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
|
req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
|
||||||
reply: FastifyReply
|
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(
|
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 = {
|
export const examEnvironmentPostScreenshot = {
|
||||||
|
headers: Type.Object({
|
||||||
|
'exam-environment-authorization-token': Type.String()
|
||||||
|
}),
|
||||||
response: {
|
response: {
|
||||||
// 200: Type.Object({})
|
400: STANDARD_ERROR,
|
||||||
|
500: STANDARD_ERROR
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export const ERRORS = {
|
|||||||
'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
|
'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
|
||||||
'%s'
|
'%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;
|
export const HOME_LOCATION = process.env.HOME_LOCATION;
|
||||||
// Mailhog is used in development and test environments, hence the localhost
|
// Mailhog is used in development and test environments, hence the localhost
|
||||||
// default.
|
// default.
|
||||||
@@ -204,3 +208,5 @@ function undefinedOrBool(val: string | undefined): undefined | boolean {
|
|||||||
|
|
||||||
return val === 'true';
|
return val === 'true';
|
||||||
}
|
}
|
||||||
|
export const SCREENSHOT_SERVICE_LOCATION =
|
||||||
|
process.env.SCREENSHOT_SERVICE_LOCATION;
|
||||||
|
|||||||
@@ -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/scripts/**/*.ts',
|
||||||
'tools/challenge-helper-scripts/**/*.ts',
|
'tools/challenge-helper-scripts/**/*.ts',
|
||||||
'tools/challenge-auditor/**/*.ts',
|
'tools/challenge-auditor/**/*.ts',
|
||||||
|
'tools/screenshot-service/**/*.ts',
|
||||||
'e2e/**/*.ts'
|
'e2e/**/*.ts'
|
||||||
],
|
],
|
||||||
extends: [tseslint.configs.recommendedTypeChecked]
|
extends: [tseslint.configs.recommendedTypeChecked]
|
||||||
|
|||||||
Generated
+1510
-83
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ packages:
|
|||||||
- 'tools/challenge-parser'
|
- 'tools/challenge-parser'
|
||||||
- 'tools/client-plugins/*'
|
- 'tools/client-plugins/*'
|
||||||
- 'tools/crowdin'
|
- 'tools/crowdin'
|
||||||
|
- 'tools/screenshot-service'
|
||||||
- 'tools/scripts/build'
|
- 'tools/scripts/build'
|
||||||
- 'tools/scripts/seed'
|
- 'tools/scripts/seed'
|
||||||
- 'tools/scripts/seed-exams'
|
- 'tools/scripts/seed-exams'
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ FORUM_LOCATION=https://forum.freecodecamp.org
|
|||||||
NEWS_LOCATION=https://www.freecodecamp.org/news
|
NEWS_LOCATION=https://www.freecodecamp.org/news
|
||||||
RADIO_LOCATION=https://coderadio.freecodecamp.org
|
RADIO_LOCATION=https://coderadio.freecodecamp.org
|
||||||
|
|
||||||
|
# Exam Env application paths
|
||||||
|
SCREENSHOT_SERVICE_LOCATION=http://localhost:3003
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# Build variants
|
# Build variants
|
||||||
# ---------------------
|
# ---------------------
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
AWS_REGION=
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET_NAME=
|
||||||
|
PORT=3003
|
||||||
@@ -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