diff --git a/package.json b/package.json index 22753401..a286b223 100644 --- a/package.json +++ b/package.json @@ -48,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", @@ -66,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 index 1fdd1696..ff0c393c 100644 --- a/src/app/api/super-admin/FilesController.ts +++ b/src/app/api/super-admin/FilesController.ts @@ -4,7 +4,6 @@ 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 ObjectHydrate from "@Common/helpers/ObjectHydrate"; import { File } from "le-coffre-resources/dist/SuperAdmin"; import { validateOrReject } from "class-validator"; @@ -46,14 +45,18 @@ export default class FilesController extends ApiController { @Post("/api/v1/super-admin/files") protected async post(req: Request, response: Response) { try { - //init File resource with request body values - const fileEntity = File.hydrate(req.body); + //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); + const prismaEntityCreated = await this.filesService.create(fileEntity, req.file); //Hydrate ressource with prisma entity const fileEntityCreated = File.hydrate(prismaEntityCreated, { @@ -114,7 +117,7 @@ export default class FilesController extends ApiController { const fileEntity: Files = await this.filesService.delete(uid); //Hydrate ressource with prisma entity - const file = ObjectHydrate.hydrate(new File(), fileEntity, { strategy: "excludeAll" }); + const file = File.hydrate(fileEntity, { strategy: "excludeAll" }); //success this.httpSuccess(response, file); @@ -138,7 +141,7 @@ export default class FilesController extends ApiController { const fileEntity = await this.filesService.getByUid(uid); //Hydrate ressource with prisma entity - const file = ObjectHydrate.hydrate(new File(), fileEntity, { strategy: "excludeAll" }); + const file = File.hydrate(fileEntity, { strategy: "excludeAll" }); //success this.httpSuccess(response, file); 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/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 9f156af6..b1b04496 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -203,6 +203,7 @@ 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 ab450e9f..25dba6f3 100644 --- a/src/common/databases/seeders/seeder.ts +++ b/src/common/databases/seeders/seeder.ts @@ -379,6 +379,7 @@ import { { uid: uidFiles1, document_uid: uidDocument1, + file_name: "fileName1", file_path: "https://www.google1.com", iv: "randomIv1", created_at: new Date(), @@ -387,6 +388,7 @@ import { { uid: uidFiles2, document_uid: uidDocument2, + file_name: "fileName2", file_path: "https://www.google2.com", iv: "randomIv2", created_at: new Date(), diff --git a/src/common/repositories/FilesRepository.ts b/src/common/repositories/FilesRepository.ts index 54bf3bef..af5b8044 100644 --- a/src/common/repositories/FilesRepository.ts +++ b/src/common/repositories/FilesRepository.ts @@ -35,9 +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 index c0ac9d5e..23c7624a 100644 --- a/src/services/private-services/CryptoService/CryptoService.ts +++ b/src/services/private-services/CryptoService/CryptoService.ts @@ -5,11 +5,10 @@ import crypto from "crypto"; @Service() export default class CryptoService extends BaseService { - private key: CryptoKey; - private jwkKey: any; + private jwkKey: JsonWebKey; + private subtle: SubtleCrypto = crypto.webcrypto.subtle constructor(protected variables: BackendVariables) { super(); - this.key = new CryptoKey(); this.jwkKey = { kty: "oct", k: variables.KEY_DATA, @@ -19,24 +18,18 @@ export default class CryptoService extends BaseService { } private async getKey() { - if (!this.key) this.key = await crypto.subtle.importKey("jwk", this.jwkKey, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]); - return this.key; - } - - public getTextEncoderDecoder() { - return { encoder: new TextEncoder(), decoder: new TextDecoder("utf-8") } + 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: any) { - const { encoder, decoder } = this.getTextEncoderDecoder(); - const encodedData = encoder.encode(data); + 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 crypto.subtle.encrypt( + const cipherData = await this.subtle.encrypt( { name: "AES-GCM", iv, @@ -45,8 +38,8 @@ export default class CryptoService extends BaseService { encodedData, ); - const cipherText = decoder.decode(cipherData); - const ivStringified = decoder.decode(iv); + const cipherText = Buffer.from(cipherData).toString('base64'); + const ivStringified = Buffer.from(iv).toString('base64'); return { cipherText, ivStringified }; } @@ -56,11 +49,10 @@ export default class CryptoService extends BaseService { * @throws {Error} If data cannot be decrypted */ public async decrypt(cipherText: string, ivStringified: string): Promise { - const { encoder, decoder } = this.getTextEncoderDecoder(); - const cipherData = encoder.encode(cipherText); - const iv = encoder.encode(ivStringified); + const cipherData = Buffer.from(cipherText, 'base64'); + const iv = Buffer.from(ivStringified, 'base64'); const key = await this.getKey(); - const decryptedData = await crypto.subtle.decrypt( + const decryptedData = await this.subtle.decrypt( { name: "AES-GCM", iv, @@ -69,6 +61,6 @@ export default class CryptoService extends BaseService { cipherData, ); - return decoder.decode(decryptedData); + 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 ea97b2fc..ce6373df 100644 --- a/src/services/private-services/FilesService/FilesService.ts +++ b/src/services/private-services/FilesService/FilesService.ts @@ -4,8 +4,9 @@ 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 fs from "fs"; import { BackendVariables } from "@Common/config/variables/Variables"; +import { Readable } from "stream"; @Service() export default class FilesService extends BaseService { @@ -25,10 +26,10 @@ export default class FilesService extends BaseService { * @description : Create a new file * @throws {Error} If file cannot be created */ - public async create(file: File) { - const stream = fs.createReadStream('./login.png'); - const upload = await this.ipfsService.pinFile(stream, 'login.png'); + 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); @@ -47,6 +48,10 @@ export default class FilesService extends BaseService { * @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); } diff --git a/src/services/private-services/IpfsService/IpfsService.ts b/src/services/private-services/IpfsService/IpfsService.ts index 95a1c4d1..cc0f3df7 100644 --- a/src/services/private-services/IpfsService/IpfsService.ts +++ b/src/services/private-services/IpfsService/IpfsService.ts @@ -2,7 +2,7 @@ import BaseService from "@Services/BaseService"; import { Service } from "typedi"; import pinataSDK from "@pinata/sdk"; import { BackendVariables } from "@Common/config/variables/Variables"; -import fs from "fs"; +import { Readable } from "stream"; @Service() export default class FilesService extends BaseService { @@ -16,7 +16,7 @@ export default class FilesService extends BaseService { * @description : pin a file * @throws {Error} If file cannot be pinned */ - public async pinFile(stream: fs.ReadStream, fileName: string) { + public async pinFile(stream: Readable, fileName: string) { return this.ipfsClient.pinFileToIPFS(stream, {pinataMetadata : {name: fileName}}); }