diff --git a/package.json b/package.json index 8a3c77e1..22753401 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", diff --git a/src/app/api/super-admin/FilesController.ts b/src/app/api/super-admin/FilesController.ts new file mode 100644 index 00000000..1fdd1696 --- /dev/null +++ b/src/app/api/super-admin/FilesController.ts @@ -0,0 +1,150 @@ +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 ObjectHydrate from "@Common/helpers/ObjectHydrate"; +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 { + //init File resource with request body values + const fileEntity = File.hydrate(req.body); + + //validate File + await validateOrReject(fileEntity, { groups: ["createFile"] }); + + //call service to get prisma entity + const prismaEntityCreated = await this.filesService.create(fileEntity); + + //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 = ObjectHydrate.hydrate(new File(), 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 = ObjectHydrate.hydrate(new File(), 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/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/schema.prisma b/src/common/databases/schema.prisma index 08317b33..9f156af6 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) + 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..ab450e9f 100644 --- a/src/common/databases/seeders/seeder.ts +++ b/src/common/databases/seeders/seeder.ts @@ -380,6 +380,7 @@ import { uid: uidFiles1, document_uid: uidDocument1, file_path: "https://www.google1.com", + iv: "randomIv1", created_at: new Date(), updated_at: new Date(), }, @@ -387,6 +388,7 @@ import { uid: uidFiles2, document_uid: uidDocument2, 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..54bf3bef 100644 --- a/src/common/repositories/FilesRepository.ts +++ b/src/common/repositories/FilesRepository.ts @@ -36,6 +36,7 @@ export default class FilesRepository extends BaseRepository { }, }, file_path: file.file_path, + iv: file.iv }, }); } diff --git a/src/services/private-services/CryptoService/CryptoService.ts b/src/services/private-services/CryptoService/CryptoService.ts new file mode 100644 index 00000000..c0ac9d5e --- /dev/null +++ b/src/services/private-services/CryptoService/CryptoService.ts @@ -0,0 +1,74 @@ +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 key: CryptoKey; + private jwkKey: any; + constructor(protected variables: BackendVariables) { + super(); + this.key = new CryptoKey(); + this.jwkKey = { + kty: "oct", + k: variables.KEY_DATA, + alg: "A256GCM", + ext: true, + }; + } + + 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") } + } + + /** + * @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); + const iv = crypto.getRandomValues(new Uint8Array(16)); + const key = await this.getKey(); + const cipherData = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + }, + key, + encodedData, + ); + + const cipherText = decoder.decode(cipherData); + const ivStringified = decoder.decode(iv); + + 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 { encoder, decoder } = this.getTextEncoderDecoder(); + const cipherData = encoder.encode(cipherText); + const iv = encoder.encode(ivStringified); + const key = await this.getKey(); + const decryptedData = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + key, + cipherData, + ); + + return decoder.decode(decryptedData); + } +} diff --git a/src/services/private-services/FilesService/FilesService.ts b/src/services/private-services/FilesService/FilesService.ts index 67656f16..ea97b2fc 100644 --- a/src/services/private-services/FilesService/FilesService.ts +++ b/src/services/private-services/FilesService/FilesService.ts @@ -2,10 +2,14 @@ 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"; @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(); } @@ -22,6 +26,11 @@ export default class FilesService extends BaseService { * @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'); + const encryptedPath = await this.cryptoService.encrypt(this.variables.PINATA_GATEWAY.concat(upload.IpfsHash)); + file.file_path = encryptedPath.cipherText; + file.iv = encryptedPath.ivStringified; return this.filesRepository.create(file); } @@ -29,10 +38,18 @@ 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) { + 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..95a1c4d1 --- /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 fs from "fs"; + +@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: fs.ReadStream, 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); + } +}