diff --git a/api/src/attachment/attachment.module.ts b/api/src/attachment/attachment.module.ts index 4e5008d8..8e5a35aa 100644 --- a/api/src/attachment/attachment.module.ts +++ b/api/src/attachment/attachment.module.ts @@ -10,8 +10,12 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PassportModule } from '@nestjs/passport'; +import { UserModule } from '@/user/user.module'; + import { AttachmentController } from './controllers/attachment.controller'; import { AttachmentRepository } from './repositories/attachment.repository'; +import { SubscriberAttachmentRepository } from './repositories/subscriber-attachment.repository'; +import { UserAttachmentRepository } from './repositories/user-attachment.repository'; import { AttachmentModel } from './schemas/attachment.schema'; import { AttachmentService } from './services/attachment.service'; @@ -21,8 +25,14 @@ import { AttachmentService } from './services/attachment.service'; PassportModule.register({ session: true, }), + UserModule, + ], + providers: [ + AttachmentRepository, + UserAttachmentRepository, + SubscriberAttachmentRepository, + AttachmentService, ], - providers: [AttachmentRepository, AttachmentService], controllers: [AttachmentController], exports: [AttachmentService], }) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index a0cf19e8..5298dd1a 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -6,14 +6,22 @@ * 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 { @@ -27,7 +35,7 @@ import { import { attachment, attachmentFile } from '../mocks/attachment.mock'; import { AttachmentRepository } from '../repositories/attachment.repository'; -import { AttachmentModel, Attachment } from '../schemas/attachment.schema'; +import { Attachment, AttachmentModel } from '../schemas/attachment.schema'; import { AttachmentService } from '../services/attachment.service'; import { AttachmentController } from './attachment.controller'; @@ -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 = @@ -78,29 +102,42 @@ describe('AttachmentController', () => { describe('Upload', () => { it('should throw BadRequestException if no file is selected to be uploaded', async () => { - const promiseResult = attachmentController.uploadFile({ - file: undefined, - }); + const promiseResult = attachmentController.uploadFile( + {} as Request, + { + file: undefined, + }, + { context: 'block_attachment' }, + ); await expect(promiseResult).rejects.toThrow( new BadRequestException('No file was selected'), ); }); it('should upload attachment', async () => { + const id = '1'.repeat(24); 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}`, - }); + const result = await attachmentController.uploadFile( + { session: { passport: { user: { id } } } } as Request, + { + file: [attachmentFile], + }, + { context: 'block_attachment' }, + ); + // const [name, ext] = attachmentFile.filename.split('.'); + expect(attachmentService.create).toHaveBeenCalledWith( + expect.objectContaining({ + size: attachmentFile.size, + type: attachmentFile.mimetype, + name: attachmentFile.filename, + ownerType: 'User', + owner: id, + context: 'block_attachment', + }), + ); expect(result).toEqualPayload( - [attachment], - [...IGNORED_TEST_FIELDS, 'url'], + [{ ...attachment, ownerType: 'User', owner: id }], + [...IGNORED_TEST_FIELDS, 'url', 'location'], ); }); }); diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index 3607903f..ed83660c 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -6,8 +6,6 @@ * 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, @@ -18,19 +16,21 @@ import { Param, Post, Query, + Req, StreamableFile, + UnauthorizedException, 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'; 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'; @@ -38,12 +38,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 { constructor( private readonly attachmentService: AttachmentService, @@ -61,7 +66,7 @@ export class AttachmentController extends BaseController { async filterCount( @Query( new SearchFilterPipe({ - allowedFields: ['name', 'type'], + allowedFields: ['name', 'type', 'context'], }), ) filters?: TFilterQuery, @@ -72,10 +77,12 @@ export class AttachmentController extends BaseController { @Get(':id') async findOne(@Param('id') id: string): Promise { const doc = await this.attachmentService.findOne(id); + if (!doc) { this.logger.warn(`Unable to find Attachment by id ${id}`); throw new NotFoundException(`Attachment with ID ${id} not found`); } + return doc; } @@ -90,7 +97,9 @@ export class AttachmentController extends BaseController { async findPage( @Query(PageQueryPipe) pageQuery: PageQueryDto, @Query( - new SearchFilterPipe({ allowedFields: ['name', 'type'] }), + new SearchFilterPipe({ + allowedFields: ['name', 'type', 'context'], + }), ) filters: TFilterQuery, ) { @@ -114,26 +123,41 @@ export class AttachmentController extends BaseController { 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( + @Req() req: Request, @UploadedFiles() files: { file: Express.Multer.File[] }, + @Query() { context }: AttachmentContextParamDto, ): Promise { 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 UnauthorizedException( + '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; } /** @@ -142,7 +166,6 @@ export class AttachmentController extends BaseController { * @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, diff --git a/api/src/attachment/dto/attachment.dto.ts b/api/src/attachment/dto/attachment.dto.ts index ce180dc0..7e36dd24 100644 --- a/api/src/attachment/dto/attachment.dto.ts +++ b/api/src/attachment/dto/attachment.dto.ts @@ -9,7 +9,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + IsIn, + IsMimeType, IsNotEmpty, + IsNumber, IsObject, IsOptional, IsString, @@ -18,10 +21,18 @@ 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 { /** - * Attachment original file name + * Attachment name */ @ApiProperty({ description: 'Attachment original file name', type: String }) @IsNotEmpty() @@ -33,6 +44,7 @@ export class AttachmentMetadataDto { */ @ApiProperty({ description: 'Attachment size in bytes', type: Number }) @IsNotEmpty() + @IsNumber() size: number; /** @@ -41,15 +53,50 @@ export class AttachmentMetadataDto { @ApiProperty({ description: 'Attachment MIME type', type: String }) @IsNotEmpty() @IsString() + @IsMimeType() type: string; /** - * Attachment specia channel(s) metadata + * Attachment channel */ @ApiPropertyOptional({ description: 'Attachment channel', type: Object }) @IsNotEmpty() @IsObject() channel?: Partial>; + + /** + * Attachment context + */ + @ApiPropertyOptional({ + description: 'Attachment Context', + enum: Object.values(AttachmentContext), + }) + @IsString() + @IsIn(Object.values(AttachmentContext)) + context: TAttachmentContext; + + /** + * Attachment Owner Type + */ + @ApiPropertyOptional({ + description: 'Attachment Owner Type', + enum: Object.values(AttachmentOwnerType), + }) + @IsString() + @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 +122,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; +} diff --git a/api/src/attachment/guards/attachment-ability.guard.spec.ts b/api/src/attachment/guards/attachment-ability.guard.spec.ts new file mode 100644 index 00000000..4e207ded --- /dev/null +++ b/api/src/attachment/guards/attachment-ability.guard.spec.ts @@ -0,0 +1,282 @@ +/* + * 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). + */ + +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); + permissionService = module.get(PermissionService); + modelService = module.get(ModelService); + attachmentService = module.get(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, + ); + }); + }); +}); diff --git a/api/src/attachment/guards/attachment-ability.guard.ts b/api/src/attachment/guards/attachment-ability.guard.ts new file mode 100644 index 00000000..b2bef05a --- /dev/null +++ b/api/src/attachment/guards/attachment-ability.guard.ts @@ -0,0 +1,273 @@ +/* + * 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). + */ + +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 + > = { + // Read attachments by context + [Action.READ]: { + setting_attachment: [ + ['setting', Action.READ], + ['attachment', Action.READ], + ], + user_avatar: [ + ['user', Action.READ], + ['attachment', Action.READ], + ], + block_attachment: [ + ['block', Action.READ], + ['attachment', Action.READ], + ], + content_attachment: [ + ['content', Action.READ], + ['attachment', Action.READ], + ], + subscriber_avatar: [ + ['subscriber', Action.READ], + ['attachment', 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: [ + // Only when user if updating his own avatar + ['attachment', Action.CREATE], + ], + 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: [ + // Only when user if updating his own avatar + ['attachment', Action.DELETE], + ], + 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 { + 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 { + const { query, _parsedUrl, method, user, params } = ctx + .switchToHttp() + .getRequest(); + + 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'); + } + } +} diff --git a/api/src/attachment/mocks/attachment.mock.ts b/api/src/attachment/mocks/attachment.mock.ts index 34966a39..43be041e 100644 --- a/api/src/attachment/mocks/attachment.mock.ts +++ b/api/src/attachment/mocks/attachment.mock.ts @@ -16,6 +16,7 @@ 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(), @@ -28,7 +29,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,7 +43,8 @@ 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(), @@ -53,7 +55,8 @@ 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(), diff --git a/api/src/attachment/repositories/attachment.repository.ts b/api/src/attachment/repositories/attachment.repository.ts index a686cd43..01a1fdaf 100644 --- a/api/src/attachment/repositories/attachment.repository.ts +++ b/api/src/attachment/repositories/attachment.repository.ts @@ -16,7 +16,7 @@ import { BaseRepository } from '@/utils/generics/base-repository'; import { Attachment } from '../schemas/attachment.schema'; @Injectable() -export class AttachmentRepository extends BaseRepository { +export class AttachmentRepository extends BaseRepository { constructor( readonly eventEmitter: EventEmitter2, @InjectModel(Attachment.name) readonly model: Model, diff --git a/api/src/attachment/repositories/subscriber-attachment.repository.ts b/api/src/attachment/repositories/subscriber-attachment.repository.ts new file mode 100644 index 00000000..87488040 --- /dev/null +++ b/api/src/attachment/repositories/subscriber-attachment.repository.ts @@ -0,0 +1,58 @@ +/* + * 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). + */ + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, ProjectionType, Query } from 'mongoose'; + +import { BaseRepository } from '@/utils/generics/base-repository'; +import { + PageQueryDto, + QuerySortDto, +} from '@/utils/pagination/pagination-query.dto'; +import { TFilterQuery } from '@/utils/types/filter.types'; + +import { + Attachment, + ATTACHMENT_POPULATE, + AttachmentPopulate, + SubscriberAttachmentFull, +} from '../schemas/attachment.schema'; + +@Injectable() +export class SubscriberAttachmentRepository extends BaseRepository< + Attachment, + AttachmentPopulate, + SubscriberAttachmentFull +> { + constructor( + readonly eventEmitter: EventEmitter2, + @InjectModel(Attachment.name) readonly model: Model, + ) { + super( + eventEmitter, + model, + Attachment, + ATTACHMENT_POPULATE, + SubscriberAttachmentFull, + ); + } + + protected findQuery( + filter: TFilterQuery, + pageQuery?: QuerySortDto | PageQueryDto, + projection?: ProjectionType, + ): Query { + return super.findQuery( + { ...filter, ownerType: 'Subscriber' }, + pageQuery as PageQueryDto, + projection, + ); + } +} diff --git a/api/src/attachment/repositories/user-attachment.repository.ts b/api/src/attachment/repositories/user-attachment.repository.ts new file mode 100644 index 00000000..5eb35b1b --- /dev/null +++ b/api/src/attachment/repositories/user-attachment.repository.ts @@ -0,0 +1,58 @@ +/* + * 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). + */ + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, ProjectionType, Query } from 'mongoose'; + +import { BaseRepository } from '@/utils/generics/base-repository'; +import { + PageQueryDto, + QuerySortDto, +} from '@/utils/pagination/pagination-query.dto'; +import { TFilterQuery } from '@/utils/types/filter.types'; + +import { + Attachment, + ATTACHMENT_POPULATE, + AttachmentPopulate, + UserAttachmentFull, +} from '../schemas/attachment.schema'; + +@Injectable() +export class UserAttachmentRepository extends BaseRepository< + Attachment, + AttachmentPopulate, + UserAttachmentFull +> { + constructor( + readonly eventEmitter: EventEmitter2, + @InjectModel(Attachment.name) readonly model: Model, + ) { + super( + eventEmitter, + model, + Attachment, + ATTACHMENT_POPULATE, + UserAttachmentFull, + ); + } + + protected findQuery( + filter: TFilterQuery, + pageQuery?: QuerySortDto | PageQueryDto, + projection?: ProjectionType, + ): Query { + return super.findQuery( + { ...filter, ownerType: 'User' }, + pageQuery as PageQueryDto, + projection, + ); + } +} diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index fed8dd90..d9a0ad9e 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -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; -} - @Schema({ timestamps: true }) -export class Attachment extends BaseSchema { +export class AttachmentStub extends BaseSchema { /** * The name of the attachment. */ @@ -72,11 +76,32 @@ 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>; + channel?: Partial>; + + /** + * 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. - * */ url?: string; @@ -114,6 +139,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; export const AttachmentModel: ModelDefinition = LifecycleHookManager.attach({ @@ -132,3 +175,10 @@ AttachmentModel.schema.virtual('url').get(function () { }); export default AttachmentModel.schema; + +export type AttachmentPopulate = keyof TFilterPopulateFields< + Attachment, + AttachmentStub +>; + +export const ATTACHMENT_POPULATE: AttachmentPopulate[] = ['owner']; diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 62305537..7b8ea2a0 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -16,6 +16,7 @@ import { Optional, StreamableFile, } from '@nestjs/common'; +import { ProjectionType } from 'mongoose'; import fetch from 'node-fetch'; import sanitizeFilename from 'sanitize-filename'; @@ -25,10 +26,19 @@ 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 { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; +import { TFilterQuery } from '@/utils/types/filter.types'; import { AttachmentMetadataDto } from '../dto/attachment.dto'; import { AttachmentRepository } from '../repositories/attachment.repository'; -import { Attachment } from '../schemas/attachment.schema'; +import { SubscriberAttachmentRepository } from '../repositories/subscriber-attachment.repository'; +import { UserAttachmentRepository } from '../repositories/user-attachment.repository'; +import { + Attachment, + SubscriberAttachmentFull, + UserAttachmentFull, +} from '../schemas/attachment.schema'; +import { TAttachmentOwnerType } from '../types'; import { fileExists, generateUniqueFilename, @@ -43,6 +53,10 @@ export class AttachmentService extends BaseService { readonly repository: AttachmentRepository, private readonly logger: LoggerService, @Optional() private readonly pluginService: PluginService, + @Optional() + private readonly userAttachmentRepository: UserAttachmentRepository, + @Optional() + private readonly subscriberAttachmentRepository: SubscriberAttachmentRepository, ) { super(repository); } @@ -157,6 +171,7 @@ export class AttachmentService extends BaseService { * Uploads files to the server. If a storage plugin is configured it uploads files accordingly. * Otherwise, uploads files to the local directory. * + * @deprecated * @param files - An array of files to upload. * @returns A promise that resolves to an array of uploaded attachments. */ @@ -178,6 +193,7 @@ export class AttachmentService extends BaseService { name: filename, channel: {}, location: `/${filename}`, + context: 'block_attachment', }); uploadedFiles.push(uploadedFile); } @@ -286,4 +302,94 @@ export class AttachmentService extends BaseService { return await fs.promises.readFile(filePath); // Reads the file content as a Buffer } } + + /** + * @deprecated + */ + async findOneAndPopulate(): Promise { + throw new TypeError( + 'Illegal call, use findOneAndPopulateByOwner() instead', + ); + } + + /** + * Finds and populates an attachment based on the provided owner type. + * + * @param ownerType - Specifies the type of attachment owner, either 'Subscriber' or 'User'. + * @param criteria - The criteria to locate the attachment. Can be a string or a filter query. + * @param projection - Optional projection to select specific fields of the attachment. + * @returns A promise that resolves to the populated attachment of type `A`. + */ + async findOneAndPopulateByOwner< + O extends TAttachmentOwnerType = TAttachmentOwnerType, + A = O extends 'Subscriber' ? SubscriberAttachmentFull : UserAttachmentFull, + >( + ownerType: O, + criteria: string | TFilterQuery, + projection?: ProjectionType, + ): Promise { + if (ownerType === 'Subscriber') { + return (await this.subscriberAttachmentRepository.findOneAndPopulate( + criteria, + projection, + )) as A; + } else if (ownerType === 'User') { + return (await this.userAttachmentRepository.findOneAndPopulate( + criteria, + projection, + )) as A; + } else { + throw new TypeError('Unknown owner type.'); + } + } + + /** + * @deprecated + */ + async findAndPopulate(): Promise { + throw new TypeError('Illegal call, use findAndPopulateByOwner() instead'); + } + + /** + * Finds and populates attachments based on the provided owner type. + * + * @param ownerType - Specifies the type of attachment owner, either 'Subscriber' or 'User'. + * @param criteria - The criteria to locate the attachments. + * @param projection - Optional projection to select specific fields of the attachment. + * @returns A promise that resolves to the populated attachments of type `A`. + */ + async findAndPopulateByOwner< + O extends TAttachmentOwnerType = TAttachmentOwnerType, + A = O extends 'Subscriber' + ? SubscriberAttachmentFull[] + : UserAttachmentFull[], + >( + ownerType: O, + criteria: TFilterQuery, + pageQuery?: PageQueryDto, + projection?: ProjectionType, + ): Promise { + if (ownerType === 'Subscriber') { + return (await this.subscriberAttachmentRepository.findAndPopulate( + criteria, + pageQuery, + projection, + )) as A; + } else if (ownerType === 'User') { + return (await this.userAttachmentRepository.findAndPopulate( + criteria, + pageQuery, + projection, + )) as A; + } else { + throw new TypeError('Unknown owner type.'); + } + } + + /** + * @deprecated + */ + async findPageAndPopulate(): Promise { + throw new TypeError('Illegal call, use findAndPopulateByOwner() instead'); + } } diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts new file mode 100644 index 00000000..dabe44a7 --- /dev/null +++ b/api/src/attachment/types/index.ts @@ -0,0 +1,33 @@ +/* + * 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). + */ + +/** + * 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}`; diff --git a/api/src/attachment/utilities/index.ts b/api/src/attachment/utilities/index.ts index 874ff0a0..e7ad9247 100644 --- a/api/src/attachment/utilities/index.ts +++ b/api/src/attachment/utilities/index.ts @@ -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; /** @@ -80,3 +82,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) + ); +}; diff --git a/api/src/channel/channel.module.ts b/api/src/channel/channel.module.ts index f67d6877..9306aeb2 100644 --- a/api/src/channel/channel.module.ts +++ b/api/src/channel/channel.module.ts @@ -13,6 +13,7 @@ import { Module, RequestMethod, } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; import { AttachmentModule } from '@/attachment/attachment.module'; @@ -38,10 +39,10 @@ export interface ChannelModuleOptions { 'dist/.hexabot/custom/extensions/channels/**/*.channel.js', ) @Module({ + imports: [ChatModule, AttachmentModule, CmsModule, HttpModule, JwtModule], controllers: [WebhookController, ChannelController], providers: [ChannelService], exports: [ChannelService], - imports: [ChatModule, AttachmentModule, CmsModule, HttpModule], }) export class ChannelModule { configure(consumer: MiddlewareConsumer) { diff --git a/api/src/channel/channel.service.ts b/api/src/channel/channel.service.ts index 4c2d9986..11476ab0 100644 --- a/api/src/channel/channel.service.ts +++ b/api/src/channel/channel.service.ts @@ -85,6 +85,19 @@ export class ChannelService { return handler as C; } + /** + * Handles a download request for a specific channel. + * + * @param channel - The channel for which the request is being handled. + * @param token - The file JWT token. + * @param req - The HTTP express request object. + * @returns A promise that resolves a streamable if the signed url is valid. + */ + async download(channel: string, token: string, req: Request) { + const handler = this.getChannelHandler(`${channel}-channel`); + return await handler.download(token, req); + } + /** * Handles a request for a specific channel. * diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index b5ed69fd..483ba975 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -58,10 +58,19 @@ export default abstract class EventWrapper< * @param event - The message event received * @param channelAttrs - Channel's specific data */ - constructor(handler: C, event: A['raw'], channelAttrs: S = {} as S) { + constructor( + handler: C, + event: A['raw'], + channelAttrs: S = {} as S, + initialize = true, + ) { this._handler = handler; - this._init(event); + this._adapter.raw = event; this.channelAttrs = channelAttrs; + + if (initialize) { + this._init(event); + } } toString() { @@ -94,7 +103,7 @@ export default abstract class EventWrapper< *- `_adapter.raw` : Sets a typed object of the event raw data * @param event - The message event received from a given channel */ - abstract _init(event: A['raw']): void; + abstract _init(event: A['raw']): Promise | void; /** * Retrieves the current channel handler. @@ -123,30 +132,6 @@ export default abstract class EventWrapper< */ abstract getId(): string; - /** - * Sets an event attribute value - * - * @param attr - Event attribute name - * @param value - The value to set for the specified attribute. - */ - set(attr: string, value: any) { - (this._adapter as any).raw[attr] = value; - } - - /** - * Returns an event attribute value, default value if it does exist - * - * @param attr - Event attribute name - * @param otherwise - Default value if attribute does not exist - * - * @returns The value of the specified attribute or the default value. - */ - get(attr: string, otherwise: any): any { - return attr in (this._adapter as any).raw - ? ((this._adapter as any).raw as any)[attr] - : otherwise || {}; - } - /** * Returns attached NLP parse results * @@ -400,3 +385,23 @@ export class GenericEventWrapper extends EventWrapper< return 0; } } + +export const createEventWrapper = async < + A extends { + eventType: StdEventType; + messageType?: IncomingMessageType; + raw: E; + }, + E, + N extends ChannelName, + C extends ChannelHandler, + S, + T extends EventWrapper, +>( + clz: new (...args: any) => T, + ...args: ConstructorParameters +) => { + const e = new clz(...args, false); + await e._init(e._adapter.raw); + return e; +}; diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index fb277fdf..f1f13728 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -8,17 +8,28 @@ import path from 'path'; -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { + Inject, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { JwtService, JwtSignOptions } from '@nestjs/jwt'; +import { plainToClass } from 'class-transformer'; import { NextFunction, Request, Response } from 'express'; +import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { AttachmentService } from '@/attachment/services/attachment.service'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; import { StdOutgoingEnvelope, StdOutgoingMessage, } from '@/chat/schemas/types/message'; +import { config } from '@/config'; import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; import { Extension } from '@/utils/generics/extension'; +import { buildURL } from '@/utils/helpers/URL'; import { HyphenToUnderscore } from '@/utils/types/extension'; import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; @@ -37,6 +48,19 @@ export default abstract class ChannelHandler< { private readonly settings: ChannelSetting[]; + @Inject(AttachmentService) + protected readonly attachmentService: AttachmentService; + + @Inject(JwtService) + protected readonly jwtService: JwtService; + + protected readonly jwtSignOptions: JwtSignOptions = { + secret: config.parameters.signedUrl.secret, + expiresIn: config.parameters.signedUrl.expiresIn, + algorithm: 'HS256', + encoding: 'utf-8', + }; + constructor( name: N, protected readonly settingService: SettingService, @@ -200,4 +224,45 @@ export default abstract class ChannelHandler< // Do nothing, override in channel next(); } + + /** + * Generates a signed URL for downloading an attachment. + * + * This function creates a signed URL for a given attachment using a JWT token. + * The signed URL includes the attachment name and a token as query parameters. + * + * @param attachment The attachment object to generate a signed URL for. + * @return A signed URL string for downloading the specified attachment. + */ + protected getPublicUrl(attachment: Attachment) { + const token = this.jwtService.sign({ ...attachment }, this.jwtSignOptions); + const [name, _suffix] = this.getName().split('-'); + return buildURL( + config.apiBaseUrl, + `/webhook/${name}/download/${attachment.name}?t=${encodeURIComponent(token)}`, + ); + } + + /** + * Downloads an attachment using a signed token. + * + * This function verifies the provided token and retrieves the corresponding + * attachment as a streamable file. If the verification fails or the attachment + * cannot be located, it throws a NotFoundException. + * + * @param token The signed token used to verify and locate the attachment. + * @param req - The HTTP express request object. + * @return A streamable file of the attachment. + * @throws NotFoundException if the attachment cannot be found or the token is invalid. + */ + public async download(token: string, _req: Request) { + try { + const result = this.jwtService.verify(token, this.jwtSignOptions); + const attachment = plainToClass(Attachment, result); + return await this.attachmentService.download(attachment); + } catch (err) { + this.logger.error('Failed to download attachment', err); + throw new NotFoundException('Unable to locate attachment'); + } + } } diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index 0794fa92..3c171447 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -85,10 +85,11 @@ const attachment: Attachment = { size: 3539, location: '39991e51-55c6-4a26-9176-b6ba04f180dc.jpg', channel: { - ['dimelo']: { - id: 'attachment-id-dimelo', + ['some-channel']: { + id: 'some-attachment-id', }, }, + context: 'message_attachment', createdAt: new Date(), updatedAt: new Date(), }; diff --git a/api/src/channel/webhook.controller.ts b/api/src/channel/webhook.controller.ts index 985e0da0..9df9faed 100644 --- a/api/src/channel/webhook.controller.ts +++ b/api/src/channel/webhook.controller.ts @@ -6,7 +6,7 @@ * 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 { Controller, Get, Param, Post, Req, Res } from '@nestjs/common'; +import { Controller, Get, Param, Post, Query, Req, Res } from '@nestjs/common'; import { Request, Response } from 'express'; // Import the Express request and response types import { LoggerService } from '@/logger/logger.service'; @@ -21,6 +21,28 @@ export class WebhookController { private readonly logger: LoggerService, ) {} + /** + * Handles GET requests to download a file + * + * @param channel - The name of the channel for which the request is being sent. + * @param filename - The name of the requested file + * @param t - The JWT Token query param. + * @param req - The HTTP express request object. + * + * @returns A promise that resolves a streamable file. + */ + @Roles('public') + @Get(':channel/download/:name') + async handleDownload( + @Param('channel') channel: string, + @Param('name') name: string, + @Query('t') token: string, + @Req() req: Request, + ) { + this.logger.log('Channel download request: ', channel, name); + return await this.channelService.download(channel, token, req); + } + /** * Handles GET requests of a specific channel. * This endpoint is accessible to public access (messaging platforms). @@ -40,7 +62,7 @@ export class WebhookController { @Req() req: Request, @Res() res: Response, ): Promise { - this.logger.log('Channel notification : ', req.method, channel); + this.logger.log('Channel notification: ', req.method, channel); return await this.channelService.handle(channel, req, res); } @@ -63,7 +85,7 @@ export class WebhookController { @Req() req: Request, @Res() res: Response, ): Promise { - this.logger.log('Channel notification : ', req.method, channel); + this.logger.log('Channel notification: ', req.method, channel); return await this.channelService.handle(channel, req, res); } } diff --git a/api/src/chat/schemas/types/attachment.ts b/api/src/chat/schemas/types/attachment.ts index 975d78fd..4ca8dbde 100644 --- a/api/src/chat/schemas/types/attachment.ts +++ b/api/src/chat/schemas/types/attachment.ts @@ -17,8 +17,9 @@ export enum FileType { } export type AttachmentForeignKey = { - url?: string; attachment_id: string; + /** @deprecated use attachment_id field instead */ + url?: string; }; export type WithUrl = A & { url?: string }; @@ -33,6 +34,8 @@ export interface AttachmentPayload< export interface IncomingAttachmentPayload { type: FileType; payload: { - url: string; + attachment_id: string; + /** @deprecated use attachment_id field instead */ + url?: string; }; } diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 429e9506..3fca067b 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -112,8 +112,13 @@ export const config: Config = { storageMode: 'disk', maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES ? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES) - : 50 * 1024 * 1024, // 50 MB in bytes + : 20 * 1024 * 1024, // 20 MB in bytes appName: 'Hexabot.ai', + signedUrl: { + salt: parseInt(process.env.SALT_LENGTH || '12'), + secret: process.env.SIGNED_URL_SECRET || 'DEFAULT_SIGNED_URL_SECRET', + expiresIn: process.env.SIGNED_URL_EXPIRES_IN || '24H', + }, }, pagination: { limit: 10, diff --git a/api/src/config/types.ts b/api/src/config/types.ts index 7d63613d..57ac8cf9 100644 --- a/api/src/config/types.ts +++ b/api/src/config/types.ts @@ -81,6 +81,7 @@ export type Config = { storageMode: 'disk' | 'memory'; maxUploadSize: number; appName: string; + signedUrl: TJwtOptions; }; pagination: { limit: number; diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 9bc8274f..1c89a620 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -16,6 +16,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; import { ChannelService } from '@/channel/channel.service'; +import { createEventWrapper } from '@/channel/lib/EventWrapper'; import ChannelHandler from '@/channel/lib/Handler'; import { ChannelName } from '@/channel/types'; import { MessageCreateDto } from '@/chat/dto/message.dto'; @@ -28,6 +29,7 @@ import { AnyMessage, ContentElement, IncomingMessage, + IncomingMessageType, OutgoingMessage, OutgoingMessageFormat, PayloadType, @@ -275,7 +277,7 @@ export default abstract class BaseWebChannelHandler< * @returns Promise to an array of message, rejects into error. * Promise to fetch the 'n' new messages since a giving date for the session profile. */ - private async pollMessages( + protected async pollMessages( req: Request, since: Date = new Date(10e14), n: number = 30, @@ -369,33 +371,17 @@ export default abstract class BaseWebChannelHandler< * @param req * @param res */ - private validateSession( - req: Request | SocketRequest, - res: Response | SocketResponse, - next: (profile: Subscriber) => void, - ) { + protected validateSession(req: Request | SocketRequest): Subscriber { if (!req.session?.web?.profile?.id) { - this.logger.warn( - 'Web Channel Handler : No session ID to be found!', - req.session, - ); - return res - .status(403) - .json({ err: 'Web Channel Handler : Unauthorized!' }); + throw new Error('No session ID to be found!'); } else if ( (this.isSocketRequest(req) && !!req.isSocket !== req.session.web.isSocket) || !Array.isArray(req.session.web.messageQueue) ) { - this.logger.warn( - 'Web Channel Handler : Mixed channel request or invalid session data!', - req.session, - ); - return res - .status(403) - .json({ err: 'Web Channel Handler : Unauthorized!' }); + throw new Error('Mixed channel request or invalid session data!'); } - next(req.session?.web?.profile); + return req.session?.web?.profile; } /** @@ -596,52 +582,32 @@ export default abstract class BaseWebChannelHandler< * @param res Either a HTTP Express response or a WS response (Synthetic Object) * @param next Callback Function */ - async handleFilesUpload( + protected async handleUpload( req: Request | SocketRequest, res: Response | SocketResponse, - next: ( - err: null | Error, - result?: Web.IncomingAttachmentMessageData, - ) => void, - ): Promise { - // Check if any file is provided - if (!req.session.web) { - this.logger.debug('Web Channel Handler : No session provided'); - return next(null); - } - + message: Web.IncomingMessage, + ): Promise { if (this.isSocketRequest(req)) { - try { - const { type, data } = req.body as Web.IncomingMessage; - - // Check if any file is provided - if (type !== 'file' || !('file' in data) || !data.file) { - this.logger.debug('Web Channel Handler : No files provided'); - return next(null); - } + const { type, data } = message; + // Check if any file is provided + if (type !== 'file' || !('file' in data) || !data.file) { + throw new TypeError('Web Channel Handler : No files provided'); + } - const size = Buffer.byteLength(data.file); + const size = Buffer.byteLength(data.file); - if (size > config.parameters.maxUploadSize) { - return next(new Error('Max upload size has been exceeded')); - } - - const attachment = await this.attachmentService.store(data.file, { - name: data.name, - size: Buffer.byteLength(data.file), - type: data.type, - }); - next(null, { - type: Attachment.getTypeByMime(attachment.type), - url: Attachment.getAttachmentUrl(attachment.id, attachment.name), - }); - } catch (err) { - this.logger.error( - 'Web Channel Handler : Unable to write uploaded file', - err, - ); - return next(new Error('Unable to upload file!')); + if (size > config.parameters.maxUploadSize) { + throw new TypeError('Web Channel Handler : No files provided'); } + + 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, + }); } else { const upload = multer({ limits: { @@ -656,35 +622,34 @@ export default abstract class BaseWebChannelHandler< })(), }).single('file'); // 'file' is the field name in the form - upload(req as Request, res as Response, async (err?: any) => { - if (err) { - this.logger.error( - 'Web Channel Handler : Unable to write uploaded file', - err, - ); - return next(new Error('Unable to upload file!')); - } + // Wrap the upload function in a promise to use async/await + await new Promise((resolve, reject) => { + upload(req, res as Response, (err?: any) => { + if (err) { + this.logger.error( + 'Web Channel Handler : Unable to write uploaded file', + err, + ); + return reject(new Error('Unable to upload file!')); + } + resolve(); + }); + }); - // Check if any file is provided - if (!req.file) { - this.logger.debug('Web Channel Handler : No files provided'); - return next(null); - } + // Check if any file is provided + if (!req.file) { + this.logger.debug('Web Channel Handler : No files provided'); + throw new Error('No files provided'); + } - try { - const file = req.file; - const attachment = await this.attachmentService.store(file, { - name: file.originalname, - size: file.size, - type: file.mimetype, - }); - next(null, { - type: Attachment.getTypeByMime(attachment.type), - url: Attachment.getAttachmentUrl(attachment.id, attachment.name), - }); - } catch (err) { - next(err); - } + const file = req.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, }); } } @@ -732,81 +697,87 @@ export default abstract class BaseWebChannelHandler< * @param req Either a HTTP Express request or a WS request (Synthetic Object) * @param res Either a HTTP Express response or a WS response (Synthetic Object) */ - _handleEvent( + protected async handleEvent( req: Request | SocketRequest, res: Response | SocketResponse, - ): void { + ): Promise { // @TODO: perform payload validation if (!req.body) { this.logger.debug('Web Channel Handler : Empty body'); res.status(400).json({ err: 'Web Channel Handler : Bad Request!' }); return; - } else { - // Parse json form data (in case of content-type multipart/form-data) - req.body.data = - typeof req.body.data === 'string' - ? JSON.parse(req.body.data) - : req.body.data; } - this.validateSession(req, res, (profile) => { - this.handleFilesUpload( - req, - res, - (err: Error, data?: Web.IncomingAttachmentMessageData) => { - if (err) { + // Parse json form data (in case of content-type multipart/form-data) + req.body.data = + typeof req.body.data === 'string' + ? JSON.parse(req.body.data) + : req.body.data; + + const body = req.body as Web.IncomingMessage; + + const profile = this.validateSession(req); + const channelAttrs = this.getChannelAttributes(req); + const event = await createEventWrapper( + WebEventWrapper, + this, + body, + channelAttrs, + ); + if (event._adapter.eventType === 'message') { + // Handler sync message sent by chabbot + if (body.sync && body.author === 'chatbot') { + const sentMessage: MessageCreateDto = { + mid: event.getId(), + message: event.getMessage() as StdOutgoingMessage, + recipient: profile.id, + read: true, + delivery: true, + }; + this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event); + res.status(200).json(event._adapter.raw); + return; + } else { + // Generate unique ID and handle message + event._adapter.raw.mid = this.generateId(); + + if (event._adapter.messageType === IncomingMessageType.attachments) { + try { + // Handle updoad + const attachment = await this.handleUpload( + req, + res, + event._adapter.raw, + ); + event._adapter.attachment = attachment; + } catch (err) { this.logger.warn( 'Web Channel Handler : Unable to upload file ', err, ); - return res + res .status(403) .json({ err: 'Web Channel Handler : File upload failed!' }); + return; } - // Set data in file upload case - const body: Web.IncomingMessage = data - ? { - ...req.body, - data, - } - : req.body; - - const channelAttrs = this.getChannelAttributes(req); - const event = new WebEventWrapper(this, body, channelAttrs); - if (event.getEventType() === 'message') { - // Handler sync message sent by chabbot - if (body.sync && body.author === 'chatbot') { - const sentMessage: MessageCreateDto = { - mid: event.getId(), - message: event.getMessage() as StdOutgoingMessage, - recipient: profile.id, - read: true, - delivery: true, - }; - this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event); - return res.status(200).json(event._adapter.raw); - } else { - // Generate unique ID and handle message - event.set('mid', this.generateId()); - } - } - // Force author id from session - event.set('author', profile.foreign_id); - event.setSender(profile); + } + } - const type = event.getEventType(); - if (type) { - this.eventEmitter.emit(`hook:chatbot:${type}`, event); - } else { - this.logger.error( - 'Web Channel Handler : Webhook received unknown event ', - event, - ); - } - res.status(200).json(event._adapter.raw); - }, + // Force author id from session + event._adapter.raw.author = profile.foreign_id; + event.setSender(profile); + } + + const type = event.getEventType(); + if (type) { + this.eventEmitter.emit(`hook:chatbot:${type}`, event); + } else { + this.logger.error( + 'Web Channel Handler : Webhook received unknown event ', + event, ); - }); + } + res.status(200).json(event._adapter.raw); } /** @@ -871,7 +842,7 @@ export default abstract class BaseWebChannelHandler< } } else { // Handle incoming messages (through POST) - return this._handleEvent(req, res); + return this.handleEvent(req, res); } } catch (err) { this.logger.warn('Web Channel Handler : Request check failed', err); diff --git a/api/src/extensions/channels/web/types.ts b/api/src/extensions/channels/web/types.ts index a650c1c0..b525d628 100644 --- a/api/src/extensions/channels/web/types.ts +++ b/api/src/extensions/channels/web/types.ts @@ -64,11 +64,17 @@ export namespace Web { // After upload and attachment is processed | { type: FileType; - url: string; // file download url - } // Before upload and attachment is processed + // Attachment id + id: string; + /** @deprecated use id instead */ + url?: string; + } + // Before upload and attachment is processed | { - type: string; // mime type - size: number; // file size + // MIME Type + type: string; + // File size + size: number; name: string; file: Buffer; }; diff --git a/api/src/extensions/channels/web/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts index 6ae44fa7..df9c17fd 100644 --- a/api/src/extensions/channels/web/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -6,6 +6,7 @@ * 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 { Attachment } from '@/attachment/schemas/attachment.schema'; import EventWrapper from '@/channel/lib/EventWrapper'; import { ChannelName } from '@/channel/types'; import { @@ -66,14 +67,13 @@ type WebEventAdapter = eventType: StdEventType.message; messageType: IncomingMessageType.attachments; raw: Web.IncomingMessage; + attachment: Attachment; }; // eslint-disable-next-line prettier/prettier -export default class WebEventWrapper extends EventWrapper< - WebEventAdapter, - Web.Event, - N -> { +export default class WebEventWrapper< + N extends ChannelName, +> extends EventWrapper { /** * Constructor : channel's event wrapper * @@ -97,7 +97,7 @@ export default class WebEventWrapper extends EventWrapper * * @param event - The message event received */ - _init(event: Web.Event) { + async _init(event: Web.Event) { switch (event.type) { case Web.StatusEventType.delivery: this._adapter.eventType = StdEventType.delivery; @@ -133,7 +133,6 @@ export default class WebEventWrapper extends EventWrapper this._adapter.eventType = StdEventType.unknown; break; } - this._adapter.raw = event; } /** @@ -216,16 +215,16 @@ export default class WebEventWrapper extends EventWrapper }; } case IncomingMessageType.attachments: - if (!('url' in this._adapter.raw.data)) { + if (!this._adapter.attachment) { throw new Error('Attachment has not been processed'); } return { type: PayloadType.attachments, attachments: { - type: this._adapter.raw.data.type, + type: Attachment.getTypeByMime(this._adapter.attachment.type), payload: { - url: this._adapter.raw.data.url, + attachment_id: this._adapter.attachment.id, }, }, }; @@ -266,19 +265,19 @@ export default class WebEventWrapper extends EventWrapper } case IncomingMessageType.attachments: { - const attachment = this._adapter.raw.data; - - if (!('url' in attachment)) { + const attachment = this._adapter.attachment; + if (!attachment) { throw new Error('Attachment has not been processed'); } + const type = Attachment.getTypeByMime(attachment.type); return { type: PayloadType.attachments, - serialized_text: `attachment:${attachment.type}:${attachment.url}`, + serialized_text: `attachment:${type}:${attachment.name}`, attachment: { - type: attachment.type, + type, payload: { - url: attachment.url, + attachment_id: attachment.id, }, }, }; diff --git a/api/src/user/user.module.ts b/api/src/user/user.module.ts index 2b123ace..045d12b3 100644 --- a/api/src/user/user.module.ts +++ b/api/src/user/user.module.ts @@ -6,7 +6,7 @@ * 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, @@ -90,6 +90,6 @@ import { ValidateAccountService } from './services/validate-account.service'; PermissionController, ModelController, ], - exports: [UserService, PermissionService], + exports: [UserService, PermissionService, ModelService], }) export class UserModule {} diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index 3319401d..c1c41408 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -22,8 +22,8 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '1', }, }, + context: 'block_attachment', }, - { name: 'store2.jpg', type: 'image/jpeg', @@ -34,6 +34,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '2', }, }, + context: 'block_attachment', }, ]; diff --git a/docker/.env.example b/docker/.env.example index aeb135a1..2213d054 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -23,6 +23,8 @@ PASSWORD_RESET_JWT_SECRET=dev_only PASSWORD_RESET_EXPIRES_IN=1h CONFIRM_ACCOUNT_SECRET=dev_only CONFIRM_ACCOUNT_EXPIRES_IN=1h +SIGNED_URL_SECRET=dev_only +SIGNED_URL_EXPIRES_IN=1h FRONTEND_DOCKER_IMAGE=linuxtry I18N_TRANSLATION_FILENAME=messages diff --git a/frontend/src/app-components/attachment/AttachmentInput.tsx b/frontend/src/app-components/attachment/AttachmentInput.tsx index 28c7a5ec..b2249067 100644 --- a/frontend/src/app-components/attachment/AttachmentInput.tsx +++ b/frontend/src/app-components/attachment/AttachmentInput.tsx @@ -12,7 +12,7 @@ import { forwardRef } from "react"; import { useGet } from "@/hooks/crud/useGet"; import { useHasPermission } from "@/hooks/useHasPermission"; import { EntityType } from "@/services/types"; -import { IAttachment } from "@/types/attachment.types"; +import { IAttachment, TAttachmentContext } from "@/types/attachment.types"; import { PermissionAction } from "@/types/permission.types"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -28,6 +28,7 @@ type AttachmentThumbnailProps = { onChange?: (id: string | null, mimeType: string | null) => void; error?: boolean; helperText?: string; + context: TAttachmentContext; }; const AttachmentInput = forwardRef( @@ -42,6 +43,7 @@ const AttachmentInput = forwardRef( onChange, error, helperText, + context, }, ref, ) => { @@ -81,6 +83,7 @@ const AttachmentInput = forwardRef( accept={accept} enableMediaLibrary={enableMediaLibrary} onChange={handleChange} + context={context} /> ) : null} {helperText ? ( diff --git a/frontend/src/app-components/attachment/AttachmentUploader.tsx b/frontend/src/app-components/attachment/AttachmentUploader.tsx index 73cdf176..c8fefbb4 100644 --- a/frontend/src/app-components/attachment/AttachmentUploader.tsx +++ b/frontend/src/app-components/attachment/AttachmentUploader.tsx @@ -16,7 +16,7 @@ import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType } from "@/services/types"; -import { IAttachment } from "@/types/attachment.types"; +import { IAttachment, TAttachmentContext } from "@/types/attachment.types"; import { AttachmentDialog } from "./AttachmentDialog"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -67,6 +67,7 @@ export type FileUploadProps = { enableMediaLibrary?: boolean; onChange?: (data?: IAttachment | null) => void; onUploadComplete?: () => void; + context: TAttachmentContext; }; const AttachmentUploader: FC = ({ @@ -74,6 +75,7 @@ const AttachmentUploader: FC = ({ enableMediaLibrary, onChange, onUploadComplete, + context, }) => { const [attachment, setAttachment] = useState( undefined, @@ -103,10 +105,16 @@ const AttachmentUploader: FC = ({ if (file) { const acceptedTypes = accept.split(","); - const isValidType = acceptedTypes.some( - (type) => - file.type === type || file.name.endsWith(type.replace(".*", "")), - ); + const isValidType = acceptedTypes.some((mimeType) => { + const [type, subtype] = mimeType.split("/"); + + if (!type || !subtype) return false; // Ensure valid MIME type + + return ( + file.type === mimeType || + (subtype === "*" && file.type.startsWith(`${type}/`)) + ); + }); if (!isValidType) { toast.error(t("message.invalid_file_type")); @@ -114,7 +122,7 @@ const AttachmentUploader: FC = ({ return; } - uploadAttachment(file); + uploadAttachment({ file, context }); } } }; @@ -123,7 +131,7 @@ const AttachmentUploader: FC = ({ const file = event.dataTransfer.files.item(0); if (file) { - uploadAttachment(file); + uploadAttachment({ file, context }); } } }; diff --git a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx index 6ba968cc..64264562 100644 --- a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx +++ b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx @@ -11,7 +11,7 @@ import { forwardRef, useState } from "react"; import { useHasPermission } from "@/hooks/useHasPermission"; import { EntityType } from "@/services/types"; -import { IAttachment } from "@/types/attachment.types"; +import { IAttachment, TAttachmentContext } from "@/types/attachment.types"; import { PermissionAction } from "@/types/permission.types"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -27,6 +27,7 @@ type MultipleAttachmentInputProps = { onChange?: (ids: string[]) => void; error?: boolean; helperText?: string; + context: TAttachmentContext; }; const MultipleAttachmentInput = forwardRef< @@ -44,6 +45,7 @@ const MultipleAttachmentInput = forwardRef< onChange, error, helperText, + context, }, ref, ) => { @@ -106,6 +108,7 @@ const MultipleAttachmentInput = forwardRef< accept={accept} enableMediaLibrary={enableMediaLibrary} onChange={(attachment) => handleChange(attachment)} + context={context} /> )} {helperText && ( diff --git a/frontend/src/components/contents/ContentDialog.tsx b/frontend/src/components/contents/ContentDialog.tsx index e8a48773..150d5f54 100644 --- a/frontend/src/components/contents/ContentDialog.tsx +++ b/frontend/src/components/contents/ContentDialog.tsx @@ -115,6 +115,7 @@ const ContentFieldInput: React.FC = ({ value={field.value?.payload?.attachment_id} accept={MIME_TYPES["images"].join(",")} format="full" + context="content_attachment" /> ); default: diff --git a/frontend/src/components/contents/ContentImportDialog.tsx b/frontend/src/components/contents/ContentImportDialog.tsx index 4ed2a4f8..672a017d 100644 --- a/frontend/src/components/contents/ContentImportDialog.tsx +++ b/frontend/src/components/contents/ContentImportDialog.tsx @@ -80,6 +80,7 @@ export const ContentImportDialog: FC = ({ }} label="" value={attachmentId} + context="content_attachment" /> diff --git a/frontend/src/components/profile/profile.tsx b/frontend/src/components/profile/profile.tsx index 16d17c84..085b011c 100644 --- a/frontend/src/components/profile/profile.tsx +++ b/frontend/src/components/profile/profile.tsx @@ -114,6 +114,7 @@ export const ProfileForm: FC = ({ user }) => { size={256} {...field} onChange={(attachment) => setValue("avatar", attachment)} + context="user_avatar" /> {getValues("avatar") ? (