diff --git a/config/test.env b/config/test.env index 18a4a681e..af9499e27 100644 --- a/config/test.env +++ b/config/test.env @@ -69,3 +69,4 @@ POIGN_ART_SERVICE_ACTIVE=false POIGN_ART_RECIPIENT_ADDRESS=0x66f59a4181f43b96fe929b711476be15c96b83b3 POIGN_ART_ORIGIN_ADDRESS=0x7a1dc1805f079a07ffd03845d3ec5b51ec8f9373 SYNC_POIGN_ART_CRONJOB_EXPRESSION=0 0 0 * * * +TRACE_FILE_UPLOADER_PASSWORD=hello_trace diff --git a/src/config.ts b/src/config.ts index 0a703b7a2..cde4e7145 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,7 @@ const envVars = [ 'OUR_SECRET', // 'XDAI_NODE_HTTP_URL', 'SEGMENT_API_KEY', + 'TRACE_FILE_UPLOADER_PASSWORD', ]; // tslint:disable-next-line:class-name interface requiredEnv { @@ -76,6 +77,7 @@ interface requiredEnv { OUR_SECRET: string; XDAI_NODE_HTTP_URL: string; SEGMENT_API_KEY: string; + TRACE_FILE_UPLOADER_PASSWORD: string; } class Config { diff --git a/src/middleware/pinataUtils.ts b/src/middleware/pinataUtils.ts index e61f88c7f..d15e48a6e 100644 --- a/src/middleware/pinataUtils.ts +++ b/src/middleware/pinataUtils.ts @@ -12,7 +12,7 @@ import config from '../config'; export const pinFile = ( file: ReadableStream, - filename: String, + filename: String = 'untitled', encoding: string, ): Promise => { const data = new FormData(); @@ -34,3 +34,33 @@ export const pinFile = ( }, }); }; + +export const pinFileDataBase64 = ( + fileDataBase64: string, + filename: string = 'untitled', + encoding: string, +): Promise => { + const data = new FormData(); + const array = fileDataBase64.split(','); + + const base64FileData = + array.length > 1 && array[0].indexOf('base64') >= 0 ? array[1] : array[0]; + const fileData = Buffer.from(base64FileData, 'base64'); + data.append('file', fileData, { filename, encoding }); + + if (filename) { + const metadata = JSON.stringify({ + name: filename, + }); + data.append('pinataMetadata', metadata); + } + + return Axios.post('https://api.pinata.cloud/pinning/pinFileToIPFS', data, { + maxContentLength: Infinity, // this is needed to prevent Axios from throw error with large files + headers: { + 'Content-Type': `multipart/form-data; boundary=${data._boundary}`, + pinata_api_key: config.get('PINATA_API_KEY') as string, + pinata_secret_api_key: config.get('PINATA_SECRET_API_KEY') as string, + }, + }); +}; diff --git a/src/modules.d.ts b/src/modules.d.ts index f334ee867..f6efa130d 100644 --- a/src/modules.d.ts +++ b/src/modules.d.ts @@ -34,5 +34,6 @@ declare namespace NodeJS { OUR_SECRET: string; XDAI_NODE_HTTP_URL: string; SEGMENT_API_KEY: string; + TRACE_FILE_UPLOADER_PASSWORD: string; } } diff --git a/src/resolvers/uploadResolver.ts b/src/resolvers/uploadResolver.ts index d3a9c90a2..5f5b63f78 100644 --- a/src/resolvers/uploadResolver.ts +++ b/src/resolvers/uploadResolver.ts @@ -1,11 +1,21 @@ -import { Arg, Ctx, Field, InputType, Mutation, Resolver } from 'type-graphql'; +import { + Arg, + Ctx, + Field, + InputType, + Mutation, + registerEnumType, + Resolver, +} from 'type-graphql'; import { GraphQLUpload, FileUpload } from 'graphql-upload'; import { MyContext } from '../types/MyContext'; -import { pinFile } from '../middleware/pinataUtils'; +import { pinFile, pinFileDataBase64 } from '../middleware/pinataUtils'; import { logger } from '../utils/logger'; import { getLoggedInUser } from '../services/authorizationServices'; import { errorMessages } from '../utils/errorMessages'; +import SentryLogger from '../sentryLogger'; +import { Readable } from 'stream'; @InputType() export class FileUploadInputType { @@ -14,6 +24,37 @@ export class FileUploadInputType { image: FileUpload; } +export enum TraceImageOwnerType { + USER = 'USER', + TRACE = 'TRACE', + CAMPAIGN = 'CAMPAIGN', + DAC = 'DAC', +} +registerEnumType(TraceImageOwnerType, { + name: 'TraceImageOwnerType', + description: + 'The entity (e.g. user, trace, campaign, or community) type owns the image', +}); + +@InputType() +export class TraceFileUploadInputType { + // Client uploads image file + @Field() + fileDataBase64: string; + + @Field() + user: string; + + @Field() + entityId: string; + + @Field(type => TraceImageOwnerType) + imageOwnerType: TraceImageOwnerType; + + @Field() + password: string; +} + @Resolver() export class UploadResolver { @Mutation(() => String, { nullable: true }) @@ -35,4 +76,38 @@ export class UploadResolver { throw Error(errorMessages.IPFS_IMAGE_UPLOAD_FAILED); } } + + @Mutation(() => String, { nullable: true }) + async traceImageUpload( + @Arg('traceFileUpload') traceFileUpload: TraceFileUploadInputType, + @Ctx() ctx: MyContext, + ): Promise { + const { fileDataBase64, user, imageOwnerType, password } = traceFileUpload; + + let errorMessage; + if (!process.env.TRACE_FILE_UPLOADER_PASSWORD) { + errorMessage = `No password is defined for trace file uploader `; + } else if (password !== process.env.TRACE_FILE_UPLOADER_PASSWORD) { + errorMessage = `Invalid password to upload trace image from ip ${ctx?.req?.ip}`; + } + + if (errorMessage) { + const userMessage = 'Access denied'; + SentryLogger.captureMessage(errorMessage); + logger.error(errorMessage); + throw new Error(userMessage); + } + + try { + const response = await pinFileDataBase64( + fileDataBase64, + undefined, + 'base64', + ); + return `/ipfs/${response.data.IpfsHash}`; + } catch (e) { + logger.error('upload() error', e); + throw Error(errorMessages.IPFS_IMAGE_UPLOAD_FAILED); + } + } }