feat(api): add attachment extra attributes

This commit is contained in:
Mohamed Marrouchi 2025-01-08 16:42:46 +01:00
parent 47e8056a15
commit 994c8857e9
16 changed files with 872 additions and 109 deletions

View File

@ -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],

View File

@ -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'],
);
});
});

View File

@ -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;
}
/**

View File

@ -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;
}

View 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,
);
});
});
});

View 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');
}
}
}

View File

@ -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',
},
];

View File

@ -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'];

View File

@ -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.

View 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}`;

View File

@ -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)
);
};

View File

@ -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(),
};

View File

@ -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',

View 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,
)

View File

@ -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,

View File

@ -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,
},
];