diff --git a/backend/.env.example b/backend/.env.example index 89f01f3eefc9c55a2bcba078faab806801fc1b67..32cdd4ec13f5c46d317ee49d78e522b0ffb191a4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,2 @@ -BLOCKFROST_SANCHONET_PROJECT_ID= \ No newline at end of file +BLOCKFROST_SANCHONET_PROJECT_ID= +BLOCKFROST_IPFS_PROJECT_ID= \ No newline at end of file diff --git a/backend/src/attachment/attachment.controller.ts b/backend/src/attachment/attachment.controller.ts index 2b605d6ca2bd6ca0f13e38530c257e9ab7ea35fb..32d9f92233cbfbb81a17d78a4dd935de756ec4f1 100644 --- a/backend/src/attachment/attachment.controller.ts +++ b/backend/src/attachment/attachment.controller.ts @@ -59,4 +59,15 @@ export class AttachmentController { parentEntity, ); } + @Get('ipfs/:IpfsHash') + async getAttachmentByIpfsHash(@Param('IpfsHash') IpfsHash: string, @Res() res: Response) { + return this.attachmentService.getAttachmentFromIPFS(IpfsHash, res); + } + @Post('ipfs/add') + async uploadAttachmentToIpfs( + @Body('attachment') + attachment: Express.Multer.File | Buffer | Uint8Array | Blob, + ) { + return this.attachmentService.uploadAttachmentToIPFS(attachment); + } } diff --git a/backend/src/attachment/attachment.module.ts b/backend/src/attachment/attachment.module.ts index 61ebdb359082d6588241d57bacbc8fbd5e080e93..0c4a64a379310cc6fc75aaf54af4c3fb5130c8d3 100644 --- a/backend/src/attachment/attachment.module.ts +++ b/backend/src/attachment/attachment.module.ts @@ -3,10 +3,18 @@ import { AttachmentService } from './attachment.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Attachment } from 'src/entities/attachment.entity'; import { AttachmentController } from './attachment.controller'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; @Module({ - imports: [TypeOrmModule.forFeature([Attachment])], + imports: [ + TypeOrmModule.forFeature([Attachment]), + HttpModule.register({ + timeout: 5000, + maxRedirects: 5, + }), + ], controllers: [AttachmentController], - providers: [AttachmentService], + providers: [AttachmentService, ConfigService], }) export class AttachmentModule {} diff --git a/backend/src/attachment/attachment.service.ts b/backend/src/attachment/attachment.service.ts index af1297000aaeeecb781294f6b8dd454218b2347d..be6d62d550b5daffc090d37beb569106225ec7ba 100644 --- a/backend/src/attachment/attachment.service.ts +++ b/backend/src/attachment/attachment.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import Jimp from 'jimp'; import { Attachment, @@ -7,12 +7,19 @@ import { } from 'src/entities/attachment.entity'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { lastValueFrom } from 'rxjs'; +import { IPFSResponse } from 'src/common/types'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Response } from 'express'; @Injectable() export class AttachmentService { constructor( @InjectDataSource('default') private voltaireService: DataSource, + private httpService: HttpService, + private configService: ConfigService, ) {} async parseMimeType(mimeType: string) { switch (mimeType) { @@ -181,4 +188,51 @@ export class AttachmentService { console.log(error); } } + async uploadAttachmentToIPFS(attachment:Express.Multer.File | Buffer | Uint8Array | Blob | FormData ): Promise<IPFSResponse> { + try { + const res = await lastValueFrom( + this.httpService.post( + 'https://ipfs.blockfrost.io/api/v0/ipfs/add', + attachment, + { + headers: { + project_id: this.configService.get<string>( + 'BLOCKFROST_IPFS_PROJECT_ID', + ) + }, + }, + ), + ); + console.log(res.data); + return res.data; + } catch (error) { + console.error(error); + throw new HttpException(error.response.data, error.response.status); + } + } + async getAttachmentFromIPFS(hash: string, res:Response): Promise<any> { + try { + const response = await lastValueFrom( + this.httpService.get(`https://ipfs.blockfrost.io/api/v0/ipfs/gateway/${hash}`, { + headers: { + project_id: this.configService.get<string>( + 'BLOCKFROST_IPFS_PROJECT_ID', + ), + }, + responseType: 'stream', // Used stream to handle large files or non-JSON data + }, + ), + ); + for (const [key, value] of Object.entries(response.headers)) { + res.setHeader(key, value as string); + } + + // Stream the data directly to the client + response.data.pipe(res); + //return response.data; + } catch (error) { + console.error(error); + throw new HttpException(error.response.data, error.response.status); + } + } } diff --git a/backend/src/common/types.ts b/backend/src/common/types.ts index 1f4c75adbecbf2a19db8bac6ada453bcef8a0a7e..c753edb6c1889688169847f59c0c322b2171510a 100644 --- a/backend/src/common/types.ts +++ b/backend/src/common/types.ts @@ -33,3 +33,8 @@ export type ValidateMetadataResult = { valid: boolean; metadata?: any; }; +export type IPFSResponse = { + name: string; + ipfs_hash: string; + size: number; +}; \ No newline at end of file diff --git a/backend/src/drep/drep.controller.ts b/backend/src/drep/drep.controller.ts index 66d7b32efb80e73a81a3c61c16c25b3afc6133e6..5d6f86b853453a618d3c863caecbe7109b2f2776 100644 --- a/backend/src/drep/drep.controller.ts +++ b/backend/src/drep/drep.controller.ts @@ -7,6 +7,7 @@ import { ParseIntPipe, Post, Query, + Res, Search, UploadedFile, UseInterceptors, @@ -17,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { DrepService } from './drep.service'; import { VoterService } from 'src/voter/voter.service'; import { Delegation, StakeKeys } from 'src/common/types'; +import { Response } from 'express'; @Controller('dreps') export class DrepController { @@ -117,8 +119,8 @@ export class DrepController { return this.drepService.getMetadataFromExternalLink(metadataUrl); } @Get(':drepId/metadata/:hash') - getMetadata(@Param('drepId') drepId: number, @Param('hash') hash: string) { - return this.drepService.getMetadata(drepId, hash); + getMetadata(@Param('drepId') drepId: number, @Param('hash') hash: string, @Res() res: Response,) { + return this.drepService.getMetadata(drepId, hash, res); } @Post('metadata/validate') validateMetadata(@Body() metadataBody: ValidateMetadataDTO) { @@ -134,7 +136,6 @@ export class DrepController { ) { return this.drepService.saveMetadata(metadata, hash, drepId, name); } - @Get(':voterId/stats') getStats(@Param('voterId') voterId: string) { return this.drepService.getStats(voterId); diff --git a/backend/src/drep/drep.service.ts b/backend/src/drep/drep.service.ts index 50f83a0c2d6c4f9832f24f197c7f004207ec260b..45027298defa3811c2edd03a9fcdb0e35d0dd506 100644 --- a/backend/src/drep/drep.service.ts +++ b/backend/src/drep/drep.service.ts @@ -1,10 +1,16 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + HttpException, + HttpStatus, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { createDrepDto, ValidateMetadataDTO } from 'src/dto'; import { faker } from '@faker-js/faker'; import * as blake from 'blakejs'; import { HttpService } from '@nestjs/axios'; import { AttachmentService } from 'src/attachment/attachment.service'; -import { Observable } from 'rxjs'; +import { lastValueFrom, Observable } from 'rxjs'; import { AxiosResponse } from 'axios'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; @@ -14,6 +20,7 @@ import { ReactionsService } from 'src/reactions/reactions.service'; import { CommentsService } from 'src/comments/comments.service'; import { Delegation, + IPFSResponse, LoggerMessage, MetadataStandard, MetadataValidationStatus, @@ -32,6 +39,8 @@ import { parseMetadata } from 'src/common/parseMetadata'; import { Metadata } from 'src/entities/metadata.entity'; import { getEpochParams } from 'src/queries/getEpochParams'; import { getDRepDelegatorsHistory } from 'src/queries/drepDelegatorsHistory'; +import { JsonLd } from 'jsonld/jsonld-spec'; +import { Response } from 'express'; @Injectable() export class DrepService { @@ -913,7 +922,7 @@ export class DrepService { .getRepository('Drep') .update(drepId, updatedDrep); } - async getMetadata(drepId: number, hash: string) { + async getMetadata(drepId: number, hash: string, res: Response) { if (!drepId || !hash) throw new Error('Inadequate parameters'); const foundMetadata = await this.voltaireService .getRepository('Metadata') @@ -921,8 +930,9 @@ export class DrepService { .where('metadata.drep = :drepId', { drepId }) .andWhere('metadata.hash = :hash', { hash }) .getOne(); - - return foundMetadata ? foundMetadata?.content : 'Not found'; + if (!foundMetadata) throw new NotFoundException('Metadata not found'); + const cid = foundMetadata.content; + return await this.getMetadataFromIPFS(cid, res); } async getMetadataFromExternalLink(metadataUrl: string) { if (!metadataUrl) throw new Error('Inadequate parameters'); @@ -993,21 +1003,14 @@ export class DrepService { .andWhere('metadata.hash = :hash', { hash: hash }) .getOne(); if (existingMetadata) { - const updateMetadata = { - name: fileName + '.jsonld', - hash: hash, - content: metadata, - drep: drepId, - }; - - await metadataRepo.update(existingMetadata.id, updateMetadata); return existingMetadata; } - + // Create a new metadata record in IPFS + const { ipfs_hash } = await this.saveMetadataToIPFS(metadata); const newMetadata = { name: fileName + '.jsonld', hash: hash, - content: metadata, + content: ipfs_hash, drep: drepId, }; @@ -1015,7 +1018,34 @@ export class DrepService { const res = (await metadataRepo.save(createdMetadata)) as Metadata; return res; } - + async saveMetadataToIPFS(metadata: JsonLd): Promise<IPFSResponse> { + try { + //save to IPFS via blockfrost + const metadataStr = JSON.stringify(metadata); + const binary = Buffer.from(metadataStr); + + // Prepare the FormData + const formData = new FormData(); + formData.append('file', binary as any); + const res = await this.attachmentService.uploadAttachmentToIPFS(formData); + return res; + } catch (error) { + console.error(error); + throw error; + } + } + async getMetadataFromIPFS(hash: string, res: Response): Promise<JsonLd> { + try { + const response = await this.attachmentService.getAttachmentFromIPFS( + hash, + res, + ); + return response; + } catch (error) { + console.error(error); + throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR); + } + } async getStats(drepVoterId: string) { const drepDelegatorsCountResult = await this.cexplorerService.manager.query( getDRepDelegatorsCountQuery, @@ -1075,12 +1105,10 @@ export class DrepService { ); const addrIds = addrIdsResult.map((row) => row.addr_id); - const drepDelegations = await this.cexplorerService.manager.query(getDRepDelegatorsHistory(addrIds), [ - drepHashId, - drepVoterId, - beforeDate, - tillDate, - ]); + const drepDelegations = await this.cexplorerService.manager.query( + getDRepDelegatorsHistory(addrIds), + [drepHashId, drepVoterId, beforeDate, tillDate], + ); return drepDelegations; } } diff --git a/backend/src/entities/metadata.entity.ts b/backend/src/entities/metadata.entity.ts index 1df68ee0d084d2bdba854820fa194ecaa7e98223..53933b8499dc7044641f1d130a2abd1dd79249bf 100644 --- a/backend/src/entities/metadata.entity.ts +++ b/backend/src/entities/metadata.entity.ts @@ -9,8 +9,8 @@ export class Metadata extends BaseEntity { @Column({ nullable: false }) hash: string; - @Column({ type: 'json', nullable: false }) - content: Record<string, any>; + @Column({ nullable: false }) + content: string; @ManyToOne(() => Drep, (drep) => drep.id) drep: Drep; diff --git a/frontend/src/components/molecules/DrepTimeline.tsx b/frontend/src/components/molecules/DrepTimeline.tsx index c09c887f7b7d46af40185ebf4bb56857ffe3a157..432178521adb0cc23b7a01c7b3155ded1c24198b 100644 --- a/frontend/src/components/molecules/DrepTimeline.tsx +++ b/frontend/src/components/molecules/DrepTimeline.tsx @@ -241,7 +241,7 @@ const DrepTimeline = ({ <div id="scrollableDiv" style={{ - height: 1000, + height: 1600, overflow: 'auto', display: 'flex', flexDirection: 'column',