diff --git a/src/modules/chapter/chapter.graphql.ts b/src/modules/chapter/chapter.graphql.ts index fb24a8ed..34627936 100644 --- a/src/modules/chapter/chapter.graphql.ts +++ b/src/modules/chapter/chapter.graphql.ts @@ -433,4 +433,31 @@ export class ChapterGraphql { headers ); } + + async queryMangaMainTitle(variables: any) { + const headers = { + 'x-hasura-admin-secret': this.configService.get( + 'graphql.adminSecret' + ), + }; + const result = await this.graphqlSvc.query( + this.configService.get('graphql.endpoint'), + '', + `query manga_by_pk($id: Int!) { + manga_by_pk(id: $id) { + manga_languages(where: {is_main_language: {_eq: true}}) { + title + } + } + } + `, + 'manga_by_pk', + variables, + headers + ); + + return ( + result.data?.manga_by_pk?.manga_languages[0]?.title || 'Punkga Manga' + ); + } } diff --git a/src/modules/chapter/chapter.module.ts b/src/modules/chapter/chapter.module.ts index 302f415e..6b9c6cdf 100644 --- a/src/modules/chapter/chapter.module.ts +++ b/src/modules/chapter/chapter.module.ts @@ -9,9 +9,17 @@ import { ChapterGraphql } from './chapter.graphql'; import { ChapterService } from './chapter.service'; import { UploadChapterService } from './upload-chapter.service'; import { CreatorModule } from '../creator/creator.module'; +import { StoryEventModule } from '../story-event/story-event.module'; @Module({ - imports: [JwtModule, GraphqlModule, FilesModule, MangaModule, CreatorModule], + imports: [ + JwtModule, + GraphqlModule, + FilesModule, + MangaModule, + CreatorModule, + StoryEventModule, + ], providers: [ChapterService, UploadChapterService, ChapterGraphql], controllers: [ChapterController], exports: [UploadChapterService, ChapterGraphql, ChapterService], diff --git a/src/modules/chapter/chapter.service.ts b/src/modules/chapter/chapter.service.ts index 04596cdb..2353b215 100644 --- a/src/modules/chapter/chapter.service.ts +++ b/src/modules/chapter/chapter.service.ts @@ -29,6 +29,9 @@ import { ViewProtectedChapterRequestDto } from './dto/view-chapter-request.dto'; import { UploadChapterService } from './upload-chapter.service'; import { mkdirp, writeFilesToFolder } from './utils'; import { CreatorService } from '../creator/creator.service'; +import { StoryEventGraphql } from '../story-event/story-event.graphql'; +import { IPFSService } from '../files/ipfs.service'; +import { FilesService } from '../files/files.service'; @Injectable() export class ChapterService { @@ -39,7 +42,10 @@ export class ChapterService { private chapterGraphql: ChapterGraphql, private uploadChapterService: UploadChapterService, private configSvc: ConfigService, - private creatorService: CreatorService + private creatorService: CreatorService, + private storyEventService: StoryEventGraphql, + private ipfsService: IPFSService, + private fileService: FilesService ) {} async upload(data: UploadInputDto, file: Express.Multer.File) { @@ -148,6 +154,55 @@ export class ChapterService { chapterImages: chapter_images, }); + if (submission_id) { + // query submission + const submission = await this.storyEventService.getSubmissionDetail({ + id: submission_id, + }); + + // upload chapter images to ipfs + const ipfsDisplayUrl = this.configSvc.get('network.ipfsQuery'); + + const ipfsImageFolder = `/punkga-manga-${manga_id}-chapter-${chapterId}/images`; + const { cid: chapterImagesCid } = + await this.ipfsService.uploadLocalFolderToIpfs( + storageFolder, + ipfsImageFolder + ); + const ipfsFolderUrl = `${ipfsDisplayUrl}/${chapterImagesCid}`; + + // upload nft image to ipfs + const thumbnail = files.filter((f) => f.fieldname === 'thumbnail')[0]; + const { cid: thumbnailCid } = await this.fileService.uploadFileToIpfs( + thumbnail.buffer, + thumbnail.originalname + ); + const thumbnailIpfs = `${ipfsDisplayUrl}/${thumbnailCid}`; + + // query manga main title + const mangaMainTitle = await this.chapterGraphql.queryMangaMainTitle({ + id: manga_id, + }); + + const metadata = { + name: mangaMainTitle, + description: `Punkga Story Event Manga - ${mangaMainTitle}`, + attributes: [ + { + chapter_images: ipfsFolderUrl, + }, + ], + image: thumbnailIpfs, + }; + const { cid: metadataCID } = + await this.fileService.uploadMetadataToIpfs( + metadata, + `/metadata-${new Date().getTime()}` + ); + + // create job to mint and register ip_asset + } + // remove files rimraf.sync(storageFolder); @@ -195,6 +250,7 @@ export class ChapterService { } } } + return result.data; } catch (errors) { return { @@ -519,4 +575,6 @@ export class ChapterService { return this.chapterGraphql.deactiveChapter(id); } + + private uploadChapterImagesToIpfs() {} } diff --git a/src/modules/files/files.module.ts b/src/modules/files/files.module.ts index 8822b30d..87b9a6c5 100644 --- a/src/modules/files/files.module.ts +++ b/src/modules/files/files.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { FilesService } from './files.service'; import { FileController } from './files.controller'; import { JwtModule } from '@nestjs/jwt'; +import { IPFSService } from './ipfs.service'; @Module({ imports: [JwtModule], - providers: [FilesService], + providers: [FilesService, IPFSService], controllers: [FileController], - exports: [FilesService], + exports: [FilesService, IPFSService], }) export class FilesModule {} diff --git a/src/modules/files/ipfs.service.ts b/src/modules/files/ipfs.service.ts new file mode 100644 index 00000000..3ee1da36 --- /dev/null +++ b/src/modules/files/ipfs.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { readFileSync, readdirSync } from 'fs'; +import { create, IPFSHTTPClient } from 'ipfs-http-client'; +import _, { omit } from 'lodash'; +import { IMetadata } from '../collection/interfaces/metadata'; +// import { IMetadata } from '../launchpad/interfaces/metadata'; + +@Injectable() +export class IPFSService implements OnModuleInit { + private readonly logger = new Logger(IPFSService.name); + private ipfsClient: IPFSHTTPClient; + + constructor(private configService: ConfigService) {} + + onModuleInit() { + const ipfsUrl = this.configService.get('network.ipfsUrl'); + + this.ipfsClient = create({ + url: ipfsUrl, + timeout: 60000, + }); + } + + async uploadImageToIpfs(file: Express.Multer.File) { + if (!file.mimetype.includes('image')) { + throw Error('file type is not valid'); + } + + const response = await this.ipfsClient.add( + { + path: file.originalname, + content: file.buffer, + }, + { + wrapWithDirectory: true, + } + ); + + return `/ipfs/${response.cid.toString()}/${file.originalname}`; + } + + async uploadLocalFolderToIpfs( + localFolderPath: string, + ipfsFolderPath: string, + chunkSize = 10 + ) { + const filenames = readdirSync(localFolderPath); + if (_.isEmpty(filenames)) + throw new Error(`Folder ${localFolderPath} is empty`); + + // await this.ipfsClient.files.rm(`/${ipfsFolderPath}`, { recursive: true }); + + await this.ipfsClient.files.mkdir(ipfsFolderPath, { parents: true }); + for (let i = 0; i < filenames.length; i += chunkSize) { + const uploadPromisses = filenames + .slice(i, i + chunkSize) + .map((filename) => { + const ipfsFilePath = `${ipfsFolderPath}/${filename}`; + const content = readFileSync(`${localFolderPath}/${filename}`); + return this.ipfsClient.files.write(ipfsFilePath, content, { + create: true, + }); + }); + await Promise.all(uploadPromisses); + } + + const folderCid = ( + await this.ipfsClient.files.stat(ipfsFolderPath) + ).cid.toString(); + + console.log( + `\nUploaded local folder (${localFolderPath}). CID: ${folderCid}` + ); + + return { + cid: folderCid, + filenames: filenames.sort((i1, i2) => + i1.localeCompare(i2, undefined, { + sensitivity: 'variant', + numeric: true, + }) + ), + }; + } + + async uploadMetadataObjectsToIpfs( + objects: IMetadata[], + ipfsFolderPath: string, + chunkSize = 10 + ): Promise { + await this.createFolderIfNotExist(ipfsFolderPath); + + for (let i = 0; i < objects.length; i += chunkSize) { + const uploadPromisses = objects.slice(i, i + chunkSize).map((object) => { + const ipfsPath = `${ipfsFolderPath}/${object.token_id}`; + const content = JSON.stringify(omit(object, ['filename', 'token_id'])); + return this.ipfsClient.files.write(ipfsPath, content, { create: true }); + }); + + await Promise.all(uploadPromisses); + } + + const folderCid = ( + await this.ipfsClient.files.stat(ipfsFolderPath) + ).cid.toString(); + + return folderCid; + } + + async uploadMetadataContractToIpfs(object: any, ipfsFolderPath: string) { + await this.createFolderIfNotExist(ipfsFolderPath); + const ipfsPath = `${ipfsFolderPath}/metadata_contract`; + const content = JSON.stringify(object); + await this.ipfsClient.files.write(ipfsPath, content, { create: true }); + const metadataContractCid = ( + await this.ipfsClient.files.stat(ipfsPath) + ).cid.toString(); + + return metadataContractCid; + } + + private async createFolderIfNotExist(ipfsPath: string) { + const exists = await this.checkExist(ipfsPath); + if (!exists) return this.ipfsClient.files.mkdir(ipfsPath); + } + + private async checkExist(ipfsPath: string) { + try { + const result = await this.ipfsClient.files.stat(ipfsPath); + if (result?.cid) return true; + return false; + } catch { + return false; + } + } +} diff --git a/src/modules/story-event/story-event.graphql.ts b/src/modules/story-event/story-event.graphql.ts index 2f3cc3d1..e7c676a6 100644 --- a/src/modules/story-event/story-event.graphql.ts +++ b/src/modules/story-event/story-event.graphql.ts @@ -588,4 +588,31 @@ export class StoryEventGraphql { headers ); } + + async getSubmissionDetail(variables: any) { + const headers = { + 'x-hasura-admin-secret': this.configSvc.get( + 'graphql.adminSecret' + ), + }; + + const result = await this.graphqlSvc.query( + this.configSvc.get('graphql.endpoint'), + '', + `query story_event_submission_by_pk($id: Int!) { + story_event_submission_by_pk(id: $id) { + id + data + name + status + type + } + }`, + 'story_event_submission_by_pk', + variables, + headers + ); + + return result.data.story_event_submission_by_pk; + } } diff --git a/src/modules/story-event/story-event.module.ts b/src/modules/story-event/story-event.module.ts index be172164..46c22670 100644 --- a/src/modules/story-event/story-event.module.ts +++ b/src/modules/story-event/story-event.module.ts @@ -22,6 +22,6 @@ import { StoryEventConsumer } from './story-event.consumer'; ], providers: [StoryEventService, StoryEventGraphql, StoryEventConsumer], controllers: [StoryEventController], - exports: [], + exports: [StoryEventService, StoryEventGraphql], }) export class StoryEventModule {} diff --git a/src/utils/nft-metadata.ts b/src/utils/nft-metadata.ts new file mode 100644 index 00000000..c8fb8090 --- /dev/null +++ b/src/utils/nft-metadata.ts @@ -0,0 +1,7 @@ +export interface IMetadata { + token_id: number; + name: string; + description: string; + attributes: any[]; + image: string; +}