mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: initial commit
This commit is contained in:
30
api/src/attachment/attachment.module.ts
Normal file
30
api/src/attachment/attachment.module.ts
Normal 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 {}
|
||||
166
api/src/attachment/controllers/attachment.controller.spec.ts
Normal file
166
api/src/attachment/controllers/attachment.controller.spec.ts
Normal 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`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
api/src/attachment/controllers/attachment.controller.ts
Normal file
177
api/src/attachment/controllers/attachment.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
75
api/src/attachment/dto/attachment.dto.ts
Normal file
75
api/src/attachment/dto/attachment.dto.ts
Normal 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;
|
||||
}
|
||||
62
api/src/attachment/mocks/attachment.mock.ts
Normal file
62
api/src/attachment/mocks/attachment.mock.ts
Normal 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(),
|
||||
},
|
||||
];
|
||||
36
api/src/attachment/repositories/attachment.repository.ts
Normal file
36
api/src/attachment/repositories/attachment.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
127
api/src/attachment/schemas/attachment.schema.ts
Normal file
127
api/src/attachment/schemas/attachment.schema.ts
Normal 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;
|
||||
212
api/src/attachment/services/attachment.service.ts
Normal file
212
api/src/attachment/services/attachment.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
53
api/src/attachment/utilities/index.ts
Normal file
53
api/src/attachment/utilities/index.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user