diff --git a/.env.example b/.env.example index b92f603..b4d4c7e 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,13 @@ OTP_LENGTH=6 OTP_EXPIRATION=15 #DISK STORAGE -DISK_STORAGE_UPLOAD_FOLDER=/home/michee/projects/system-api/file +DISK_STORAGE_UPLOAD_FOLDER=/home/michee/projects/system-api/stock + +#FILES_STORAGE +FILE_STORES=disk,minio +FILE_STORAGE=minio +MINIO_BUCKET=new-xflow-test + +CRYPTAGE_KEY=secret-key diff --git a/.gitignore b/.gitignore index c811f5d..1947187 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules package-lock.json logs -.vscode \ No newline at end of file +.vscode +upload \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index c018a99..746774c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,38 @@ services: + traefik: + image: traefik:v3.1 + container_name: ntw-traefik + restart: always + command: + - --api.insecure=true + - --api.dashboard=true + - --ping=true + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.web-secure.address=:443 + - --log.level=DEBUG + - --certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory + - --certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.myresolver.acme.tlschallenge=true + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: + - "/letsencrypt:/letsencrypt" + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - ./certs:/certs + healthcheck: + test: [ "CMD", "traefik", "healthcheck", "--ping" ] + interval: 30s + retries: 10 + labels: + - traefik.enable=true + - traefik.http.routers.dashboard.rule=Host(`traefik.localhost`) + - traefik.http.routers.dashboard.service=api@internal + - traefik.http.routers.dashboard.entrypoints=web + app: build: . container_name: ntw-app @@ -6,6 +40,10 @@ services: - "${PORT}:${PORT}" env_file: - .env + labels: + - traefik.enable=true + - traefik.http.routers.app.rule=Host(`app.localhost`) + - traefik.http.services.app.loadbalancer.server.port=${PORT} depends_on: - mongo - redis @@ -33,7 +71,7 @@ services: container_name: ntw-minio command: server /data --console-address ":${MINIO_CONSOLE_PORT}" ports: - - "${MINIO_EXT_API_PORT}:${MINIO_API_PORT}" + - "${MINIO_EXT_API_PORT}:${MINIO_API_PORT}" - "${MINIO_EXT_CONSOLE_PORT}:${MINIO_CONSOLE_PORT}" environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY} @@ -53,6 +91,11 @@ services: timeout: 10s retries: 3 + labels: + - traefik.enable=true + - traefik.http.routers.app.rule=Host(`mail.localhost`) + - traefik.http.services.app.loadbalancer.server.port=${MAILDEV_WEBAPP_PORT} + volumes: mongo-data: minio-data: diff --git a/package.json b/package.json index 6dd81e3..ac9b01c 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "minio": "^8.0.0", "mongoose": "^8.4.3", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.14", "rate-limiter-flexible": "^5.0.3", "tsconfig-paths": "^4.2.0", @@ -95,6 +96,7 @@ "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", "@types/nodemailer": "^6.4.15", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^5.57.1", diff --git a/src/apps/demo/core/api/controllers/todo.controller.ts b/src/apps/demo/core/api/controllers/todo.controller.ts index 99c20ce..126b9d8 100644 --- a/src/apps/demo/core/api/controllers/todo.controller.ts +++ b/src/apps/demo/core/api/controllers/todo.controller.ts @@ -1,13 +1,16 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { Request, Response, NextFunction } from 'express'; import { ApiResponse, ErrorResponse, ErrorResponseType, + SuccessResponseType, } from '@nodesandbox/response-kit'; import { TodoService } from 'apps/demo/core/business'; -import { CreateTodoRequestSchema } from '../dtos'; +import { IFileModel } from 'apps/files'; +import FileService from 'apps/files/core/business/services/file.service'; +import { NextFunction, Request, Response } from 'express'; import { sanitize } from 'helpers'; +import { CreateTodoRequestSchema } from '../dtos'; /** * Controller to handle the operations related to the Todo resource. @@ -26,11 +29,21 @@ export class TodoController { ): Promise { try { const _payload = sanitize(req.body, CreateTodoRequestSchema); + const image = req.file; + const service = CONFIG.fs.defaultStore; if (!_payload.success) { throw _payload.error; } + const fileService = new FileService(); + const todoImage = (await fileService.createFIle( + service, + image, + )) as SuccessResponseType; + + _payload.data.image = todoImage.document?._id; + const response = await TodoService.create(_payload.data); if (!response.success) { @@ -59,6 +72,9 @@ export class TodoController { ): Promise { try { const filters = req.query; // Extract query params for filtering. + + console.log('⚡⚡⚡⚡☂️☂️☂️☂️ filters : ', filters); + const response = await TodoService.getTodos(filters); if (response.success) { diff --git a/src/apps/demo/core/api/routes/todo.routes.ts b/src/apps/demo/core/api/routes/todo.routes.ts index e37544b..2f96f2d 100644 --- a/src/apps/demo/core/api/routes/todo.routes.ts +++ b/src/apps/demo/core/api/routes/todo.routes.ts @@ -1,13 +1,15 @@ import { Router } from 'express'; +import multer from 'multer'; import { TodoController } from '../controllers'; const router = Router(); +const upload = multer(); /** * Route for creating a new Todo * POST /todos */ -router.post('/', TodoController.createTodo); +router.post('/', upload.single('file'), TodoController.createTodo); /** * Route for retrieving all Todos, filtered by query parameters diff --git a/src/apps/demo/core/business/services/todo.service.ts b/src/apps/demo/core/business/services/todo.service.ts index 9e9793c..8b7f888 100644 --- a/src/apps/demo/core/business/services/todo.service.ts +++ b/src/apps/demo/core/business/services/todo.service.ts @@ -1,11 +1,11 @@ -import { TodoRepository } from '../repositories'; -import { ITodoModel, TodoModel } from 'apps/demo/core/domain'; -import { parseSortParam } from 'helpers'; +import { BaseService } from '@nodesandbox/repo-framework'; import { ErrorResponseType, SuccessResponseType, } from '@nodesandbox/response-kit'; -import { BaseService } from '@nodesandbox/repo-framework'; +import { ITodoModel, TodoModel } from 'apps/demo/core/domain'; +import { parseSortParam } from 'helpers'; +import { TodoRepository } from '../repositories'; class TodoService extends BaseService { constructor() { @@ -13,7 +13,7 @@ class TodoService extends BaseService { super(todoRepo, true, [ /*'attribute_to_populate'*/ ]); // This will populate the entity field - this.allowedFilterFields = ['dueDate', 'completed', 'priority']; // To filter on these fields, we need to set this + this.allowedFilterFields = ['dueDate', 'completed', 'priority', 'image']; // To filter on these fields, we need to set this this.searchFields = ['title', 'description']; // This will use the search keyword /** @@ -34,6 +34,7 @@ class TodoService extends BaseService { search = '', priority, completed, + image, upcoming, } = filters; @@ -42,6 +43,12 @@ class TodoService extends BaseService { if (priority) query.priority = priority; if (completed !== undefined) query.completed = completed === 'true'; + if (image !== 'true') { + query.image = { $exists: false }; + } else { + query.image = { $exists: true, $ne: null }; + } + // Handle upcoming due dates if (upcoming) { const days = parseInt(upcoming as string) || 7; @@ -50,6 +57,8 @@ class TodoService extends BaseService { query.dueDate = { $gte: new Date(), $lte: futureDate }; } + console.log('⚔️⚔️⚔️⚔️⚔️⚔️ query : ', query); + // Parse sorting parameter using helper function const sortObject = sort ? parseSortParam(sort) : {}; diff --git a/src/apps/demo/core/domain/models/todo.model.ts b/src/apps/demo/core/domain/models/todo.model.ts index be6ceb0..0fcbf50 100644 --- a/src/apps/demo/core/domain/models/todo.model.ts +++ b/src/apps/demo/core/domain/models/todo.model.ts @@ -18,6 +18,9 @@ const todoSchema = createBaseSchema( description: { type: String, }, + image: { + type: String, + }, completed: { type: Boolean, default: false, diff --git a/src/apps/demo/core/domain/types/todo.ts b/src/apps/demo/core/domain/types/todo.ts index 69744fe..59acef2 100644 --- a/src/apps/demo/core/domain/types/todo.ts +++ b/src/apps/demo/core/domain/types/todo.ts @@ -6,6 +6,7 @@ export type TodoPriority = 'low' | 'medium' | 'high'; export interface ITodo { title: string; description?: string; + image: string; completed: boolean; dueDate?: Date; priority: TodoPriority; diff --git a/src/apps/files/core/api/controllers/file.controller.ts b/src/apps/files/core/api/controllers/file.controller.ts new file mode 100644 index 0000000..2a68f78 --- /dev/null +++ b/src/apps/files/core/api/controllers/file.controller.ts @@ -0,0 +1,140 @@ +import FileService from 'apps/files/core/business/services/file.service'; +import fs from 'fs'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + ApiResponse, + ErrorResponseType, + SuccessResponseType, +} from '@nodesandbox/response-kit'; +import { IFileModel } from 'apps/files'; +import { NextFunction, Request, Response } from 'express'; + +export class FileController { + /** + * @param req + * @param res + * @param next + */ + static async createFile( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const payload = req.file; + const service = CONFIG.fs.defaultStore; + + const fileService = new FileService(); + const response = await fileService.createFIle(service, payload); + if (!response.success) { + throw response.error; + } + + ApiResponse.success(res, response, 201); + } catch (error) { + ApiResponse.error(res, { + success: false, + error: error, + } as ErrorResponseType); + } + } + + /** + * @param req + * @param res + * @param next + */ + static async getFileById( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const fileId = req.params.fileId; + const service = CONFIG.fs.defaultStore; + + const fileService = new FileService(); + const response = (await fileService.getFile( + service, + fileId, + )) as SuccessResponseType; + + if (response.success) { + ApiResponse.success(res, response); + } else { + throw response.error; + } + } catch (error) { + ApiResponse.error(res, { + success: false, + error: error, + } as ErrorResponseType); + } + } + + static async downloadFile( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const fileId = req.params.fileId; + const service = CONFIG.fs.defaultStore; + + const fileService = new FileService(); + const response = (await fileService.sendFile( + service, + fileId, + )) as SuccessResponseType; + + if (!response.success) { + throw response.error; + } + + // TODO Corriger le telechargement des vidéos + res.writeHead(200, { + 'content-type': response.document?.mimetype, + 'content-length': response.document?.size, + }); + + const filepath = response.document?.path as string; + const file = fs.readFileSync(filepath); + res.end(file); + } catch (error) { + ApiResponse.error(res, { + success: false, + error: error, + } as ErrorResponseType); + } + } + + /** + * @param req + * @param res + * @param next + */ + static async deleteFile( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const fileId = req.params.fileId; + const service = CONFIG.fs.defaultStore; + + const fileService = new FileService(); + const response = await fileService.deleteFile(service, fileId); + + if (!response.success) { + throw response.error; + } + + ApiResponse.success(res, response, 201); + } catch (error) { + ApiResponse.error(res, { + success: false, + error: error, + } as ErrorResponseType); + } + } +} diff --git a/src/apps/files/core/api/controllers/index.ts b/src/apps/files/core/api/controllers/index.ts new file mode 100644 index 0000000..c28de2a --- /dev/null +++ b/src/apps/files/core/api/controllers/index.ts @@ -0,0 +1 @@ +export * from './file.controller'; diff --git a/src/apps/files/core/api/index.ts b/src/apps/files/core/api/index.ts new file mode 100644 index 0000000..8bd7daa --- /dev/null +++ b/src/apps/files/core/api/index.ts @@ -0,0 +1,3 @@ +/* eslint-disable prettier/prettier */ +export * from './controllers'; +export * from './routes'; diff --git a/src/apps/files/core/api/routes/file.route.ts b/src/apps/files/core/api/routes/file.route.ts new file mode 100644 index 0000000..5fb7c23 --- /dev/null +++ b/src/apps/files/core/api/routes/file.route.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { FileController } from '../controllers'; + +const router = Router(); + +const upload = multer(); + +router.post('/', upload.single('file'), FileController.createFile); + +router.get('/:fileId', FileController.getFileById); + +router.get('/:fileId/download', FileController.downloadFile); + +router.delete('/:fileId', FileController.deleteFile); + +export default router; diff --git a/src/apps/files/core/api/routes/index.ts b/src/apps/files/core/api/routes/index.ts new file mode 100644 index 0000000..1833271 --- /dev/null +++ b/src/apps/files/core/api/routes/index.ts @@ -0,0 +1 @@ +export { default as FileRoutes } from './file.route'; diff --git a/src/apps/files/core/business/index.ts b/src/apps/files/core/business/index.ts new file mode 100644 index 0000000..7d8a10a --- /dev/null +++ b/src/apps/files/core/business/index.ts @@ -0,0 +1,3 @@ +/* eslint-disable prettier/prettier */ +export * from './repositories'; +export * from './services'; diff --git a/src/apps/files/core/business/repositories/file.repo.ts b/src/apps/files/core/business/repositories/file.repo.ts new file mode 100644 index 0000000..a9902b7 --- /dev/null +++ b/src/apps/files/core/business/repositories/file.repo.ts @@ -0,0 +1,9 @@ +import { BaseRepository } from '@nodesandbox/repo-framework'; +import { Model } from 'mongoose'; +import { IFileModel } from '../../domain'; + +export class FileRepository extends BaseRepository { + constructor(model: Model) { + super(model); + } +} diff --git a/src/apps/files/core/business/repositories/index.ts b/src/apps/files/core/business/repositories/index.ts new file mode 100644 index 0000000..c0851bc --- /dev/null +++ b/src/apps/files/core/business/repositories/index.ts @@ -0,0 +1 @@ +export * from './file.repo'; diff --git a/src/apps/files/core/business/services/file.service.ts b/src/apps/files/core/business/services/file.service.ts new file mode 100644 index 0000000..a40f114 --- /dev/null +++ b/src/apps/files/core/business/services/file.service.ts @@ -0,0 +1,267 @@ +/* eslint-disable prettier/prettier */ +import { BaseService } from '@nodesandbox/repo-framework'; +import { + ErrorResponse, + ErrorResponseType, + SuccessResponseType, +} from '@nodesandbox/response-kit'; +import { decryptAES, encryptAES } from 'helpers'; +import { storage } from 'modules/shared/storage'; +import { FileModel, IFileModel } from '../../domain'; +import { FileRepository } from '../repositories'; + +class FileService extends BaseService { + constructor() { + const fileRepo = new FileRepository(FileModel); + super(fileRepo, false); + + this.allowedFilterFields = ['type', 'storageType']; + this.searchFields = ['name', 'extension', 'size', 'type']; + } + + async createFIle( + service: any, + file: any, + ): Promise | ErrorResponseType> { + try { + if (service !== CONFIG.minio.host) { + const meta = file; + const buffer = file.buffer; + + const payload = await storage.disk.uploadFile(buffer); + + const insertFile = { + hash: payload.data.hash, + size: payload.data.size, + type: payload.data.type, + extension: payload.data.extension, + metadata: meta, + }; + + const response = await this.repository.create(insertFile); + + return { success: true, document: response }; + } else { + const meta = file; + + await storage.minio.createBucket(CONFIG.minio.bucketName); + const payload = await storage.minio.uploadBuffer( + CONFIG.minio.bucketName, + meta.originalname, + meta.buffer, + { ...meta, buffer: undefined }, + ); + if (!payload.success) { + throw payload.error; + } + const hashedName = encryptAES( + meta.originalname, + process.env.CRYPTAGE_KEY || 'secret-key', + ); + const insertFile = { + hash: hashedName, + size: meta.size, + type: meta.mimetype, + metadata: meta, + url: payload.data, + }; + const response = await this.repository.create(insertFile); + + return { success: true, document: response }; + } + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + async getFile( + service: any, + fileId: any, + ): Promise | ErrorResponseType> { + try { + if (service !== CONFIG.minio.host) { + const payload = await this.repository.findOne({ _id: fileId }); + // TODO Gerer les erreurs liés au fichier introuvable avec (if) apres la modification du package ErrorResponseType + + const hash = payload?.hash as string; + + const fileDiskName = decryptAES( + hash, + process.env.CRYPTAGE_KEY || 'secret-key', + ); + + const response = await storage.disk.getFile(fileDiskName); + + return { success: true, document: response.data }; + } else { + const file = await this.repository.findOne({ _id: fileId }); + + if (!file) { + throw file; + } + + file.originalname = ( + file.metadata as { originalname: string } + ).originalname; + const { originalname } = file; + + const payload = await storage.minio.getFileStats( + CONFIG.minio.bucketName, + originalname, + ); + + return { success: true, document: payload.data }; + } + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + async sendFile( + service: any, + fileId: any, + ): Promise | ErrorResponseType> { + try { + if (service !== CONFIG.minio.host) { + const file = await this.repository.findOne({ _id: fileId }); + + // TODO Gerer les erreurs liés au fichier introuvable avec (if) apres la modification du package ErrorResponseType + if (!file) { + throw file; + } + const fileDiskName = decryptAES( + file.hash, + process.env.CRYPTAGE_KEY || 'secret-key', + ); + + const response = await storage.disk.getFile(fileDiskName); + if (!response.success) { + throw response.error; + } + + response.data.mimetype = ( + file.metadata as { mimetype: string } + ).mimetype; + + return { success: true, document: response.data }; + } else { + const file = await this.repository.findOne({ _id: fileId }); + + if (!file) { + throw file; + } + + file.originalname = ( + file.metadata as { originalname: string } + ).originalname; + const { originalname } = file; + + const payload = await storage.minio.downloadFile( + CONFIG.minio.bucketName, + originalname, + file.url, + ); + + const response = { + path: payload.data?.path, + mimetype: file.type, + size: file.size, + } as any; + + return { success: true, document: response }; + } + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } + + async deleteFile( + service: any, + fileId: any, + ): Promise | ErrorResponseType> { + try { + if (service !== CONFIG.minio.host) { + const payload = await this.repository.findOne({ _id: fileId }); + + // TODO Gerer les erreurs liés au fichier introuvable avec (if) apres la modification du package ErrorResponseType + + const hash = payload?.hash as string; + + const fileDiskName = decryptAES( + hash, + process.env.CRYPTAGE_KEY || 'secret-key', + ); + + await storage.disk.deleteFile(fileDiskName); + + await this.repository.delete({ _id: fileId }); + + return { success: true }; + } else { + const file = await this.repository.findOne({ _id: fileId }); + + if (!file) { + throw file; + } + + file.originalname = ( + file.metadata as { originalname: string } + ).originalname; + const { originalname } = file; + + const payload = await storage.minio.deleteSingleFile( + CONFIG.minio.bucketName, + originalname, + ); + + if (!payload) { + throw payload; + } + + await this.repository.delete({ _id: fileId }); + + return { success: true }; + } + } catch (error) { + return { + success: false, + error: + error instanceof ErrorResponse + ? error + : new ErrorResponse( + 'INTERNAL_SERVER_ERROR', + (error as Error).message, + ), + }; + } + } +} + +export default FileService; diff --git a/src/apps/files/core/business/services/index.ts b/src/apps/files/core/business/services/index.ts new file mode 100644 index 0000000..3c44127 --- /dev/null +++ b/src/apps/files/core/business/services/index.ts @@ -0,0 +1 @@ +export * from './file.service'; diff --git a/src/apps/files/core/domain/index.ts b/src/apps/files/core/domain/index.ts new file mode 100644 index 0000000..685cb9a --- /dev/null +++ b/src/apps/files/core/domain/index.ts @@ -0,0 +1,3 @@ +/* eslint-disable prettier/prettier */ +export * from './models'; +export * from './types'; diff --git a/src/apps/files/core/domain/models/file.model.ts b/src/apps/files/core/domain/models/file.model.ts new file mode 100644 index 0000000..b83a3d2 --- /dev/null +++ b/src/apps/files/core/domain/models/file.model.ts @@ -0,0 +1,85 @@ +import { BaseModel, createBaseSchema } from '@nodesandbox/repo-framework'; +import { IFileModel, IMetaDataModel } from '../types'; + +const FILE_MODEL_NAME = 'File'; +const METADATA_MODEL_NAME = 'File'; + +const metaDataSchema = createBaseSchema( + { + fieldname: { + type: String, + required: true, + }, + originalname: { + type: String, + required: true, + }, + encoding: { + type: String, + required: true, + }, + mimetype: { + type: String, + required: true, + }, + }, + { + modelName: METADATA_MODEL_NAME, + }, +); + +const fileSchema = createBaseSchema( + { + hash: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + extension: { + type: String, + required: false, + }, + size: { + type: Number, + required: true, + }, + storageType: { + type: String, + enum: CONFIG.fs.stores, + default: CONFIG.fs.defaultStore, + required: false, + }, + locationData: { + path: { type: String }, + minio: { + bucket: { type: String }, + objectName: { type: String }, + }, + }, + url: { + type: String, + }, + presignedUrlExpiration: { + type: Date, + }, + metadata: { + type: metaDataSchema, + }, + downloadCount: { + type: Number, + }, + }, + { + modelName: FILE_MODEL_NAME, + }, +); + +const FileModel = new BaseModel( + FILE_MODEL_NAME, + fileSchema, +).getModel(); + +export { FileModel }; diff --git a/src/apps/files/core/domain/models/index.ts b/src/apps/files/core/domain/models/index.ts new file mode 100644 index 0000000..6ef3f97 --- /dev/null +++ b/src/apps/files/core/domain/models/index.ts @@ -0,0 +1 @@ +export * from './file.model'; diff --git a/src/apps/files/core/domain/types/file.ts b/src/apps/files/core/domain/types/file.ts new file mode 100644 index 0000000..bbd256d --- /dev/null +++ b/src/apps/files/core/domain/types/file.ts @@ -0,0 +1,24 @@ +import { IBaseModel } from '@nodesandbox/repo-framework'; +import { Document } from 'mongoose'; + +export type FileStorageType = keyof typeof CONFIG.fs.stores; + +export interface IFile { + hash: string; + type: string; + extension: string; + size: number; + storageType: FileStorageType; + locationData: string; + path: string; + minio: string; + url: string; + presignedUrlExpiration: Date; + metadata: {}; + description: string; + tags: Array; + accessRoles: Array; + downloadCount: string; +} + +export interface IFileModel extends IFile, IBaseModel, Document {} diff --git a/src/apps/files/core/domain/types/index.ts b/src/apps/files/core/domain/types/index.ts new file mode 100644 index 0000000..0ca0d69 --- /dev/null +++ b/src/apps/files/core/domain/types/index.ts @@ -0,0 +1,3 @@ +/* eslint-disable prettier/prettier */ +export * from './file'; +export * from './metaData'; diff --git a/src/apps/files/core/domain/types/metaData.ts b/src/apps/files/core/domain/types/metaData.ts new file mode 100644 index 0000000..90f9ebb --- /dev/null +++ b/src/apps/files/core/domain/types/metaData.ts @@ -0,0 +1,11 @@ +import { IBaseModel } from '@nodesandbox/repo-framework'; +import { Document } from 'mongoose'; + +export interface IMetaData { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; +} + +export interface IMetaDataModel extends IBaseModel, Document {} diff --git a/src/apps/files/core/index.ts b/src/apps/files/core/index.ts new file mode 100644 index 0000000..9526c2c --- /dev/null +++ b/src/apps/files/core/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable prettier/prettier */ +export * from './api'; +export * from './business'; +export * from './domain'; diff --git a/src/apps/files/index.ts b/src/apps/files/index.ts new file mode 100644 index 0000000..4b0e041 --- /dev/null +++ b/src/apps/files/index.ts @@ -0,0 +1 @@ +export * from './core'; diff --git a/src/apps/index.ts b/src/apps/index.ts index 57ebf74..5b300e3 100644 --- a/src/apps/index.ts +++ b/src/apps/index.ts @@ -1,5 +1,7 @@ +/* eslint-disable prettier/prettier */ /** * Export all the apps routes here */ export * from './demo'; +export * from './files'; diff --git a/src/core/config/index.ts b/src/core/config/index.ts index fa8fb5f..237d85f 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -49,6 +49,7 @@ export interface Config { apiPort: number; consolePort: number; useSSL: boolean; + bucketName: string; }; mail: { host: string; @@ -74,6 +75,10 @@ export interface Config { { code: string; title: string; description: string; message: string } >; }; + fs: { + stores?: Array; + defaultStore: string; + }; } export class ConfigService { @@ -134,6 +139,7 @@ export class ConfigService { apiPort: parseInt(process.env.MINIO_API_PORT || '9000', 10), consolePort: parseInt(process.env.MINIO_EXT_CONSOLE_PORT || '5050', 10), useSSL: process.env.MINIO_USE_SSL === 'true', + bucketName: process.env.MINIO_BUCKET || 'my-new-bucket', }, mail: { host: @@ -225,6 +231,10 @@ export class ConfigService { }, }, }, + fs: { + stores: process.env.FILE_STORES?.split(','), + defaultStore: process.env.FILE_STORAGE || 'disk', + }, }; } diff --git a/src/helpers/utils/crypto.ts b/src/helpers/utils/crypto.ts new file mode 100644 index 0000000..6d3d422 --- /dev/null +++ b/src/helpers/utils/crypto.ts @@ -0,0 +1,32 @@ +import * as crypto from 'crypto'; + +/** + * @param text + * @param secretKey + * @returns + */ + +export function encryptAES(text: string, secretKey: string): string { + const iv = crypto.randomBytes(16); + const key = crypto.scryptSync(secretKey, 'salt', 32); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; +} + +/** + * @param encryptedText + * @param secretKey + * @returns + */ +export function decryptAES(encryptedText: string, secretKey: string): string { + const textParts = encryptedText.split(':'); + const iv = Buffer.from(textParts.shift() as string, 'hex'); + const encrypted = textParts.join(':'); + const key = crypto.scryptSync(secretKey, 'salt', 32); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} diff --git a/src/helpers/utils/file.ts b/src/helpers/utils/file.ts index c5d1330..be7cf2b 100644 --- a/src/helpers/utils/file.ts +++ b/src/helpers/utils/file.ts @@ -1,10 +1,23 @@ const magicNumbers: { type: string; signature?: number[] }[] = [ + // fichiers { type: 'JPEG', signature: [0xff, 0xd8, 0xff] }, { type: 'PNG', signature: [0x89, 0x50, 0x4e, 0x47] }, { type: 'GIF', signature: [0x47, 0x49, 0x46, 0x38] }, { type: 'PDF', signature: [0x25, 0x50, 0x44, 0x46] }, { type: 'ZIP', signature: [0x50, 0x4b, 0x03, 0x04] }, { type: 'TXT' }, + // Vidéos + { type: 'MP4', signature: [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70] }, + { type: 'MP4', signature: [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70] }, + { type: 'AVI', signature: [0x52, 0x49, 0x46, 0x46] }, + { type: 'MKV', signature: [0x1a, 0x45, 0xdf, 0xa3] }, + { type: 'MOV', signature: [0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70] }, + // Audio + { type: 'MP3', signature: [0x49, 0x44, 0x33] }, + { type: 'WAV', signature: [0x52, 0x49, 0x46, 0x46] }, + { type: 'FLAC', signature: [0x66, 0x4c, 0x61, 0x43] }, + { type: 'AAC', signature: [0xff, 0xf1] }, + { type: 'OGG', signature: [0x4f, 0x67, 0x67, 0x53] }, ]; export const detectFileType = async (buffer: Buffer): Promise => { diff --git a/src/helpers/utils/index.ts b/src/helpers/utils/index.ts index 4393854..736991a 100644 --- a/src/helpers/utils/index.ts +++ b/src/helpers/utils/index.ts @@ -1,4 +1,6 @@ /* eslint-disable prettier/prettier */ +export * from './crypto'; +export { decryptAES, encryptAES } from './crypto'; export * from './file'; export * from './generator'; export * from './string'; diff --git a/src/modules/router/index.ts b/src/modules/router/index.ts index d8ce7f7..19df553 100644 --- a/src/modules/router/index.ts +++ b/src/modules/router/index.ts @@ -1,4 +1,4 @@ -import { TodoRoutes } from 'apps'; +import { FileRoutes, TodoRoutes } from 'apps'; import { Router } from 'express'; import { DevRoutes } from 'modules/features'; @@ -16,5 +16,6 @@ export class RouterModule { private static initializeRoutes(): void { RouterModule.router.use('', DevRoutes); RouterModule.router.use('/todos', TodoRoutes); + RouterModule.router.use('/file', FileRoutes); } } diff --git a/src/modules/shared/storage/disk/index.ts b/src/modules/shared/storage/disk/index.ts index f9c6cc1..1c12512 100644 --- a/src/modules/shared/storage/disk/index.ts +++ b/src/modules/shared/storage/disk/index.ts @@ -4,15 +4,16 @@ dotenv.config(); import crypto from 'crypto'; import fs, { promises as fsPromise } from 'fs'; import path from 'path'; -import { detectFileType } from '../../../../helpers/utils/file'; +import { detectFileType, encryptAES } from '../../../../helpers/utils'; import { FileMetadata } from './types'; export class DiskStorageService { - private static uploadDir: string = path.resolve( - process.env.DISK_STORAGE_UPLOAD_FOLDER || 'uploadtest', + // private static uploadDir: string = path.resolve('./upload'); + private uploadDir: string = path.resolve( + process.env.DISK_STORAGE_UPLOAD_FOLDER || './upload', ); - private static handleResponse( + private handleResponse( success: boolean, message: string, code: number, @@ -28,20 +29,27 @@ export class DiskStorageService { }; } - static async CreateUploadFolder() { + async CreateUploadFolder() { try { if (fs.existsSync(this.uploadDir)) { return this.handleResponse(false, 'Dossier existant', 409); } - fs.mkdirSync(this.uploadDir); + fs.mkdirSync(this.uploadDir, { recursive: true }); return this.handleResponse(true, 'Dossier créer avec succès', 201); } catch (error) { - return this.handleResponse(false, 'Erreur lors de la création', 500); + console.error('Erreur lors de la création du dossier :', error); + return this.handleResponse( + false, + 'Erreur lors de la création', + 500, + undefined, + error as Error, + ); } } - static async uploadFile(contentBuffer: Buffer): Promise { + async uploadFile(contentBuffer: Buffer): Promise { try { await this.CreateUploadFolder(); @@ -57,19 +65,25 @@ export class DiskStorageService { await fsPromise.writeFile(filePath, contentBuffer); + const hashedName = encryptAES( + fileId, + process.env.CRYPTAGE_KEY || 'secret-key', + ); + const fileData: FileMetadata = { - name: fileId, + // name: fileId, size: contentBuffer.length, type: fileType, extension: `.${fileType || 'bin'}`, - hash: hash, + hash: hashedName, + path: filePath, }; return this.handleResponse( true, 'FIchier uploader avec succes', 201, - fileData.name, + fileData, ); } catch (error) { return this.handleResponse( @@ -82,7 +96,7 @@ export class DiskStorageService { } } - static async getFile(fileId: string): Promise { + async getFile(fileId: string): Promise { try { const filePath = path.join(this.uploadDir, fileId); const contentBuffer = await fsPromise.readFile(filePath); @@ -94,6 +108,7 @@ export class DiskStorageService { size: contentBuffer.length, type: fileType, extension: fileType, + path: filePath, hash: crypto.createHash('sha256').update(contentBuffer).digest('hex'), }; @@ -114,7 +129,7 @@ export class DiskStorageService { } } - static async listFiles(): Promise { + async listFiles(): Promise { try { const files = await fsPromise.readdir(this.uploadDir); @@ -130,7 +145,33 @@ export class DiskStorageService { } } - static async deleteFile(fileId: string): Promise { + async updateFile(fileId: any, newContent: any): Promise { + try { + const file = await this.getFile(fileId); + if (!file.error) { + throw file.error; + } + + const updateFile = await fsPromise.writeFile(this.uploadDir, newContent); + + return this.handleResponse( + true, + 'La modification du nom du fichier a été effectuer avec succès', + 201, + updateFile, + ); + } catch (error) { + return this.handleResponse( + false, + 'Erreur lors de la modification du nom', + 500, + undefined, + error as Error, + ); + } + } + + async deleteFile(fileId: string): Promise { try { const filePath = path.join(this.uploadDir, fileId); await fsPromise.unlink(filePath); @@ -146,7 +187,7 @@ export class DiskStorageService { } } - static async emptyDirectory(): Promise { + async emptyDirectory(): Promise { try { const files = await fsPromise.readdir(this.uploadDir); const deleteFile = files.map((files) => @@ -170,7 +211,7 @@ export class DiskStorageService { } } - static async deleteDirectory(): Promise { + async deleteDirectory(): Promise { try { await fsPromise.rmdir(this.uploadDir); @@ -190,7 +231,7 @@ export class DiskStorageService { } } - static async copyFile(sourceId: string, destinationId: string): Promise { + async copyFile(sourceId: string, destinationId: string): Promise { try { const sourcePath = path.join(this.uploadDir, sourceId); const destinationPath = path.join(this.uploadDir, destinationId); @@ -211,27 +252,4 @@ export class DiskStorageService { ); } } - - static async moveFile(sourceId: string, destinationId: string): Promise { - try { - const sourcePath = path.join(this.uploadDir, sourceId); - const destinationPath = path.join(this.uploadDir, destinationId); - const newName = await fsPromise.rename(sourcePath, destinationPath); - - return this.handleResponse( - true, - 'La modification du nom du fichier a été effectuer avec succès', - 201, - newName, - ); - } catch (error) { - return this.handleResponse( - false, - 'Erreur lors de la modification du nom', - 500, - undefined, - error as Error, - ); - } - } } diff --git a/src/modules/shared/storage/disk/tests/disk-storage.spec.ts b/src/modules/shared/storage/disk/tests/disk-storage.spec.ts index 5e29ff8..46911b3 100644 --- a/src/modules/shared/storage/disk/tests/disk-storage.spec.ts +++ b/src/modules/shared/storage/disk/tests/disk-storage.spec.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { DiskStorageService } from '..'; +import { storage } from '../..'; describe('DiskStorageService', () => { const TEST_UPLOAD_DIR = path.resolve( @@ -24,13 +24,13 @@ describe('DiskStorageService', () => { beforeAll(async () => { if (!fs.existsSync(TEST_UPLOAD_DIR)) { - await DiskStorageService.CreateUploadFolder(); + await storage.disk.CreateUploadFolder(); } }); afterAll(async () => { try { - await Promise.all(await DiskStorageService.deleteDirectory()); + await Promise.all(await storage.disk.deleteDirectory()); } catch (error) { console.error('Cleanup error:', error); } @@ -38,7 +38,7 @@ describe('DiskStorageService', () => { describe('uploadFile', () => { it('should upload a file successfully', async () => { - const result = await DiskStorageService.uploadFile(TEST_FILE_CONTENT); + const result = await storage.disk.uploadFile(TEST_FILE_CONTENT); expect(result.success).toBe(true); expect(result.code).toBe(201); @@ -53,13 +53,12 @@ describe('DiskStorageService', () => { describe('getFile', () => { beforeEach(async () => { - const uploadResult = - await DiskStorageService.uploadFile(TEST_FILE_CONTENT); + const uploadResult = await storage.disk.uploadFile(TEST_FILE_CONTENT); testFileId = uploadResult.data; }); it('should retrieve file metadata successfully', async () => { - const result = await DiskStorageService.getFile(testFileId); + const result = await storage.disk.getFile(testFileId); expect(result.success).toBe(true); expect(result.code).toBe(200); @@ -69,7 +68,7 @@ describe('DiskStorageService', () => { }); it('should handle non-existent files', async () => { - const result = await DiskStorageService.getFile('non-existent-file'); + const result = await storage.disk.getFile('non-existent-file'); expect(result.success).toBe(false); expect(result.code).toBe(500); @@ -78,12 +77,12 @@ describe('DiskStorageService', () => { describe('listFiles', () => { beforeEach(async () => { - await DiskStorageService.uploadFile(Buffer.from('File 1')); - await DiskStorageService.uploadFile(Buffer.from('File 2')); + await storage.disk.uploadFile(Buffer.from('File 1')); + await storage.disk.uploadFile(Buffer.from('File 2')); }); it('should list all files in directory', async () => { - const result = await DiskStorageService.listFiles(); + const result = await storage.disk.listFiles(); expect(result.success).toBe(true); expect(result.code).toBe(200); @@ -94,13 +93,12 @@ describe('DiskStorageService', () => { describe('deleteFile', () => { beforeEach(async () => { - const uploadResult = - await DiskStorageService.uploadFile(TEST_FILE_CONTENT); + const uploadResult = await storage.disk.uploadFile(TEST_FILE_CONTENT); testFileId = uploadResult.data; }); it('should delete file successfully', async () => { - const result = await DiskStorageService.deleteFile(testFileId); + const result = await storage.disk.deleteFile(testFileId); expect(result.success).toBe(true); expect(result.code).toBe(200); @@ -111,7 +109,7 @@ describe('DiskStorageService', () => { }); it('should handle deletion of non-existent files', async () => { - const result = await DiskStorageService.deleteFile('non-existent-file'); + const result = await storage.disk.deleteFile('non-existent-file'); expect(result.success).toBe(false); expect(result.code).toBe(500); @@ -120,7 +118,7 @@ describe('DiskStorageService', () => { describe('emptyDirectory', () => { it('should empty directory successfully', async () => { - const result = await DiskStorageService.emptyDirectory(); + const result = await storage.disk.emptyDirectory(); expect(result.success).toBe(true); expect(result.code).toBe(200); @@ -134,14 +132,13 @@ describe('DiskStorageService', () => { let sourceId: string; beforeEach(async () => { - const uploadResult = - await DiskStorageService.uploadFile(TEST_FILE_CONTENT); + const uploadResult = await storage.disk.uploadFile(TEST_FILE_CONTENT); sourceId = uploadResult.data; }); it('should copy file successfully', async () => { const destinationId = 'copied-file'; - const result = await DiskStorageService.copyFile(sourceId, destinationId); + const result = await storage.disk.copyFile(sourceId, destinationId); expect(result.success).toBe(true); expect(result.code).toBe(200); @@ -154,37 +151,29 @@ describe('DiskStorageService', () => { }); it('should handle copying non-existent files', async () => { - const result = await DiskStorageService.copyFile('non-existent', 'dest'); + const result = await storage.disk.copyFile('non-existent', 'dest'); expect(result.success).toBe(false); expect(result.code).toBe(500); }); }); - describe('moveFile', () => { - let sourceId: string; - - beforeEach(async () => { - const uploadResult = - await DiskStorageService.uploadFile(TEST_FILE_CONTENT); - sourceId = uploadResult.data; - }); - + describe('updateFile', () => { it('should move file successfully', async () => { - const destinationId = 'moved-file'; - const result = await DiskStorageService.moveFile(sourceId, destinationId); + const uploadResult = await storage.disk.uploadFile(TEST_FILE_CONTENT); + const fileId = uploadResult.data; + + const newContent = 'moved-file'; + const result = await storage.disk.updateFile(fileId, newContent); expect(result.success).toBe(true); expect(result.code).toBe(201); - const sourcePath = path.join(TEST_UPLOAD_DIR, sourceId); - const destinationPath = path.join(TEST_UPLOAD_DIR, destinationId); - expect(fs.existsSync(sourcePath)).toBe(false); - expect(fs.existsSync(destinationPath)).toBe(true); + expect(fs.existsSync(newContent)).toBe(true); }); it('should handle moving non-existent files', async () => { - const result = await DiskStorageService.moveFile('non-existent', 'dest'); + const result = await storage.disk.updateFile('fileId', 'non-existent'); expect(result.success).toBe(false); expect(result.code).toBe(500); diff --git a/src/modules/shared/storage/disk/types/index.ts b/src/modules/shared/storage/disk/types/index.ts index b1162eb..1b81e38 100644 --- a/src/modules/shared/storage/disk/types/index.ts +++ b/src/modules/shared/storage/disk/types/index.ts @@ -1,7 +1,9 @@ export interface FileMetadata { - name: string; + name?: string; size: number; type: string; extension: string; hash: string; + path: string; + mimetype?: string; } diff --git a/src/modules/shared/storage/minio/index.ts b/src/modules/shared/storage/minio/index.ts index 8a6f559..b94f951 100644 --- a/src/modules/shared/storage/minio/index.ts +++ b/src/modules/shared/storage/minio/index.ts @@ -1,6 +1,6 @@ -import { Client, BucketItem, ItemBucketMetadata, CopyConditions } from 'minio'; -import { Readable } from 'stream'; +import { BucketItem, Client, CopyConditions, ItemBucketMetadata } from 'minio'; import * as path from 'path'; +import { Readable } from 'stream'; import { BucketPolicy, FileStats } from './types'; export class MinioStorageService {