Skip to content

Commit

Permalink
chore: Integration with MinIO and S3
Browse files Browse the repository at this point in the history
Adds support for MinIO and S3 for storing media files. Modified several files to implement this feature, including package.json, prisma/postgresql-schema.prisma, src/api/integrations/typebot/services/typebot.service.ts, src/api/routes/index.router.ts, src/api/services/channels/whatsapp.baileys.service.ts, and src/config/env.config.ts. Added untracked files for the new S3 integration. Also added a new S3Controller and S3Service for handling S3 related operations.

This change allows for more flexible media storage options and enables the use of MinIO or S3 for storing media files.
  • Loading branch information
dgcode-tec committed Jul 13, 2024
1 parent f7a731a commit e73d9c1
Show file tree
Hide file tree
Showing 14 changed files with 364 additions and 15 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"js-yaml": "^4.1.0",
"jsonschema": "^1.4.1",
"link-preview-js": "^3.0.4",
"minio": "^8.0.1",
"node-cache": "^5.1.2",
"node-mime-types": "^1.1.0",
"node-windows": "^1.0.0-beta.8",
Expand Down
24 changes: 24 additions & 0 deletions prisma/migrations/20240713184337_add_media_table/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "Media" (
"id" TEXT NOT NULL,
"fileName" VARCHAR(500) NOT NULL,
"type" VARCHAR(100) NOT NULL,
"mimetype" VARCHAR(100) NOT NULL,
"createdAt" DATE DEFAULT CURRENT_TIMESTAMP,
"messageId" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,

CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Media_fileName_key" ON "Media"("fileName");

-- CreateIndex
CREATE UNIQUE INDEX "Media_messageId_key" ON "Media"("messageId");

-- AddForeignKey
ALTER TABLE "Media" ADD CONSTRAINT "Media_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Media" ADD CONSTRAINT "Media_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "Instance"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 changes: 14 additions & 0 deletions prisma/postgresql-schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ model Instance {
MessageUpdate MessageUpdate[]
TypebotSession TypebotSession[]
TypebotSetting TypebotSetting?
Media Media[]
}

