mirror of
https://github.com/hexastack/hexabot
synced 2025-05-31 10:57:06 +00:00
feat(api): add attachment extra attributes
This commit is contained in:
parent
47e8056a15
commit
994c8857e9
@ -13,6 +13,7 @@ import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
import { config } from '@/config';
|
||||
import { UserModule } from '@/user/user.module';
|
||||
|
||||
import { AttachmentController } from './controllers/attachment.controller';
|
||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||
@ -25,6 +26,7 @@ import { AttachmentService } from './services/attachment.service';
|
||||
PassportModule.register({
|
||||
session: true,
|
||||
}),
|
||||
UserModule,
|
||||
],
|
||||
providers: [AttachmentRepository, AttachmentService],
|
||||
controllers: [AttachmentController],
|
||||
|
@ -1,19 +1,27 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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).
|
||||
*/
|
||||
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
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 { Request } from 'express';
|
||||
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { ModelRepository } from '@/user/repositories/model.repository';
|
||||
import { PermissionRepository } from '@/user/repositories/permission.repository';
|
||||
import { ModelModel } from '@/user/schemas/model.schema';
|
||||
import { PermissionModel } from '@/user/schemas/permission.schema';
|
||||
import { ModelService } from '@/user/services/model.service';
|
||||
import { PermissionService } from '@/user/services/permission.service';
|
||||
import { NOT_FOUND_ID } from '@/utils/constants/mock';
|
||||
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
|
||||
import {
|
||||
@ -42,14 +50,30 @@ describe('AttachmentController', () => {
|
||||
controllers: [AttachmentController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installAttachmentFixtures),
|
||||
MongooseModule.forFeature([AttachmentModel]),
|
||||
MongooseModule.forFeature([
|
||||
AttachmentModel,
|
||||
PermissionModel,
|
||||
ModelModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
PermissionService,
|
||||
PermissionRepository,
|
||||
ModelService,
|
||||
ModelRepository,
|
||||
LoggerService,
|
||||
EventEmitter2,
|
||||
PluginService,
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
del: jest.fn(),
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
attachmentController =
|
||||
@ -76,9 +100,13 @@ describe('AttachmentController', () => {
|
||||
|
||||
describe('Upload', () => {
|
||||
it('should throw BadRequestException if no file is selected to be uploaded', async () => {
|
||||
const promiseResult = attachmentController.uploadFile({
|
||||
file: [],
|
||||
});
|
||||
const promiseResult = attachmentController.uploadFile(
|
||||
{
|
||||
file: [],
|
||||
},
|
||||
{} as Request,
|
||||
{ context: 'block_attachment' },
|
||||
);
|
||||
await expect(promiseResult).rejects.toThrow(
|
||||
new BadRequestException('No file was selected'),
|
||||
);
|
||||
@ -86,19 +114,35 @@ describe('AttachmentController', () => {
|
||||
|
||||
it('should upload attachment', async () => {
|
||||
jest.spyOn(attachmentService, 'create');
|
||||
const result = await attachmentController.uploadFile({
|
||||
file: [attachmentFile],
|
||||
});
|
||||
const result = await attachmentController.uploadFile(
|
||||
{
|
||||
file: [attachmentFile],
|
||||
},
|
||||
{
|
||||
session: { passport: { user: { id: '9'.repeat(24) } } },
|
||||
} as unknown as Request,
|
||||
{ context: 'block_attachment' },
|
||||
);
|
||||
const [name] = attachmentFile.filename.split('.');
|
||||
expect(attachmentService.create).toHaveBeenCalledWith({
|
||||
size: attachmentFile.size,
|
||||
type: attachmentFile.mimetype,
|
||||
name: attachmentFile.filename,
|
||||
channel: {},
|
||||
location: `/${attachmentFile.filename}`,
|
||||
name: attachmentFile.originalname,
|
||||
location: expect.stringMatching(new RegExp(`^/${name}`)),
|
||||
context: 'block_attachment',
|
||||
ownerType: 'User',
|
||||
owner: '9'.repeat(24),
|
||||
});
|
||||
expect(result).toEqualPayload(
|
||||
[attachment],
|
||||
[...IGNORED_TEST_FIELDS, 'url'],
|
||||
[
|
||||
{
|
||||
...attachment,
|
||||
context: 'block_attachment',
|
||||
ownerType: 'User',
|
||||
owner: '9'.repeat(24),
|
||||
},
|
||||
],
|
||||
[...IGNORED_TEST_FIELDS, 'location', 'url'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -1,31 +1,32 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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).
|
||||
*/
|
||||
|
||||
import { extname } from 'path';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
StreamableFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import { Request } from 'express';
|
||||
import { diskStorage, memoryStorage } from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { config } from '@/config';
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
@ -38,12 +39,17 @@ import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
|
||||
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
|
||||
import { TFilterQuery } from '@/utils/types/filter.types';
|
||||
|
||||
import { AttachmentDownloadDto } from '../dto/attachment.dto';
|
||||
import {
|
||||
AttachmentContextParamDto,
|
||||
AttachmentDownloadDto,
|
||||
} from '../dto/attachment.dto';
|
||||
import { AttachmentGuard } from '../guards/attachment-ability.guard';
|
||||
import { Attachment } from '../schemas/attachment.schema';
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
|
||||
@UseInterceptors(CsrfInterceptor)
|
||||
@Controller('attachment')
|
||||
@UseGuards(AttachmentGuard)
|
||||
export class AttachmentController extends BaseController<Attachment> {
|
||||
constructor(
|
||||
private readonly attachmentService: AttachmentService,
|
||||
@ -61,7 +67,7 @@ export class AttachmentController extends BaseController<Attachment> {
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Attachment>({
|
||||
allowedFields: ['name', 'type'],
|
||||
allowedFields: ['name', 'type', 'context'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Attachment>,
|
||||
@ -90,7 +96,9 @@ export class AttachmentController extends BaseController<Attachment> {
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Attachment>,
|
||||
@Query(
|
||||
new SearchFilterPipe<Attachment>({ allowedFields: ['name', 'type'] }),
|
||||
new SearchFilterPipe<Attachment>({
|
||||
allowedFields: ['name', 'type', 'context'],
|
||||
}),
|
||||
)
|
||||
filters: TFilterQuery<Attachment>,
|
||||
) {
|
||||
@ -114,26 +122,41 @@ export class AttachmentController extends BaseController<Attachment> {
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
return diskStorage({});
|
||||
}
|
||||
})(),
|
||||
}),
|
||||
)
|
||||
async uploadFile(
|
||||
@UploadedFiles() files: { file: Express.Multer.File[] },
|
||||
@Req() req: Request,
|
||||
@Query() { context }: AttachmentContextParamDto,
|
||||
): 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);
|
||||
const userId = req.session?.passport?.user?.id;
|
||||
if (!userId) {
|
||||
throw new ForbiddenException(
|
||||
'Unexpected Error: Only authenticated users are allowed to upload',
|
||||
);
|
||||
}
|
||||
|
||||
const attachments = [];
|
||||
for (const file of files.file) {
|
||||
const attachment = await this.attachmentService.store(file, {
|
||||
name: file.originalname,
|
||||
size: file.size,
|
||||
type: file.mimetype,
|
||||
context,
|
||||
owner: userId,
|
||||
ownerType: 'User',
|
||||
});
|
||||
attachments.push(attachment);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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.
|
||||
@ -9,7 +9,10 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsMimeType,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -18,6 +21,14 @@ import {
|
||||
|
||||
import { ChannelName } from '@/channel/types';
|
||||
import { ObjectIdDto } from '@/utils/dto/object-id.dto';
|
||||
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
|
||||
|
||||
import {
|
||||
AttachmentContext,
|
||||
AttachmentOwnerType,
|
||||
TAttachmentContext,
|
||||
TAttachmentOwnerType,
|
||||
} from '../types';
|
||||
|
||||
export class AttachmentMetadataDto {
|
||||
/**
|
||||
@ -33,6 +44,7 @@ export class AttachmentMetadataDto {
|
||||
*/
|
||||
@ApiProperty({ description: 'Attachment size in bytes', type: Number })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
size: number;
|
||||
|
||||
/**
|
||||
@ -41,6 +53,7 @@ export class AttachmentMetadataDto {
|
||||
@ApiProperty({ description: 'Attachment MIME type', type: String })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsMimeType()
|
||||
type: string;
|
||||
|
||||
/**
|
||||
@ -50,6 +63,42 @@ export class AttachmentMetadataDto {
|
||||
@IsNotEmpty()
|
||||
@IsObject()
|
||||
channel?: Partial<Record<ChannelName, any>>;
|
||||
|
||||
/**
|
||||
* Attachment context
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Attachment Context',
|
||||
enum: Object.values(AttachmentContext),
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(Object.values(AttachmentContext))
|
||||
context: TAttachmentContext;
|
||||
|
||||
/**
|
||||
* Attachment Owner Type
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Attachment Owner Type',
|
||||
enum: Object.values(AttachmentOwnerType),
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(Object.values(AttachmentOwnerType))
|
||||
ownerType: TAttachmentOwnerType;
|
||||
|
||||
/**
|
||||
* Attachment Owner : Subscriber or User ID
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Attachment Owner : Subscriber / User ID',
|
||||
enum: Object.values(AttachmentContext),
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsObjectId({ message: 'Owner must be a valid ObjectId' })
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export class AttachmentCreateDto extends AttachmentMetadataDto {
|
||||
@ -75,3 +124,13 @@ export class AttachmentDownloadDto extends ObjectIdDto {
|
||||
@IsOptional()
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export class AttachmentContextParamDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Attachment Context',
|
||||
enum: Object.values(AttachmentContext),
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(Object.values(AttachmentContext))
|
||||
context?: TAttachmentContext;
|
||||
}
|
||||
|
282
api/src/attachment/guards/attachment-ability.guard.spec.ts
Normal file
282
api/src/attachment/guards/attachment-ability.guard.spec.ts
Normal file
@ -0,0 +1,282 @@
|
||||
/*
|
||||
* Copyright © 2025 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).
|
||||
*/
|
||||
|
||||
import { BadRequestException, ExecutionContext } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { Model } from '@/user/schemas/model.schema';
|
||||
import { Permission } from '@/user/schemas/permission.schema';
|
||||
import { ModelService } from '@/user/services/model.service';
|
||||
import { PermissionService } from '@/user/services/permission.service';
|
||||
import { Action } from '@/user/types/action.type';
|
||||
|
||||
import { attachment } from '../mocks/attachment.mock';
|
||||
import { Attachment } from '../schemas/attachment.schema';
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
|
||||
import { AttachmentGuard } from './attachment-ability.guard';
|
||||
|
||||
describe('AttachmentGuard', () => {
|
||||
let guard: AttachmentGuard;
|
||||
let permissionService: PermissionService;
|
||||
let modelService: ModelService;
|
||||
let attachmentService: AttachmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AttachmentGuard,
|
||||
{
|
||||
provide: PermissionService,
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: ModelService,
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: AttachmentService,
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<AttachmentGuard>(AttachmentGuard);
|
||||
permissionService = module.get<PermissionService>(PermissionService);
|
||||
modelService = module.get<ModelService>(ModelService);
|
||||
attachmentService = module.get<AttachmentService>(AttachmentService);
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should allow GET requests with valid context', async () => {
|
||||
const mockUser = { roles: ['admin-id'] } as any;
|
||||
const mockContext = ['user_avatar'];
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject('Invalid #1')
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user-id', 'attachment-id'].includes(criteria.model) ||
|
||||
criteria.action !== Action.READ
|
||||
? Promise.reject('Invalid #2')
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: Action.READ,
|
||||
role: 'admin-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
query: { where: { context: mockContext } },
|
||||
method: 'GET',
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for GET requests with invalid context', async () => {
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
query: { where: { context: 'invalid_context' } },
|
||||
method: 'GET',
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow GET requests with valid id', async () => {
|
||||
const mockUser = { roles: ['admin-id'] } as any;
|
||||
|
||||
jest
|
||||
.spyOn(attachmentService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return criteria !== '9'.repeat(24)
|
||||
? Promise.reject('Invalid ID')
|
||||
: Promise.resolve({
|
||||
id: '9'.repeat(24),
|
||||
context: `user_avatar`,
|
||||
} as Attachment);
|
||||
});
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject('Invalid #1')
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['user-id', 'attachment-id'].includes(criteria.model) ||
|
||||
criteria.action !== Action.READ
|
||||
? Promise.reject('Invalid #2')
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: Action.READ,
|
||||
role: 'admin-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
params: { id: '9'.repeat(24) },
|
||||
method: 'GET',
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow POST requests with valid context', async () => {
|
||||
const mockUser = { roles: ['editor-id'] } as any;
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject()
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block-id', 'attachment-id'].includes(criteria.model)
|
||||
? Promise.reject()
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: Action.CREATE,
|
||||
role: 'editor-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
query: { context: 'block_attachment' },
|
||||
method: 'POST',
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for DELETE requests with invalid attachment ID', async () => {
|
||||
jest.spyOn(attachmentService, 'findOne').mockResolvedValue(null);
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
method: 'DELETE',
|
||||
params: { id: 'invalid-id' },
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow DELETE requests with valid attachment and context', async () => {
|
||||
const mockUser = { roles: ['admin-id'] } as any;
|
||||
|
||||
jest.spyOn(attachmentService, 'findOne').mockResolvedValue(attachment);
|
||||
|
||||
jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block', 'attachment'].includes(criteria.identity)
|
||||
? Promise.reject('Invalid X')
|
||||
: Promise.resolve({
|
||||
identity: criteria.identity,
|
||||
id: `${criteria.identity}-id`,
|
||||
} as Model);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(permissionService, 'findOne')
|
||||
.mockImplementation((criteria) => {
|
||||
return typeof criteria === 'string' ||
|
||||
!['block-id', 'attachment-id'].includes(criteria.model) ||
|
||||
(criteria.model === 'block-id' &&
|
||||
criteria.action !== Action.UPDATE) ||
|
||||
(criteria.model === 'attachment-id' &&
|
||||
criteria.action !== Action.DELETE)
|
||||
? Promise.reject('Invalid Y')
|
||||
: Promise.resolve({
|
||||
model: criteria.model,
|
||||
action: criteria.action,
|
||||
role: 'admin-id',
|
||||
} as Permission);
|
||||
});
|
||||
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
method: 'DELETE',
|
||||
params: { id: attachment.id },
|
||||
user: mockUser,
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for unsupported HTTP methods', async () => {
|
||||
const mockExecutionContext = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue({
|
||||
method: 'PUT',
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
265
api/src/attachment/guards/attachment-ability.guard.ts
Normal file
265
api/src/attachment/guards/attachment-ability.guard.ts
Normal file
@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright © 2025 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).
|
||||
*/
|
||||
|
||||
import { Url } from 'url';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Types } from 'mongoose';
|
||||
import qs from 'qs';
|
||||
|
||||
import { User } from '@/user/schemas/user.schema';
|
||||
import { ModelService } from '@/user/services/model.service';
|
||||
import { PermissionService } from '@/user/services/permission.service';
|
||||
import { Action } from '@/user/types/action.type';
|
||||
import { TModel } from '@/user/types/model.type';
|
||||
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
import { TAttachmentContext } from '../types';
|
||||
import { isAttachmentContext, isAttachmentContextArray } from '../utilities';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly permissionService: PermissionService,
|
||||
private readonly modelService: ModelService,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
) {}
|
||||
|
||||
private permissionMap: Record<
|
||||
Action,
|
||||
Record<TAttachmentContext, [TModel, Action][]>
|
||||
> = {
|
||||
// Read attachments by context
|
||||
[Action.READ]: {
|
||||
setting_attachment: [
|
||||
['setting', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
user_avatar: [['user', Action.READ]],
|
||||
block_attachment: [
|
||||
['block', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
content_attachment: [
|
||||
['content', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
subscriber_avatar: [['subscriber', Action.READ]],
|
||||
message_attachment: [
|
||||
['message', Action.READ],
|
||||
['attachment', Action.READ],
|
||||
],
|
||||
},
|
||||
// Create attachments by context
|
||||
[Action.CREATE]: {
|
||||
setting_attachment: [
|
||||
['setting', Action.UPDATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
user_avatar: [
|
||||
// Not authorized, done via /user/:id/edit endpoint
|
||||
],
|
||||
block_attachment: [
|
||||
['block', Action.UPDATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
content_attachment: [
|
||||
['content', Action.UPDATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
subscriber_avatar: [
|
||||
// Not authorized, done programmatically by the channel
|
||||
],
|
||||
message_attachment: [
|
||||
// Unless we're in case of a handover, done programmatically by the channel
|
||||
['message', Action.CREATE],
|
||||
['attachment', Action.CREATE],
|
||||
],
|
||||
},
|
||||
// Delete attachments by context
|
||||
[Action.DELETE]: {
|
||||
setting_attachment: [
|
||||
['setting', Action.UPDATE],
|
||||
['attachment', Action.DELETE],
|
||||
],
|
||||
user_avatar: [
|
||||
// Not authorized
|
||||
],
|
||||
block_attachment: [
|
||||
['block', Action.UPDATE],
|
||||
['attachment', Action.DELETE],
|
||||
],
|
||||
content_attachment: [
|
||||
['content', Action.UPDATE],
|
||||
['attachment', Action.DELETE],
|
||||
],
|
||||
subscriber_avatar: [
|
||||
// Not authorized, done programmatically by the channel
|
||||
],
|
||||
message_attachment: [
|
||||
// Not authorized
|
||||
],
|
||||
},
|
||||
// Update attachments is not possible
|
||||
[Action.UPDATE]: {
|
||||
setting_attachment: [],
|
||||
user_avatar: [],
|
||||
block_attachment: [],
|
||||
content_attachment: [],
|
||||
subscriber_avatar: [],
|
||||
message_attachment: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the user has the required permission for a given action and model.
|
||||
*
|
||||
* @param user - The current authenticated user.
|
||||
* @param identity - The model identity being accessed.
|
||||
* @param action - The action being performed (e.g., CREATE, READ).
|
||||
* @returns A promise that resolves to `true` if the user has the required permission, otherwise `false`.
|
||||
*/
|
||||
private async hasPermission(
|
||||
user: Express.User & User,
|
||||
identity: TModel,
|
||||
action?: Action,
|
||||
) {
|
||||
if (Array.isArray(user?.roles)) {
|
||||
for (const role of user.roles) {
|
||||
const modelObj = await this.modelService.findOne({ identity });
|
||||
if (modelObj) {
|
||||
const { id: model } = modelObj;
|
||||
const hasRequiredPermission = await this.permissionService.findOne({
|
||||
action,
|
||||
role,
|
||||
model,
|
||||
});
|
||||
|
||||
return !!hasRequiredPermission;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is authorized to perform a given action on a attachment based on its context and user roles.
|
||||
*
|
||||
* @param action - The action on the attachment.
|
||||
* @param user - The current user.
|
||||
* @param context - The context of the attachment (e.g., user_avatar, setting_attachment).
|
||||
* @returns A promise that resolves to `true` if the user has the required upload permission, otherwise `false`.
|
||||
*/
|
||||
private async isAuthorized(
|
||||
action: Action,
|
||||
user: Express.User & User,
|
||||
context: TAttachmentContext,
|
||||
): Promise<boolean> {
|
||||
if (!action) {
|
||||
throw new TypeError('Invalid action');
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
throw new TypeError('Invalid context');
|
||||
}
|
||||
|
||||
const permissions = this.permissionMap[action][context];
|
||||
|
||||
if (!permissions.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
permissions.map(([identity, action]) =>
|
||||
this.hasPermission(user, identity, action),
|
||||
),
|
||||
)
|
||||
).every(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the user is authorized to perform the requested action.
|
||||
*
|
||||
* @param ctx - The execution context, providing details of the
|
||||
* incoming HTTP request and user information.
|
||||
*
|
||||
* @returns Returns `true` if the user is authorized, otherwise throws an exception.
|
||||
*/
|
||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||
const { query, _parsedUrl, method, user, params } = ctx
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user: User; _parsedUrl: Url }>();
|
||||
|
||||
switch (method) {
|
||||
// count(), find() and findOne() endpoints
|
||||
case 'GET': {
|
||||
if (params && 'id' in params && Types.ObjectId.isValid(params.id)) {
|
||||
const attachment = await this.attachmentService.findOne(params.id);
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found!');
|
||||
}
|
||||
|
||||
return await this.isAuthorized(Action.READ, user, attachment.context);
|
||||
} else if (query.where) {
|
||||
const { context = [] } = query.where as qs.ParsedQs;
|
||||
|
||||
if (!isAttachmentContextArray(context)) {
|
||||
throw new BadRequestException('Invalid context param');
|
||||
}
|
||||
|
||||
return (
|
||||
await Promise.all(
|
||||
context.map((c) => this.isAuthorized(Action.READ, user, c)),
|
||||
)
|
||||
).every(Boolean);
|
||||
} else {
|
||||
throw new BadRequestException('Invalid params');
|
||||
}
|
||||
}
|
||||
// upload() endpoint
|
||||
case 'POST': {
|
||||
const { context = '' } = query;
|
||||
if (!isAttachmentContext(context)) {
|
||||
throw new BadRequestException('Invalid context param');
|
||||
}
|
||||
|
||||
return await this.isAuthorized(Action.CREATE, user, context);
|
||||
}
|
||||
// deleteOne() endpoint
|
||||
case 'DELETE': {
|
||||
if (params && 'id' in params && Types.ObjectId.isValid(params.id)) {
|
||||
const attachment = await this.attachmentService.findOne(params.id);
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Invalid attachment ID');
|
||||
}
|
||||
|
||||
return await this.isAuthorized(
|
||||
Action.DELETE,
|
||||
user,
|
||||
attachment.context,
|
||||
);
|
||||
} else {
|
||||
throw new BadRequestException('Invalid params');
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new BadRequestException('Invalid operation');
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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.
|
||||
@ -16,9 +16,12 @@ export const attachment: Attachment = {
|
||||
size: 343370,
|
||||
location:
|
||||
'/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
|
||||
context: 'block_attachment',
|
||||
id: '65940d115178607da65c82b6',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
owner: '1',
|
||||
ownerType: 'User',
|
||||
};
|
||||
|
||||
export const attachmentFile: Express.Multer.File = {
|
||||
@ -28,7 +31,7 @@ export const attachmentFile: Express.Multer.File = {
|
||||
buffer: Buffer.from(new Uint8Array([])),
|
||||
destination: '',
|
||||
fieldname: '',
|
||||
originalname: '',
|
||||
originalname: attachment.name,
|
||||
path: '',
|
||||
stream: new Stream.Readable(),
|
||||
encoding: '7bit',
|
||||
@ -42,10 +45,13 @@ export const attachments: Attachment[] = [
|
||||
size: 343370,
|
||||
location:
|
||||
'/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png',
|
||||
channel: { 'web-channel': {} },
|
||||
channel: { ['some-channel']: {} },
|
||||
context: 'block_attachment',
|
||||
id: '65940d115178607da65c82b7',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
owner: '1',
|
||||
ownerType: 'User',
|
||||
},
|
||||
{
|
||||
name: 'Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
|
||||
@ -53,9 +59,12 @@ export const attachments: Attachment[] = [
|
||||
size: 33829,
|
||||
location:
|
||||
'/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png',
|
||||
channel: { 'web-channel': {} },
|
||||
channel: { ['some-channel']: {} },
|
||||
context: 'block_attachment',
|
||||
id: '65940d115178607da65c82b8',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
owner: '1',
|
||||
ownerType: 'User',
|
||||
},
|
||||
];
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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.
|
||||
@ -7,28 +7,32 @@
|
||||
*/
|
||||
|
||||
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { Schema as MongooseSchema } from 'mongoose';
|
||||
|
||||
import { ChannelName } from '@/channel/types';
|
||||
import { Subscriber } from '@/chat/schemas/subscriber.schema';
|
||||
import { FileType } from '@/chat/schemas/types/attachment';
|
||||
import { config } from '@/config';
|
||||
import { User } from '@/user/schemas/user.schema';
|
||||
import { BaseSchema } from '@/utils/generics/base-schema';
|
||||
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
|
||||
import { buildURL } from '@/utils/helpers/URL';
|
||||
import { THydratedDocument } from '@/utils/types/filter.types';
|
||||
import {
|
||||
TFilterPopulateFields,
|
||||
THydratedDocument,
|
||||
} from '@/utils/types/filter.types';
|
||||
|
||||
import {
|
||||
AttachmentContext,
|
||||
AttachmentOwnerType,
|
||||
TAttachmentContext,
|
||||
TAttachmentOwnerType,
|
||||
} from '../types';
|
||||
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 {
|
||||
export class AttachmentStub extends BaseSchema {
|
||||
/**
|
||||
* The name of the attachment.
|
||||
*/
|
||||
@ -72,7 +76,29 @@ export class Attachment extends BaseSchema {
|
||||
* Optional property representing the attachment channel, can hold a partial record of various channel data.
|
||||
*/
|
||||
@Prop({ type: JSON })
|
||||
channel?: Partial<Record<string, any>>;
|
||||
channel?: Partial<Record<ChannelName, any>>;
|
||||
|
||||
/**
|
||||
* Object ID of the owner (depending on the owner type)
|
||||
*/
|
||||
@Prop({
|
||||
type: MongooseSchema.Types.ObjectId,
|
||||
refPath: 'ownerType',
|
||||
default: null,
|
||||
})
|
||||
owner: unknown;
|
||||
|
||||
/**
|
||||
* Type of the owner (depending on the owner type)
|
||||
*/
|
||||
@Prop({ type: String, enum: Object.values(AttachmentOwnerType) })
|
||||
ownerType: TAttachmentOwnerType;
|
||||
|
||||
/**
|
||||
* Context of the attachment
|
||||
*/
|
||||
@Prop({ type: String, enum: Object.values(AttachmentContext) })
|
||||
context: TAttachmentContext;
|
||||
|
||||
/**
|
||||
* Optional property representing the URL of the attachment.
|
||||
@ -114,6 +140,24 @@ export class Attachment extends BaseSchema {
|
||||
}
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class Attachment extends AttachmentStub {
|
||||
@Transform(({ obj }) => obj.owner?.toString() || null)
|
||||
owner: string | null;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class UserAttachmentFull extends AttachmentStub {
|
||||
@Type(() => User)
|
||||
owner: User | null;
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class SubscriberAttachmentFull extends AttachmentStub {
|
||||
@Type(() => Subscriber)
|
||||
owner: Subscriber | null;
|
||||
}
|
||||
|
||||
export type AttachmentDocument = THydratedDocument<Attachment>;
|
||||
|
||||
export const AttachmentModel: ModelDefinition = LifecycleHookManager.attach({
|
||||
@ -132,3 +176,10 @@ AttachmentModel.schema.virtual('url').get(function () {
|
||||
});
|
||||
|
||||
export default AttachmentModel.schema;
|
||||
|
||||
export type AttachmentPopulate = keyof TFilterPopulateFields<
|
||||
Attachment,
|
||||
AttachmentStub
|
||||
>;
|
||||
|
||||
export const ATTACHMENT_POPULATE: AttachmentPopulate[] = ['owner'];
|
||||
|
@ -155,43 +155,6 @@ export class AttachmentService extends BaseService<Attachment> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
|
||||
* Otherwise, uploads files to the local directory.
|
||||
*
|
||||
* @deprecated use store() instead
|
||||
* @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[] }) {
|
||||
const uploadedFiles: Attachment[] = [];
|
||||
|
||||
if (this.getStoragePlugin()) {
|
||||
for (const file of files?.file) {
|
||||
const dto = await this.getStoragePlugin()?.upload?.(file);
|
||||
if (dto) {
|
||||
const uploadedFile = await this.create(dto);
|
||||
uploadedFiles.push(uploadedFile);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(files?.file)) {
|
||||
for (const { size, mimetype, filename } of files?.file) {
|
||||
const uploadedFile = await this.create({
|
||||
size,
|
||||
type: mimetype,
|
||||
name: filename,
|
||||
channel: {},
|
||||
location: `/${filename}`,
|
||||
});
|
||||
uploadedFiles.push(uploadedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
|
||||
* Otherwise, uploads files to the local directory.
|
||||
|
33
api/src/attachment/types/index.ts
Normal file
33
api/src/attachment/types/index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright © 2025 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).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Defines the types of owners for an attachment,
|
||||
* indicating whether the file belongs to a User or a Subscriber.
|
||||
*/
|
||||
export enum AttachmentOwnerType {
|
||||
User = 'User',
|
||||
Subscriber = 'Subscriber',
|
||||
}
|
||||
|
||||
export type TAttachmentOwnerType = `${AttachmentOwnerType}`;
|
||||
|
||||
/**
|
||||
* Defines the various contexts in which an attachment can exist.
|
||||
* These contexts influence how the attachment is uploaded, stored, and accessed:
|
||||
*/
|
||||
export enum AttachmentContext {
|
||||
SettingAttachment = 'setting_attachment', // Attachments related to app settings, restricted to users with specific permissions.
|
||||
UserAvatar = 'user_avatar', // Avatar files for users, only the current user can upload, accessible to those with appropriate permissions.
|
||||
SubscriberAvatar = 'subscriber_avatar', // Avatar files for subscribers, uploaded programmatically, accessible to authorized users.
|
||||
BlockAttachment = 'block_attachment', // Files sent by the bot, public or private based on the channel and user authentication.
|
||||
ContentAttachment = 'content_attachment', // Files in the knowledge base, usually public but could vary based on specific needs.
|
||||
MessageAttachment = 'message_attachment', // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.;
|
||||
}
|
||||
|
||||
export type TAttachmentContext = `${AttachmentContext}`;
|
@ -15,6 +15,8 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { config } from '@/config';
|
||||
|
||||
import { AttachmentContext, TAttachmentContext } from '../types';
|
||||
|
||||
export const MIME_REGEX = /^[a-z-]+\/[0-9a-z\-.]+$/gm;
|
||||
|
||||
/**
|
||||
@ -78,3 +80,29 @@ export const generateUniqueFilename = (originalname: string) => {
|
||||
const name = originalname.slice(0, -extension.length);
|
||||
return `${name}-${uuidv4()}${extension}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given context is of type TAttachmentContext.
|
||||
*
|
||||
* @param ctx - The context to check.
|
||||
* @returns True if the context is of type TAttachmentContext, otherwise false.
|
||||
*/
|
||||
export const isAttachmentContext = (ctx: any): ctx is TAttachmentContext => {
|
||||
return Object.values(AttachmentContext).includes(ctx);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given list is an array of TAttachmentContext.
|
||||
*
|
||||
* @param ctxList - The list of contexts to check.
|
||||
* @returns True if all items in the list are of type TAttachmentContext, otherwise false.
|
||||
*/
|
||||
export const isAttachmentContextArray = (
|
||||
ctxList: any,
|
||||
): ctxList is TAttachmentContext[] => {
|
||||
return (
|
||||
Array.isArray(ctxList) &&
|
||||
ctxList.length > 0 &&
|
||||
ctxList.every(isAttachmentContext)
|
||||
);
|
||||
};
|
||||
|
@ -88,6 +88,9 @@ const attachment: Attachment = {
|
||||
id: 'any-channel-attachment-id',
|
||||
},
|
||||
},
|
||||
context: 'block_attachment',
|
||||
ownerType: 'User',
|
||||
owner: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
@ -616,17 +616,14 @@ export default abstract class BaseWebChannelHandler<
|
||||
throw new Error('Max upload size has been exceeded');
|
||||
}
|
||||
|
||||
const attachment = await this.attachmentService.store(data.file, {
|
||||
return await this.attachmentService.store(data.file, {
|
||||
name: data.name,
|
||||
size: Buffer.byteLength(data.file),
|
||||
type: data.type,
|
||||
context: 'message_attachment',
|
||||
ownerType: 'Subscriber',
|
||||
owner: req.session.web.profile?.id,
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
return attachment;
|
||||
} else {
|
||||
throw new Error('Unable to retrieve stored attachment');
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Web Channel Handler : Unable to store uploaded file',
|
||||
@ -685,18 +682,14 @@ export default abstract class BaseWebChannelHandler<
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const attachment = await this.attachmentService.store(file, {
|
||||
name: file.originalname,
|
||||
size: file.size,
|
||||
type: file.mimetype,
|
||||
});
|
||||
if (attachment) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
throw new Error('Unable to store uploaded file');
|
||||
}
|
||||
return await this.attachmentService.store(file, {
|
||||
name: file.originalname,
|
||||
size: file.size,
|
||||
type: file.mimetype,
|
||||
context: 'message_attachment',
|
||||
ownerType: 'Subscriber',
|
||||
owner: req.session.web.profile?.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Web Channel Handler : Unable to store uploaded file',
|
||||
|
@ -299,6 +299,9 @@ export class ReadWriteUserController extends ReadOnlyUserController {
|
||||
name: avatarFile.originalname,
|
||||
size: avatarFile.size,
|
||||
type: avatarFile.mimetype,
|
||||
context: 'user_avatar',
|
||||
ownerType: 'User',
|
||||
owner: req.user.id,
|
||||
},
|
||||
config.parameters.avatarDir,
|
||||
)
|
||||
|
@ -1,12 +1,12 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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).
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
@ -59,7 +59,7 @@ import { ValidateAccountService } from './services/validate-account.service';
|
||||
session: true,
|
||||
}),
|
||||
JwtModule,
|
||||
AttachmentModule,
|
||||
forwardRef(() => AttachmentModule),
|
||||
],
|
||||
providers: [
|
||||
PermissionSeeder,
|
||||
|
9
api/src/utils/test/fixtures/attachment.ts
vendored
9
api/src/utils/test/fixtures/attachment.ts
vendored
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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.
|
||||
@ -22,8 +22,10 @@ export const attachmentFixtures: AttachmentCreateDto[] = [
|
||||
id: '1',
|
||||
},
|
||||
},
|
||||
context: 'content_attachment',
|
||||
ownerType: 'User',
|
||||
owner: null,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'store2.jpg',
|
||||
type: 'image/jpeg',
|
||||
@ -34,6 +36,9 @@ export const attachmentFixtures: AttachmentCreateDto[] = [
|
||||
id: '2',
|
||||
},
|
||||
},
|
||||
context: 'content_attachment',
|
||||
ownerType: 'User',
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user