diff --git a/package.json b/package.json index 8a3c77e1..a286b223 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/smart-chain-fr/leCoffre-back#readme", "dependencies": { + "@pinata/sdk": "^2.1.0", "@prisma/client": "^4.11.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -47,8 +48,9 @@ "cors": "^2.8.5", "express": "^4.18.2", "jsonwebtoken": "^9.0.0", - "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.37", + "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.38", "module-alias": "^2.2.2", + "multer": "^1.4.5-lts.1", "next": "^13.1.5", "node-cache": "^5.1.2", "node-schedule": "^2.1.1", @@ -65,6 +67,7 @@ "@types/express": "^4.17.16", "@types/jest": "^29.5.0", "@types/jsonwebtoken": "^9.0.1", + "@types/multer": "^1.4.7", "@types/node": "^18.11.18", "@types/node-schedule": "^2.1.0", "@types/uuid": "^9.0.0", diff --git a/src/app/api/super-admin/FilesController.ts b/src/app/api/super-admin/FilesController.ts new file mode 100644 index 00000000..ff0c393c --- /dev/null +++ b/src/app/api/super-admin/FilesController.ts @@ -0,0 +1,153 @@ +import { Response, Request } from "express"; +import { Controller, Delete, Get, Post, Put } from "@ControllerPattern/index"; +import ApiController from "@Common/system/controller-pattern/ApiController"; +import { Service } from "typedi"; +import FilesService from "@Services/private-services/FilesService/FilesService"; +import { Files } from "@prisma/client"; +import { File } from "le-coffre-resources/dist/SuperAdmin"; +import { validateOrReject } from "class-validator"; + +@Controller() +@Service() +export default class FilesController extends ApiController { + constructor(private filesService: FilesService) { + super(); + } + + /** + * @description Get all Files + * @returns File[] list of Files + */ + @Get("/api/v1/super-admin/files") + protected async get(req: Request, response: Response) { + try { + //get query + const query = JSON.parse(req.query["q"] as string); + + //call service to get prisma entity + const prismaEntity = await this.filesService.get(query); + + //Hydrate ressource with prisma entity + const files = File.map(File, prismaEntity, { strategy: "excludeAll" }); + + //success + this.httpSuccess(response, files); + } catch (error) { + this.httpBadRequest(response, error); + return; + } + } + + /** + * @description Create a new File + * @returns File created + */ + @Post("/api/v1/super-admin/files") + protected async post(req: Request, response: Response) { + try { + + //get file + if(!req.file) throw new Error('No file provided') + + //init File resource with request body values + const fileEntity = File.hydrate(JSON.parse(req.body["q"])); + + //validate File + await validateOrReject(fileEntity, { groups: ["createFile"] }); + + //call service to get prisma entity + const prismaEntityCreated = await this.filesService.create(fileEntity, req.file); + + //Hydrate ressource with prisma entity + const fileEntityCreated = File.hydrate(prismaEntityCreated, { + strategy: "excludeAll", + }); + + //success + this.httpSuccess(response, fileEntityCreated); + } catch (error) { + this.httpBadRequest(response, error); + return; + } + } + + /** + * @description Update a specific file + */ + @Put("/api/v1/super-admin/files/:uid") + protected async update(req: Request, response: Response) { + try { + const uid = req.params["uid"]; + if (!uid) { + throw new Error("No uid provided"); + } + + //init File resource with request body values + const fileEntity = File.hydrate(req.body); + + //validate file + await validateOrReject(fileEntity, { groups: ["create"] }); + + //call service to get prisma entity + const prismaEntityUpdated: Files = await this.filesService.update(uid, fileEntity); + + //Hydrate ressource with prisma entity + const file = File.hydrate(prismaEntityUpdated, { strategy: "excludeAll" }); + + //success + this.httpSuccess(response, file); + } catch (error) { + this.httpBadRequest(response, error); + return; + } + } + + /** + * @description Delete a specific File + */ + @Delete("/api/v1/super-admin/files/:uid") + protected async delete(req: Request, response: Response) { + try { + const uid = req.params["uid"]; + if (!uid) { + throw new Error("No uid provided"); + } + + //call service to get prisma entity + const fileEntity: Files = await this.filesService.delete(uid); + + //Hydrate ressource with prisma entity + const file = File.hydrate(fileEntity, { strategy: "excludeAll" }); + + //success + this.httpSuccess(response, file); + } catch (error) { + this.httpBadRequest(response, error); + return; + } + } + + /** + * @description Get a specific File by uid + */ + @Get("/api/v1/super-admin/Files/:uid") + protected async getOneByUid(req: Request, response: Response) { + try { + const uid = req.params["uid"]; + if (!uid) { + throw new Error("No uid provided"); + } + + const fileEntity = await this.filesService.getByUid(uid); + + //Hydrate ressource with prisma entity + const file = File.hydrate(fileEntity, { strategy: "excludeAll" }); + + //success + this.httpSuccess(response, file); + } catch (error) { + this.httpBadRequest(response, error); + return; + } + } +} diff --git a/src/app/index.ts b/src/app/index.ts index 01e173ed..e2b0a51f 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -9,8 +9,8 @@ import DeedTypesController from "./api/super-admin/DeedTypesController"; import DocumentsController from "./api/super-admin/DocumentsController"; import DocumentTypesController from "./api/super-admin/DocumentTypesController"; import IdNotUserInfoController from "./api/idnot-user/UserInfoController"; - import DocumentsControllerCustomer from "./api/customer/DocumentsController"; +import FilesController from "./api/super-admin/FilesController"; /** @@ -28,7 +28,7 @@ export default { Container.get(DocumentsController); Container.get(DocumentTypesController); Container.get(IdNotUserInfoController); - + Container.get(FilesController); Container.get(DocumentsControllerCustomer); }, }; diff --git a/src/app/middlewares/FileHandler.ts b/src/app/middlewares/FileHandler.ts new file mode 100644 index 00000000..3a6138ac --- /dev/null +++ b/src/app/middlewares/FileHandler.ts @@ -0,0 +1,22 @@ +import { NextFunction, Request, Response } from "express"; +import multer from "multer"; + +export default function fileHandler(req: Request, response: Response, next: NextFunction) { + const storage = multer.memoryStorage() + const upload = multer({storage:storage}).single('file'); + + // Here call the upload middleware of multer + upload(req, response, function (err) { + if (err instanceof multer.MulterError) { + // A Multer error occurred when uploading. + const err = new Error('Multer error'); + return next(err) + } else if (err) { + // An unknown error occurred when uploading. + const err = new Error('Server Error') + return next(err) + } + next() + }) +} + diff --git a/src/common/config/variables/Variables.ts b/src/common/config/variables/Variables.ts index 8624a386..43646886 100644 --- a/src/common/config/variables/Variables.ts +++ b/src/common/config/variables/Variables.ts @@ -45,6 +45,18 @@ export class BackendVariables { @IsNotEmpty() public readonly IDNOT_REDIRECT_URL!: string; + @IsNotEmpty() + public readonly PINATA_API_KEY!: string; + + @IsNotEmpty() + public readonly PINATA_API_SECRET!: string; + + @IsNotEmpty() + public readonly PINATA_GATEWAY!: string; + + @IsNotEmpty() + public readonly KEY_DATA!: string; + public constructor() { dotenv.config(); this.DATABASE_PORT = process.env["DATABASE_PORT"]!; @@ -60,6 +72,10 @@ export class BackendVariables { this.IDNOT_CLIENT_ID = process.env["IDNOT_CLIENT_ID"]!; this.IDNOT_CLIENT_SECRET = process.env["IDNOT_CLIENT_SECRET"]!; this.IDNOT_REDIRECT_URL = process.env["IDNOT_REDIRECT_URL"]!; + this.PINATA_API_KEY = process.env["PINATA_API_KEY"]!; + this.PINATA_API_SECRET = process.env["PINATA_API_SECRET"]!; + this.PINATA_GATEWAY = process.env["PINATA_GATEWAY"]!; + this.KEY_DATA = process.env["KEY_DATA"]!; } public async validate() { await validateOrReject(this); diff --git a/src/common/databases/migrations/20230505075245_v1/migration.sql b/src/common/databases/migrations/20230505075245_v1/migration.sql new file mode 100644 index 00000000..c212eab0 --- /dev/null +++ b/src/common/databases/migrations/20230505075245_v1/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `iv` to the `files` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "files" ADD COLUMN "iv" VARCHAR(255) NOT NULL; diff --git a/src/common/databases/migrations/20230505131655_v2/migration.sql b/src/common/databases/migrations/20230505131655_v2/migration.sql new file mode 100644 index 00000000..a7f5aaa6 --- /dev/null +++ b/src/common/databases/migrations/20230505131655_v2/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `file_name` to the `files` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "files" ADD COLUMN "file_name" VARCHAR(255) NOT NULL; diff --git a/src/common/databases/schema.prisma b/src/common/databases/schema.prisma index 08317b33..b1b04496 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -203,6 +203,8 @@ model Files { document Documents @relation(fields: [document_uid], references: [uid], onDelete: Cascade) document_uid String @db.VarChar(255) file_path String @unique @db.VarChar(255) + file_name String @db.VarChar(255) + iv String @db.VarChar(255) created_at DateTime? @default(now()) updated_at DateTime? @updatedAt diff --git a/src/common/databases/seeders/seeder.ts b/src/common/databases/seeders/seeder.ts index 35df0bbd..25dba6f3 100644 --- a/src/common/databases/seeders/seeder.ts +++ b/src/common/databases/seeders/seeder.ts @@ -379,14 +379,18 @@ import { { uid: uidFiles1, document_uid: uidDocument1, + file_name: "fileName1", file_path: "https://www.google1.com", + iv: "randomIv1", created_at: new Date(), updated_at: new Date(), }, { uid: uidFiles2, document_uid: uidDocument2, + file_name: "fileName2", file_path: "https://www.google2.com", + iv: "randomIv2", created_at: new Date(), updated_at: new Date(), }, diff --git a/src/common/helpers/ObjectHydrate.ts b/src/common/helpers/ObjectHydrate.ts index 199b8733..92cb3194 100644 --- a/src/common/helpers/ObjectHydrate.ts +++ b/src/common/helpers/ObjectHydrate.ts @@ -10,4 +10,14 @@ export default abstract class ObjectHydrate { return plainToInstance(ClassEntity, from, options); }); } + + // public static fromTypeToRessource(ClassEntity: { new (): T }, from: Record): T { + // const properties = Object.getOwnPropertyNames(ClassEntity); + // const classInstance = new ClassEntity() as T; + // properties.forEach((property) => { + // if (property in from) { + // classInstance[property] = from[property] as T[keyof T]; + // } + // }); + // } } diff --git a/src/common/repositories/FilesRepository.ts b/src/common/repositories/FilesRepository.ts index 7c7d99d8..af5b8044 100644 --- a/src/common/repositories/FilesRepository.ts +++ b/src/common/repositories/FilesRepository.ts @@ -35,8 +35,11 @@ export default class FilesRepository extends BaseRepository { uid: file.document!.uid, }, }, + file_name: file.file_name, file_path: file.file_path, + iv: file.iv }, + include: { document: true } }); } diff --git a/src/entries/App.ts b/src/entries/App.ts index b520f7a0..da76f6ae 100644 --- a/src/entries/App.ts +++ b/src/entries/App.ts @@ -8,6 +8,7 @@ import bodyParser from "body-parser"; // import TezosLink from "@Common/databases/TezosLink"; import errorHandler from "@App/middlewares/ErrorHandler"; import { BackendVariables } from "@Common/config/variables/Variables"; +import fileHandler from "@App/middlewares/FileHandler"; (async () => { try { @@ -22,7 +23,7 @@ import { BackendVariables } from "@Common/config/variables/Variables"; label, port: parseInt(port), rootUrl, - middlwares: [cors({ origin: "*" }), bodyParser.urlencoded({ extended: true }), bodyParser.json()], + middlwares: [cors({ origin: "*" }), fileHandler, bodyParser.urlencoded({ extended: true }), bodyParser.json()], errorHandler, }); diff --git a/src/services/private-services/CryptoService/CryptoService.ts b/src/services/private-services/CryptoService/CryptoService.ts new file mode 100644 index 00000000..23c7624a --- /dev/null +++ b/src/services/private-services/CryptoService/CryptoService.ts @@ -0,0 +1,66 @@ +import BaseService from "@Services/BaseService"; +import { Service } from "typedi"; +import { BackendVariables } from "@Common/config/variables/Variables"; +import crypto from "crypto"; + +@Service() +export default class CryptoService extends BaseService { + private jwkKey: JsonWebKey; + private subtle: SubtleCrypto = crypto.webcrypto.subtle + constructor(protected variables: BackendVariables) { + super(); + this.jwkKey = { + kty: "oct", + k: variables.KEY_DATA, + alg: "A256GCM", + ext: true, + }; + } + + private async getKey() { + return await this.subtle.importKey("jwk", this.jwkKey, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]); + } + + /** + * @description : encrypt data + * @throws {Error} If data cannot be encrypted + */ + public async encrypt(data: string) { + const encodedData = Buffer.from(data); + const iv = crypto.getRandomValues(new Uint8Array(16)); + const key = await this.getKey(); + const cipherData = await this.subtle.encrypt( + { + name: "AES-GCM", + iv, + }, + key, + encodedData, + ); + + const cipherText = Buffer.from(cipherData).toString('base64'); + const ivStringified = Buffer.from(iv).toString('base64'); + + return { cipherText, ivStringified }; + } + + /** + * @description : decrypt data with an initialization vector + * @throws {Error} If data cannot be decrypted + */ + public async decrypt(cipherText: string, ivStringified: string): Promise { + const cipherData = Buffer.from(cipherText, 'base64'); + const iv = Buffer.from(ivStringified, 'base64'); + const key = await this.getKey(); + const decryptedData = await this.subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + key, + cipherData, + ); + + return Buffer.from(decryptedData).toString('utf-8'); + } +} diff --git a/src/services/private-services/FilesService/FilesService.ts b/src/services/private-services/FilesService/FilesService.ts index 67656f16..ce6373df 100644 --- a/src/services/private-services/FilesService/FilesService.ts +++ b/src/services/private-services/FilesService/FilesService.ts @@ -2,10 +2,15 @@ import FilesRepository from "@Repositories/FilesRepository"; import BaseService from "@Services/BaseService"; import { Service } from "typedi"; import { File } from "le-coffre-resources/dist/SuperAdmin" +import CryptoService from "../CryptoService/CryptoService"; +import IpfsService from "../IpfsService/IpfsService"; +//import fs from "fs"; +import { BackendVariables } from "@Common/config/variables/Variables"; +import { Readable } from "stream"; @Service() export default class FilesService extends BaseService { - constructor(private filesRepository: FilesRepository) { + constructor(private filesRepository: FilesRepository, private ipfsService: IpfsService, private variables: BackendVariables, private cryptoService: CryptoService) { super(); } @@ -21,7 +26,12 @@ export default class FilesService extends BaseService { * @description : Create a new file * @throws {Error} If file cannot be created */ - public async create(file: File) { + public async create(file: File, fileData: Express.Multer.File) { + const upload = await this.ipfsService.pinFile(Readable.from(fileData.buffer), fileData.originalname); + const encryptedPath = await this.cryptoService.encrypt(this.variables.PINATA_GATEWAY.concat(upload.IpfsHash)); + file.file_name = fileData.originalname; + file.file_path = encryptedPath.cipherText; + file.iv = encryptedPath.ivStringified; return this.filesRepository.create(file); } @@ -29,10 +39,22 @@ export default class FilesService extends BaseService { * @description : Modify a new file * @throws {Error} If file cannot be modified */ - public async put(uid: string, file: File) { + public async update(uid: string, file: File) { return this.filesRepository.update(uid, file); } + /** + * @description : Delete a file + * @throws {Error} If file cannot be deleted + */ + public async delete(uid: string) { + const fileToUnpin = await this.filesRepository.findOneByUid(uid); + const decryptedFilePath = await this.cryptoService.decrypt(fileToUnpin.file_path, fileToUnpin.iv); + const fileHash= decryptedFilePath.substring(this.variables.PINATA_GATEWAY.length); + await this.ipfsService.unpinFile(fileHash) + return this.filesRepository.delete(uid); + } + /** * @description : Get a file by uid * @throws {Error} If project cannot be created diff --git a/src/services/private-services/IpfsService/IpfsService.ts b/src/services/private-services/IpfsService/IpfsService.ts new file mode 100644 index 00000000..cc0f3df7 --- /dev/null +++ b/src/services/private-services/IpfsService/IpfsService.ts @@ -0,0 +1,30 @@ +import BaseService from "@Services/BaseService"; +import { Service } from "typedi"; +import pinataSDK from "@pinata/sdk"; +import { BackendVariables } from "@Common/config/variables/Variables"; +import { Readable } from "stream"; + +@Service() +export default class FilesService extends BaseService { + private ipfsClient: pinataSDK; + constructor(protected variables: BackendVariables) { + super(); + this.ipfsClient = new pinataSDK({ pinataApiKey: variables.PINATA_API_KEY, pinataSecretApiKey: variables.PINATA_API_SECRET }) + } + + /** + * @description : pin a file + * @throws {Error} If file cannot be pinned + */ + public async pinFile(stream: Readable, fileName: string) { + return this.ipfsClient.pinFileToIPFS(stream, {pinataMetadata : {name: fileName}}); + } + + /** + * @description : unpin a file + * @throws {Error} If file cannot be unpinned + */ + public async unpinFile(hashToUnpin: string) { + return this.ipfsClient.unpin(hashToUnpin); + } +}