feat: initial commit

This commit is contained in:
Mohamed Marrouchi
2024-09-10 10:50:11 +01:00
commit 30e5766487
879 changed files with 122820 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport';
import { AttachmentController } from './controllers/attachment.controller';
import { AttachmentRepository } from './repositories/attachment.repository';
import { AttachmentModel } from './schemas/attachment.schema';
import { AttachmentService } from './services/attachment.service';
@Module({
imports: [
MongooseModule.forFeature([AttachmentModel]),
PassportModule.register({
session: true,
}),
],
providers: [AttachmentRepository, AttachmentService],
controllers: [AttachmentController],
exports: [AttachmentService],
})
export class AttachmentModule {}

View File

@@ -0,0 +1,166 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { BadRequestException } from '@nestjs/common/exceptions';
import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from '@/logger/logger.service';
import { PluginService } from '@/plugins/plugins.service';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import {
attachmentFixtures,
installAttachmentFixtures,
} from '@/utils/test/fixtures/attachment';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { AttachmentController } from './attachment.controller';
import { attachment, attachmentFile } from '../mocks/attachment.mock';
import { AttachmentRepository } from '../repositories/attachment.repository';
import { AttachmentModel, Attachment } from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service';
describe('AttachmentController', () => {
let attachmentController: AttachmentController;
let attachmentService: AttachmentService;
let attachmentToDelete: Attachment;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttachmentController],
imports: [
rootMongooseTestModule(installAttachmentFixtures),
MongooseModule.forFeature([AttachmentModel]),
],
providers: [
AttachmentService,
AttachmentRepository,
LoggerService,
EventEmitter2,
PluginService,
],
}).compile();
attachmentController =
module.get<AttachmentController>(AttachmentController);
attachmentService = module.get<AttachmentService>(AttachmentService);
attachmentToDelete = await attachmentService.findOne({
name: 'store1.jpg',
});
});
afterAll(async () => {
await closeInMongodConnection();
});
afterEach(jest.clearAllMocks);
describe('count', () => {
it('should count attachments', async () => {
jest.spyOn(attachmentService, 'count');
const result = await attachmentController.filterCount();
expect(attachmentService.count).toHaveBeenCalled();
expect(result).toEqual({ count: attachmentFixtures.length });
});
});
describe('Upload', () => {
it('should throw BadRequestException if no file is selected to be uploaded', async () => {
const promiseResult = attachmentController.uploadFile({
file: undefined,
});
await expect(promiseResult).rejects.toThrow(
new BadRequestException('No file was selected'),
);
});
it('should upload attachment', async () => {
jest.spyOn(attachmentService, 'create');
const result = await attachmentController.uploadFile({
file: [attachmentFile],
});
expect(attachmentService.create).toHaveBeenCalledWith({
size: attachmentFile.size,
type: attachmentFile.mimetype,
name: attachmentFile.filename,
channel: {},
location: `/${attachmentFile.filename}`,
});
expect(result).toEqualPayload(
[attachment],
[...IGNORED_TEST_FIELDS, 'url'],
);
});
});
describe('Download', () => {
it(`should throw NotFoundException the id or/and file don't exist`, async () => {
jest.spyOn(attachmentService, 'findOne');
const result = attachmentController.download({ id: NOT_FOUND_ID });
expect(attachmentService.findOne).toHaveBeenCalledWith(NOT_FOUND_ID);
expect(result).rejects.toThrow(
new NotFoundException('Attachment not found'),
);
});
it('should download the attachment by id', async () => {
jest.spyOn(attachmentService, 'findOne');
const storedAttachment = await attachmentService.findOne({
name: 'store1.jpg',
});
const result = await attachmentController.download({
id: storedAttachment.id,
});
expect(attachmentService.findOne).toHaveBeenCalledWith(
storedAttachment.id,
);
expect(result.options).toEqual({
type: storedAttachment.type,
length: storedAttachment.size,
disposition: `attachment; filename="${encodeURIComponent(
storedAttachment.name,
)}"`,
});
});
});
describe('deleteOne', () => {
it('should delete an attachment by id', async () => {
jest.spyOn(attachmentService, 'deleteOne');
const result = await attachmentController.deleteOne(
attachmentToDelete.id,
);
expect(attachmentService.deleteOne).toHaveBeenCalledWith(
attachmentToDelete.id,
);
expect(result).toEqual({
acknowledged: true,
deletedCount: 1,
});
});
it('should throw a NotFoundException when attempting to delete an attachment by id', async () => {
await expect(
attachmentController.deleteOne(attachmentToDelete.id),
).rejects.toThrow(
new NotFoundException(
`Attachment with ID ${attachmentToDelete.id} not found`,
),
);
});
});
});