model Session {
Expand Down Expand Up @@ -127,6 +128,7 @@ model Message {
typebotSessionId String?
MessageUpdate MessageUpdate[]
TypebotSession TypebotSession? @relation(fields: [typebotSessionId], references: [id])
Media Media?
}

model MessageUpdate {
Expand Down Expand Up @@ -311,3 +313,15 @@ model TypebotSetting {
Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
instanceId String @unique
}

model Media {
id String @id @default(cuid())
fileName String @unique @db.VarChar(500)
type String @db.VarChar(100)
mimetype String @db.VarChar(100)
createdAt DateTime? @default(now()) @db.Date
Message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
messageId String @unique
Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
instanceId String
}
15 changes: 15 additions & 0 deletions src/api/integrations/s3/controllers/s3.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { InstanceDto } from '../../../dto/instance.dto';
import { MediaDto } from '../dto/media.dto';
import { S3Service } from '../services/s3.service';

export class S3Controller {
constructor(private readonly s3Service: S3Service) {}

public async getMedia(instance: InstanceDto, data: MediaDto) {
return this.s3Service.getMedia(instance, data);
}

public async getMediaUrl(instance: InstanceDto, data: MediaDto) {
return this.s3Service.getMediaUrl(instance, data);
}
}
6 changes: 6 additions & 0 deletions src/api/integrations/s3/dto/media.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class MediaDto {
id?: string;
type?: string;
messageId?: number;
expiry?: number;
}
88 changes: 88 additions & 0 deletions src/api/integrations/s3/libs/minio.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as MinIo from 'minio';
import { join } from 'path';
import { Readable, Transform } from 'stream';

import { ConfigService, S3 } from '../../../../config/env.config';
import { Logger } from '../../../../config/logger.config';
import { BadRequestException } from '../../../../exceptions';

const logger = new Logger('S3 Service');

const BUCKET = new ConfigService().get<S3>('S3');

interface Metadata extends MinIo.ItemBucketMetadata {
'Content-Type': string;
}

const minioClient = (() => {
if (BUCKET?.ENABLE) {
return new MinIo.Client({
endPoint: BUCKET.ENDPOINT,
port: BUCKET.PORT,
useSSL: BUCKET.USE_SSL,
accessKey: BUCKET.ACCESS_KEY,
secretKey: BUCKET.SECRET_KEY,
});
}
})();

const bucketName = process.env.S3_BUCKET;

const bucketExists = async () => {
if (minioClient) {
try {
const list = await minioClient.listBuckets();
return list.find((bucket) => bucket.name === bucketName);
} catch (error) {
return false;
}
}
};

const createBucket = async () => {
if (minioClient) {
try {
const exists = await bucketExists();
if (!exists) {
await minioClient.makeBucket(bucketName);
}

logger.info(`S3 Bucket ${bucketName} - ON`);
return true;
} catch (error) {
console.log('S3 ERROR: ', error);
return false;
}
}
};

createBucket();

const uploadFile = async (fileName: string, file: Buffer | Transform | Readable, size: number, metadata: Metadata) => {
if (minioClient) {
const objectName = join('evolution-api', fileName);
try {
metadata['custom-header-application'] = 'evolution-api';
return await minioClient.putObject(bucketName, objectName, file, size, metadata);
} catch (error) {
console.log('ERROR: ', error);
return error;
}
}
};

const getObjectUrl = async (fileName: string, expiry?: number) => {
if (minioClient) {
try {
const objectName = join('evolution-api', fileName);
if (expiry) {
return await minioClient.presignedGetObject(bucketName, objectName, expiry);
}
return await minioClient.presignedGetObject(bucketName, objectName);
} catch (error) {
throw new BadRequestException(error?.message);
}
}
};

export { BUCKET, getObjectUrl, uploadFile };
36 changes: 36 additions & 0 deletions src/api/integrations/s3/routes/s3.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { RequestHandler, Router } from 'express';

import { RouterBroker } from '../../../abstract/abstract.router';
import { HttpStatus } from '../../../routes/index.router';
import { s3Controller } from '../../../server.module';
import { MediaDto } from '../dto/media.dto';
import { s3Schema, s3UrlSchema } from '../validate/s3.schema';

export class S3Router extends RouterBroker {
constructor(...guards: RequestHandler[]) {
super();
this.router
.post(this.routerPath('getMedia'), ...guards, async (req, res) => {
const response = await this.dataValidate<MediaDto>({
request: req,
schema: s3Schema,
ClassRef: MediaDto,
execute: (instance, data) => s3Controller.getMedia(instance, data),
});

res.status(HttpStatus.CREATED).json(response);
})
.post(this.routerPath('getMediaUrl'), ...guards, async (req, res) => {
const response = await this.dataValidate<MediaDto>({
request: req,
schema: s3UrlSchema,
ClassRef: MediaDto,
execute: (instance, data) => s3Controller.getMediaUrl(instance, data),
});

res.status(HttpStatus.OK).json(response);
});
}

public readonly router = Router();
}
50 changes: 50 additions & 0 deletions src/api/integrations/s3/services/s3.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Logger } from '../../../../config/logger.config';
import { BadRequestException } from '../../../../exceptions';
import { InstanceDto } from '../../../dto/instance.dto';
import { PrismaRepository } from '../../../repository/repository.service';
import { MediaDto } from '../dto/media.dto';
import { getObjectUrl } from '../libs/minio.server';

export class S3Service {
constructor(private readonly prismaRepository: PrismaRepository) {}

private readonly logger = new Logger(S3Service.name);

public async getMedia(instance: InstanceDto, query?: MediaDto) {
try {
const where: any = {
instanceId: instance.instanceId,
...query,
};

const media = await this.prismaRepository.media.findMany({
where,
select: {
id: true,
fileName: true,
type: true,
mimetype: true,
createdAt: true,
Message: true,
},
});

if (!media || media.length === 0) {
throw 'Media not found';
}

return media;
} catch (error) {
throw new BadRequestException(error);
}
}

public async getMediaUrl(instance: InstanceDto, data: MediaDto) {
const media = (await this.getMedia(instance, { id: data.id }))[0];
const mediaUrl = await getObjectUrl(media.fileName, data.expiry);
return {
mediaUrl,
...media,
};
}
}
43 changes: 43 additions & 0 deletions src/api/integrations/s3/validate/s3.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { JSONSchema7 } from 'json-schema';
import { v4 } from 'uuid';

const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => {
const properties = {};
propertyNames.forEach(
(property) =>
(properties[property] = {
minLength: 1,
description: `The "${property}" cannot be empty`,
}),
);
return {
if: {
propertyNames: {
enum: [...propertyNames],
},
},
then: { properties },
};
};

export const s3Schema: JSONSchema7 = {
$id: v4(),
type: 'object',
properties: {
id: { type: 'string' },
type: { type: 'string' },
messageId: { type: 'integer' },
},
...isNotEmpty('id', 'type', 'messageId'),
};

export const s3UrlSchema: JSONSchema7 = {
$id: v4(),
type: 'object',
properties: {
id: { type: 'string', pattern: '\\d+', minLength: 1 },
expiry: { type: 'string', pattern: '\\d+', minLength: 1 },
},
...isNotEmpty('id'),
required: ['id'],
};
6 changes: 3 additions & 3 deletions src/api/integrations/typebot/services/typebot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,17 +651,17 @@ export class TypebotService {

if (ignoreGroups && remoteJid.includes('@g.us')) {
this.logger.warn('Ignoring message from group: ' + remoteJid);
return;
throw new Error('Group not allowed');
}

if (ignoreContacts && remoteJid.includes('@s.whatsapp.net')) {
this.logger.warn('Ignoring message from contact: ' + remoteJid);
return;
throw new Error('Contact not allowed');
}

if (ignoreJids.includes(remoteJid)) {
this.logger.warn('Ignoring message from jid: ' + remoteJid);
return;
throw new Error('Jid not allowed');
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/api/routes/index.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { authGuard } from '../guards/auth.guard';
import { instanceExistsGuard, instanceLoggedGuard } from '../guards/instance.guard';
import { ChatwootRouter } from '../integrations/chatwoot/routes/chatwoot.router';
import { RabbitmqRouter } from '../integrations/rabbitmq/routes/rabbitmq.router';
import { S3Router } from '../integrations/s3/routes/s3.router';
import { SqsRouter } from '../integrations/sqs/routes/sqs.router';
import { TypebotRouter } from '../integrations/typebot/routes/typebot.router';
import { WebsocketRouter } from '../integrations/websocket/routes/websocket.router';
Expand Down Expand Up @@ -63,6 +64,7 @@ router
.use('/typebot', new TypebotRouter(...guards).router)
.use('/proxy', new ProxyRouter(...guards).router)
.use('/label', new LabelRouter(...guards).router)
.use('/s3', new S3Router(...guards).router)
.get('/webhook/meta', async (req, res) => {
if (req.query['hub.verify_token'] === configService.get<WaBusiness>('WA_BUSINESS').TOKEN_WEBHOOK)
res.send(req.query['hub.challenge']);
Expand Down
5 changes: 5 additions & 0 deletions src/api/server.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { ChatwootController } from './integrations/chatwoot/controllers/chatwoot
import { ChatwootService } from './integrations/chatwoot/services/chatwoot.service';
import { RabbitmqController } from './integrations/rabbitmq/controllers/rabbitmq.controller';
import { RabbitmqService } from './integrations/rabbitmq/services/rabbitmq.service';
import { S3Controller } from './integrations/s3/controllers/s3.controller';
import { S3Service } from './integrations/s3/services/s3.service';
import { SqsController } from './integrations/sqs/controllers/sqs.controller';
import { SqsService } from './integrations/sqs/services/sqs.service';
import { TypebotController } from './integrations/typebot/controllers/typebot.controller';
Expand Down Expand Up @@ -63,6 +65,9 @@ const authService = new AuthService(prismaRepository);
const typebotService = new TypebotService(waMonitor, configService, prismaRepository);
export const typebotController = new TypebotController(typebotService);

const s3Service = new S3Service(prismaRepository);
export const s3Controller = new S3Controller(s3Service);

const webhookService = new WebhookService(waMonitor, prismaRepository);
export const webhookController = new WebhookController(webhookService, waMonitor);

Expand Down
Loading

0 comments on commit e73d9c1

Please sign in to comment.