diff --git a/.vscode/settings.json b/.vscode/settings.json index fc0584be1..27642e00d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ }, "eslint.nodePath": ".yarn/sdks", "prettier.prettierPath": ".yarn/sdks/prettier/index.js", + "prettier.configPath": ".prettierrc.js", "typescript.tsdk": ".yarn/sdks/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "editor.formatOnSave": true, diff --git a/server/__tests__/fileUpload.test.ts b/server/__tests__/fileUpload.test.ts new file mode 100644 index 000000000..f4cc3070f --- /dev/null +++ b/server/__tests__/fileUpload.test.ts @@ -0,0 +1,138 @@ +import type { Response, Request } from 'express'; +import fs from 'fs'; +import path from 'path'; + +import { uploadFiles } from '../controllers/filesController'; +import type { User } from '../entities/user'; +import { AwsS3 } from '../fileUpload/s3'; +import { AppError } from '../middlewares/handleErrors'; +import { appDataSource, fakeUser } from './mock'; + +beforeAll(() => { + return appDataSource.initialize(); +}); +beforeEach(() => {}); +afterAll(() => { + return appDataSource.destroy(); +}); + +const dummyPdf = fs.readFileSync(path.join(__dirname, 'files/dummy.pdf')); +describe('Upload files', () => { + test('Should return an url array', async () => { + jest.spyOn(AwsS3.prototype, 'uploadFile').mockImplementationOnce(() => { + return Promise.resolve('url/test'); + }); + jest.spyOn(AwsS3.prototype, 'uploadS3File').mockImplementationOnce(() => { + return Promise.resolve('url/test'); + }); + + const mockRequest: Partial = { + user: fakeUser as User, + files: [ + { + buffer: dummyPdf, + fieldname: 'files', + filename: 'dummy.pdf', + mimetype: 'application/pdf', + originalname: 'dummy.pdf', + path: 'server/__tests__/files', + size: 1000, + destination: 'server/__tests__/files', + } as Express.Multer.File, + ], + }; + + const res = {} as unknown as Response; + res.json = jest.fn(); + res.status = jest.fn(() => res); // chained + + await uploadFiles(mockRequest as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(['url/test']); + }); + test('Should throw - files are missing -', async () => { + const mockRequest: Partial = { + user: fakeUser as User, + files: [], + }; + + const res = {} as unknown as Response; + res.json = jest.fn(); + res.status = jest.fn(() => res); // chained + + await uploadFiles(mockRequest as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith('Files are missing'); + }); + test('User forbiden', async () => { + const mockRequest: Partial = { + files: [], + }; + + const res = {} as unknown as Response; + res.json = jest.fn(); + res.status = jest.fn(() => res); // chained + + await uploadFiles(mockRequest as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith('Forbidden'); + }); + test('Should throw an app error', async () => { + jest.spyOn(AwsS3.prototype, 'uploadFile').mockImplementationOnce(() => { + throw new AppError('Yolo', 0); + }); + + const mockRequest: Partial = { + user: fakeUser as User, + files: [ + { + buffer: dummyPdf, + fieldname: 'files', + filename: 'dummy.pdf', + mimetype: 'application/pdf', + originalname: 'dummy.pdf', + path: 'server/__tests__/files', + size: 1000, + destination: 'server/__tests__/files', + } as Express.Multer.File, + ], + }; + + const res = {} as unknown as Response; + res.json = jest.fn(); + res.status = jest.fn(() => res); // chained + + await uploadFiles(mockRequest as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith('Yolo'); + }); + test('Should throw an unknown error', async () => { + jest.spyOn(AwsS3.prototype, 'uploadFile').mockImplementationOnce(() => { + throw new Error('Yolo'); + }); + + const mockRequest: Partial = { + user: fakeUser as User, + files: [ + { + buffer: dummyPdf, + fieldname: 'files', + filename: 'dummy.pdf', + mimetype: 'application/pdf', + originalname: 'dummy.pdf', + path: 'server/__tests__/files', + size: 1000, + destination: 'server/__tests__/files', + } as Express.Multer.File, + ], + }; + + const res = {} as unknown as Response; + res.json = jest.fn(); + res.status = jest.fn(() => res); // chained + + await uploadFiles(mockRequest as Request, res as Response); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith('Internal server error'); + }); +}); diff --git a/server/__tests__/files/dummy-image.jpg b/server/__tests__/files/dummy-image.jpg new file mode 100644 index 000000000..1db39e04b Binary files /dev/null and b/server/__tests__/files/dummy-image.jpg differ diff --git a/server/__tests__/files/dummy.pdf b/server/__tests__/files/dummy.pdf new file mode 100644 index 000000000..774c2ea70 Binary files /dev/null and b/server/__tests__/files/dummy.pdf differ diff --git a/server/app.ts b/server/app.ts index 4fe5c0f17..92779842b 100644 --- a/server/app.ts +++ b/server/app.ts @@ -18,6 +18,7 @@ import { handleErrors } from './middlewares/handleErrors'; import { jsonify } from './middlewares/jsonify'; import { setVillage } from './middlewares/setVillage'; import { removeTrailingSlash } from './middlewares/trailingSlash'; +import { filesRouter } from './routes/filesRouter'; import { connectToDatabase } from './utils/database'; import { logger } from './utils/logger'; import { getDefaultDirectives } from './utils/server'; @@ -90,11 +91,11 @@ export async function getApp() { res.status(200).send('Hello World 1Village!'); }); backRouter.use(controllerRouter); + backRouter.use('/files', filesRouter); backRouter.use((_, res: Response) => { res.status(404).send('Error 404 - Not found.'); }); app.use('/api', backRouter); - // [4-bis] --- Add h5p --- if (process.env.DYNAMODB_REGION) { try { diff --git a/server/controllers/controller.ts b/server/controllers/controller.ts index e79024fb9..7c611cdee 100644 --- a/server/controllers/controller.ts +++ b/server/controllers/controller.ts @@ -3,11 +3,11 @@ import { Router } from 'express'; import fs from 'fs-extra'; import multer from 'multer'; import path from 'path'; -import { v4 as uuidv4 } from 'uuid'; import type { UserType } from '../entities/user'; import { authenticate } from '../middlewares/authenticate'; import { handleErrors } from '../middlewares/handleErrors'; +import { diskStorage } from './multer'; type RouteOptions = { path: string; @@ -15,16 +15,6 @@ type RouteOptions = { }; fs.ensureDir(path.join(__dirname, '../fileUpload/videos')).catch(); -const diskStorage = multer.diskStorage({ - destination: function (_req, _file, cb) { - cb(null, path.join(__dirname, '../fileUpload/videos/')); - }, - filename: function (_req, file, cb) { - const uuid = uuidv4(); - cb(null, `${uuid}${path.extname(file.originalname)}`); - }, -}); - export class Controller { private uploadMiddleware = multer({ storage: multer.memoryStorage() }); private uploadVideoMiddleware = multer({ storage: diskStorage }); diff --git a/server/controllers/filesController.ts b/server/controllers/filesController.ts new file mode 100644 index 000000000..faeb61b22 --- /dev/null +++ b/server/controllers/filesController.ts @@ -0,0 +1,33 @@ +import type { Request, Response } from 'express'; + +import { uploadFile } from '../fileUpload'; +import { AppError, ErrorCode } from '../middlewares/handleErrors'; + +export async function uploadFiles(req: Request, res: Response) { + try { + const user = req.user; + if (!user) { + throw new AppError('Forbidden', ErrorCode.UNKNOWN); + } + const { files } = req; + if (!files || !files.length) { + throw new AppError('Files are missing', ErrorCode.UNKNOWN); + } + const promises: Promise[] = []; + for (const file of files as Express.Multer.File[]) { + // making the filename being the path here is a trick to use + // upload function... + const filename = `images/${user.id}/${file.filename}`; + const promise = uploadFile(filename, file.mimetype); + promises.push(promise); + } + const results = await Promise.all(promises); + res.status(200).json(results); + } catch (error) { + if (error instanceof AppError) { + res.status(400).json(error.message); + } else { + res.status(500).json('Internal server error'); + } + } +} diff --git a/server/controllers/multer.ts b/server/controllers/multer.ts new file mode 100644 index 000000000..60bb07db9 --- /dev/null +++ b/server/controllers/multer.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import multer from 'multer'; +import path from 'path'; +import { v4 } from 'uuid'; + +export const diskStorage = multer.diskStorage({ + destination: function (_req, _file, cb) { + cb(null, path.join(__dirname, '../fileUpload/videos/')); + }, + filename: function (_req, file, cb) { + const uuid = v4(); + cb(null, `${uuid}${path.extname(file.originalname)}`); + }, +}); +export const upload = multer({ storage: diskStorage }); + +export const diskStorageToImages = multer.diskStorage({ + destination: function (_req, _file, cb) { + const dirPath = path.join(__dirname, '../fileUpload/images/'); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath); + } + cb(null, dirPath); + }, + filename: function (_req, file, cb) { + const uuid = v4(); + cb(null, `${uuid}${path.extname(file.originalname)}`); + }, +}); + +const whitelist = [ + 'application/pdf', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]; +export const fileUplad = multer({ + storage: diskStorageToImages, + fileFilter: (_req, file, cb) => { + if (!whitelist.includes(file.mimetype)) { + return cb(new Error('file is not allowed')); + } + cb(null, true); + }, +}); diff --git a/server/routes/filesRouter.ts b/server/routes/filesRouter.ts new file mode 100644 index 000000000..d2cc94b25 --- /dev/null +++ b/server/routes/filesRouter.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; + +import { UserType } from '../../types/user.type'; +import { uploadFiles } from '../controllers/filesController'; +import { fileUplad } from '../controllers/multer'; +import { authenticate } from '../middlewares/authenticate'; +import { handleErrors } from '../middlewares/handleErrors'; + +export const filesRouter = Router(); + +filesRouter.post('/', fileUplad.array('files'), handleErrors(authenticate(UserType.ADMIN)), uploadFiles);