View File

@@ -0,0 +1,177 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { extname } from 'path';
import {
BadRequestException,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Post,
Query,
StreamableFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { TFilterQuery } from 'mongoose';
import { diskStorage, memoryStorage } from 'multer';
import { v4 as uuidv4 } from 'uuid';
import { config } from '@/config';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service';
import { Roles } from '@/utils/decorators/roles.decorator';
import { BaseController } from '@/utils/generics/base-controller';
import { DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { AttachmentDownloadDto } from '../dto/attachment.dto';
import { Attachment } from '../schemas/attachment.schema';
import { AttachmentService } from '../services/attachment.service';
@UseInterceptors(CsrfInterceptor)
@Controller('attachment')
export class AttachmentController extends BaseController<Attachment> {
constructor(
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService,
) {
super(attachmentService);
}
/**
* Counts the filtered number of attachments.
*
* @returns A promise that resolves to an object representing the filtered number of attachments.
*/
@Get('count')
async filterCount(
@Query(
new SearchFilterPipe<Attachment>({
allowedFields: ['name', 'type'],
}),
)
filters?: TFilterQuery<Attachment>,
) {
return await this.count(filters);
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<Attachment> {
const doc = await this.attachmentService.findOne(id);
if (!doc) {
this.logger.warn(`Unable to find Attachement by id ${id}`);
throw new NotFoundException(`Attachement with ID ${id} not found`);
}
return doc;
}
/**
* Retrieves all attachments based on specified filters.
*
* @param pageQuery - The pagination to apply when retrieving attachments.
* @param filters - The filters to apply when retrieving attachments.
* @returns A promise that resolves to an array of attachments matching the filters.
*/
@Get()
async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Attachment>,
@Query(
new SearchFilterPipe<Attachment>({ allowedFields: ['name', 'type'] }),
)
filters: TFilterQuery<Attachment>,
) {
return await this.attachmentService.findPage(filters, pageQuery);
}
/**
* Uploads files to the server.
*
* @param files - An array of files to upload.
* @returns A promise that resolves to an array of uploaded attachments.
*/
@CsrfCheck(true)
@Post('upload')
@UseInterceptors(
FileFieldsInterceptor([{ name: 'file' }], {
limits: {
fileSize: config.parameters.maxUploadSize,
},
storage: (() => {
if (config.parameters.storageMode === 'memory') {
return memoryStorage();
} else {
return diskStorage({
destination: config.parameters.uploadDir,
filename: (req, file, cb) => {
const name = file.originalname.split('.')[0];
const extension = extname(file.originalname);
cb(null, `${name}-${uuidv4()}${extension}`);
},
});
}
})(),
}),
)
async uploadFile(
@UploadedFiles() files: { file: Express.Multer.File[] },
): Promise<Attachment[]> {
if (!files || !Array.isArray(files?.file) || files.file.length === 0) {
throw new BadRequestException('No file was selected');
}
return await this.attachmentService.uploadFiles(files);
}
/**
* Downloads an attachment identified by the provided parameters.
*
* @param params - The parameters identifying the attachment to download.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
*/
@Roles('public')
@Get('download/:id/:filename?')
async download(
@Param() params: AttachmentDownloadDto,
): Promise<StreamableFile> {
const attachment = await this.attachmentService.findOne(params.id);
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
return this.attachmentService.download(attachment);
}
/**
* Deletes an attachment with the specified ID.
*
* @param id - The ID of the attachment to delete.
* @returns A promise that resolves to the result of the deletion operation.
*/
@CsrfCheck(true)
@Delete(':id')
@HttpCode(204)
async deleteOne(@Param('id') id: string): Promise<DeleteResult> {
const result = await this.attachmentService.deleteOne(id);
if (result.deletedCount === 0) {
this.logger.warn(`Unable to delete attachment by id ${id}`);
throw new NotFoundException(`Attachment with ID ${id} not found`);
}
return result;
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsObject,
IsOptional,
MaxLength,
IsNotEmpty,
IsString,
} from 'class-validator';
import { ObjectIdDto } from '@/utils/dto/object-id.dto';
export class AttachmentCreateDto {
/**
* Attachment channel
*/
@ApiPropertyOptional({ description: 'Attachment channel', type: Object })
@IsNotEmpty()
@IsObject()
channel?: Partial<Record<string, any>>;
/**
* Attachment location
*/
@ApiProperty({ description: 'Attachment location', type: String })
@IsNotEmpty()
@IsString()
location: string;
/**
* Attachment name
*/
@ApiProperty({ description: 'Attachment name', type: String })
@IsNotEmpty()
@IsString()
name: string;
/**
* Attachment size
*/
@ApiProperty({ description: 'Attachment size', type: Number })
@IsNotEmpty()
size: number;
/**
* Attachment type
*/
@ApiProperty({ description: 'Attachment type', type: String })
@IsNotEmpty()
@IsString()
type: string;
}
export class AttachmentDownloadDto extends ObjectIdDto {
/**
* Attachment file name
*/
@ApiPropertyOptional({
description: 'Attachment download filename',
type: String,
})
@Type(() => String)
@MaxLength(255)
@IsOptional()
filename?: string;
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Stream } from 'node:stream';
import { Attachment } from '../schemas/attachment.schema';
export const attachment: Attachment = {
name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
type: 'image/png',
size: 343370,
location:
'/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
id: '65940d115178607da65c82b6',
createdAt: new Date(),
updatedAt: new Date(),
};
export const attachmentFile: Express.Multer.File = {
filename: attachment.name,
mimetype: attachment.type,
size: attachment.size,
buffer: Buffer.from(new Uint8Array([])),
destination: '',
fieldname: '',
originalname: '',
path: '',
stream: new Stream.Readable(),
encoding: '7bit',
};
export const attachments: Attachment[] = [
attachment,
{
name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
type: 'image/png',
size: 343370,
location:
'/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
channel: { dimelo: {} },
id: '65940d115178607da65c82b7',
createdAt: new Date(),
updatedAt: new Date(),
},
{
name: 'Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
type: 'image/png',
size: 33829,
location:
'/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
channel: { dimelo: {} },
id: '65940d115178607da65c82b8',
createdAt: new Date(),
updatedAt: new Date(),
},
];

View File

@@ -0,0 +1,36 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { BaseRepository } from '@/utils/generics/base-repository';
import { Attachment, AttachmentDocument } from '../schemas/attachment.schema';
@Injectable()
export class AttachmentRepository extends BaseRepository<Attachment, never> {
constructor(
@InjectModel(Attachment.name) readonly model: Model<Attachment>,
private readonly eventEmitter: EventEmitter2,
) {
super(model, Attachment);
}
/**
* Handles post-creation operations for an attachment.
*
* @param created - The created attachment document.
*/
async postCreate(created: AttachmentDocument): Promise<void> {
this.eventEmitter.emit('hook:chatbot:attachment:upload', created);
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { THydratedDocument } from 'mongoose';
import { FileType } from '@/chat/schemas/types/attachment';
import { config } from '@/config';
import { BaseSchema } from '@/utils/generics/base-schema';
import { MIME_REGEX } from '../utilities';
// TODO: Interface AttachmentAttrs declared, currently not used
export interface AttachmentAttrs {
name: string;
type: string;
size: number;
location: string;
channel?: Record<string, any>;
}
@Schema({ timestamps: true })
export class Attachment extends BaseSchema {
/**
* The name of the attachment.
*/
@Prop({
type: String,
required: true,
})
name: string;
/**
* The MIME type of the attachment, must match the MIME_REGEX.
*/
@Prop({
type: String,
required: true,
match: MIME_REGEX,
})
type: string;
/**
* The size of the attachment in bytes, must be between 0 and config.parameters.maxUploadSize.
*/
@Prop({
type: Number,
required: true,
min: 0,
max: config.parameters.maxUploadSize,
})
size: number;
/**
* The location of the attachment, must be a unique value and pass the fileExists validation.
*/
@Prop({
type: String,
unique: true,
})
location: string;
/**
* Optional property representing the attachment channel, can hold a partial record of various channel data.
*/
@Prop({ type: JSON })
channel?: Partial<Record<string, any>>;
/**
* Optional property representing the URL of the attachment.
*
*/
url?: string;
/**
* Generates and returns the URL of the attachment.
* @param attachmentId - Id of the attachment
* @param attachmentName - The file name of the attachment. Optional and defaults to an empty string.
* @returns A string representing the attachment URL
*/
static getAttachmentUrl(
attachmentId: string,
attachmentName: string = '',
): string {
return `${config.parameters.apiUrl}/attachment/download/${attachmentId}/${attachmentName}`;
}
/**
* Determines the type of the attachment based on its MIME type.
* @param mimeType - The MIME Type of the attachment (eg. image/png)
* @returns The attachment type ('image', 'audio', 'video' or 'file')
*/
static getTypeByMime(mimeType: string): FileType {
if (mimeType.startsWith(FileType.image)) {
return FileType.image;
} else if (mimeType.startsWith(FileType.audio)) {
return FileType.audio;
} else if (mimeType.startsWith(FileType.video)) {
return FileType.video;
} else {
return FileType.file;
}
}
}
export type AttachmentDocument = THydratedDocument<Attachment>;
export const AttachmentModel: ModelDefinition = {
name: Attachment.name,
schema: SchemaFactory.createForClass(Attachment),
};
AttachmentModel.schema.virtual('url').get(function () {
if (this._id && this.name)
return `${config.apiPath}/attachment/download/${this._id}/${this.name}`;
return '';
});
export default AttachmentModel.schema;

View File

@@ -0,0 +1,212 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import fs, { createReadStream } from 'fs';
import path, { join } from 'path';
import {
Injectable,
NotFoundException,
Optional,
StreamableFile,
} from '@nestjs/common';
import fetch from 'node-fetch';
import { config } from '@/config';
import { LoggerService } from '@/logger/logger.service';
import { PluginInstance } from '@/plugins/map-types';
import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { BaseService } from '@/utils/generics/base-service';
import { AttachmentRepository } from '../repositories/attachment.repository';
import { Attachment } from '../schemas/attachment.schema';
import { fileExists, getStreamableFile } from '../utilities';
@Injectable()
export class AttachmentService extends BaseService<Attachment> {
private storagePlugin: PluginInstance<PluginType.storage> | null = null;
constructor(
readonly repository: AttachmentRepository,
private readonly logger: LoggerService,
@Optional() private readonly pluginService: PluginService,
) {
super(repository);
}
/**
* A storage plugin is a alternative way to store files, instead of local filesystem, you can
* have a plugin that would store files in a 3rd party system (Minio, AWS S3, ...)
*
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
* @returns A singleton instance of the storage plugin
*/
getStoragePlugin() {
if (!this.storagePlugin) {
const plugins = this.pluginService.getAllByType(PluginType.storage);
if (plugins.length === 1) {
this.storagePlugin = plugins[0];
} else if (plugins.length > 1) {
throw new Error(
'Multiple storage plugins are detected, please ensure only one is available',
);
}
}
return this.storagePlugin;
}
/**
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration.
*
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
* @returns A `StreamableFile` containing the user's profile picture.
*/
async downloadProfilePic(foreign_id: string): Promise<StreamableFile> {
if (this.getStoragePlugin()) {
try {
const pict = foreign_id + '.jpeg';
const picture = await this.getStoragePlugin().downloadProfilePic(pict);
return picture;
} catch (err) {
this.logger.error('Error downloading profile picture', err);
throw new NotFoundException('Profile picture not found');
}
} else {
const path = join(config.parameters.avatarDir, `${foreign_id}.jpeg`);
if (fs.existsSync(path)) {
const picturetream = createReadStream(path);
return new StreamableFile(picturetream);
} else {
throw new NotFoundException('Profile picture not found');
}
}
}
/**
* Uploads a profile picture to either 3rd party storage system or locally based on the configuration.
*
* @param res - The response object from which the profile picture will be buffered or piped.
* @param filename - The filename
*/
async uploadProfilePic(res: fetch.Response, filename: string) {
if (this.getStoragePlugin()) {
// Upload profile picture
const buffer = await res.buffer();
const picture = {
originalname: filename,
buffer,
} as Express.Multer.File;
try {
await this.getStoragePlugin().uploadAvatar(picture);
this.logger.log(
`Profile picture uploaded successfully to ${
this.getStoragePlugin().id
}`,
);
} catch (err) {
this.logger.error(
`Error while uploading profile picture to ${
this.getStoragePlugin().id
}`,
err,
);
}
} else {
// Save profile picture locally
const dirPath = path.join(config.parameters.avatarDir, filename);
try {
await fs.promises.mkdir(config.parameters.avatarDir, {
recursive: true,
}); // Ensure the directory exists
const dest = fs.createWriteStream(dirPath);
res.body.pipe(dest);
this.logger.debug(
'Messenger Channel Handler : Profile picture fetched successfully',
);
} catch (err) {
this.logger.error(
'Messenger Channel Handler : Error while creating directory',
err,
);
}
}
}
/**
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
* Otherwise, uploads files to the local directory.
*
* @param files - An array of files to upload.
* @returns A promise that resolves to an array of uploaded attachments.
*/
async uploadFiles(files: { file: Express.Multer.File[] }) {
if (this.getStoragePlugin()) {
const dtos = await Promise.all(
files.file.map((file) => {
return this.getStoragePlugin().upload(file);
}),
);
const uploadedFiles = await Promise.all(
dtos.map((dto) => {
return this.create(dto);
}),
);
return uploadedFiles;
} else {
if (Array.isArray(files?.file)) {
const uploadedFiles = await Promise.all(
files?.file?.map(async ({ size, filename, mimetype }) => {
return await this.create({
size,
type: mimetype,
name: filename,
channel: {},
location: `/${filename}`,
});
}),
);
return uploadedFiles;
}
}
}
/**
* Downloads an attachment identified by the provided parameters.
*
* @param attachment - The attachment to download.
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
*/
async download(attachment: Attachment) {
if (this.getStoragePlugin()) {
return await this.getStoragePlugin().download(attachment);
} else {
if (!fileExists(attachment.location)) {
throw new NotFoundException('No file was found');
}
const path = join(config.parameters.uploadDir, attachment.location);
const disposition = `attachment; filename="${encodeURIComponent(
attachment.name,
)}"`;
return getStreamableFile({
path,
options: {
type: attachment.type,
length: attachment.size,
disposition,
},
});
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
* 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited.
*/
import { createReadStream, existsSync } from 'fs';
import { join } from 'path';
import { Logger, StreamableFile } from '@nestjs/common';
import { StreamableFileOptions } from '@nestjs/common/file-stream/interfaces/streamable-options.interface';
import { config } from '@/config';
export const MIME_REGEX = /^[a-z-]+\/[0-9a-z\-.]+$/gm;
export const isMime = (type: string): boolean => {
return MIME_REGEX.test(type);
};
export const fileExists = (location: string): boolean => {
// bypass test env
if (config.env === 'test') {
return true;
}
try {
const dirPath = config.parameters.uploadDir;
const fileLocation = join(dirPath, location);
return existsSync(fileLocation);
} catch (e) {
new Logger(`Attachment Model : Unable to locate file: ${location}`);
return false;
}
};
export const getStreamableFile = ({
path,
options,
}: {
path: string;
options?: StreamableFileOptions;
}) => {
// bypass test env
if (config.env === 'test') {
return new StreamableFile(Buffer.from(''), options);
}
const fileReadStream = createReadStream(path);
return new StreamableFile(fileReadStream, options);
};