From 994c8857e929ac9c8c677f91d7e2bbcdf7c30f89 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 8 Jan 2025 16:42:46 +0100 Subject: [PATCH 01/23] feat(api): add attachment extra attributes --- api/src/attachment/attachment.module.ts | 2 + .../controllers/attachment.controller.spec.ts | 70 ++++- .../controllers/attachment.controller.ts | 55 +++- api/src/attachment/dto/attachment.dto.ts | 61 +++- .../guards/attachment-ability.guard.spec.ts | 282 ++++++++++++++++++ .../guards/attachment-ability.guard.ts | 265 ++++++++++++++++ api/src/attachment/mocks/attachment.mock.ts | 17 +- .../attachment/schemas/attachment.schema.ts | 79 ++++- .../attachment/services/attachment.service.ts | 37 --- api/src/attachment/types/index.ts | 33 ++ api/src/attachment/utilities/index.ts | 28 ++ api/src/channel/lib/__test__/common.mock.ts | 3 + .../channels/web/base-web-channel.ts | 31 +- api/src/user/controllers/user.controller.ts | 3 + api/src/user/user.module.ts | 6 +- api/src/utils/test/fixtures/attachment.ts | 9 +- 16 files changed, 872 insertions(+), 109 deletions(-) create mode 100644 api/src/attachment/guards/attachment-ability.guard.spec.ts create mode 100644 api/src/attachment/guards/attachment-ability.guard.ts create mode 100644 api/src/attachment/types/index.ts diff --git a/api/src/attachment/attachment.module.ts b/api/src/attachment/attachment.module.ts index 0c66e2f4..c3fadbb6 100644 --- a/api/src/attachment/attachment.module.ts +++ b/api/src/attachment/attachment.module.ts @@ -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], diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index 4d0c39d6..920c14c7 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -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'], ); }); }); diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index 3607903f..c93dd014 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -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 { constructor( private readonly attachmentService: AttachmentService, @@ -61,7 +67,7 @@ export class AttachmentController extends BaseController { async filterCount( @Query( new SearchFilterPipe({ - allowedFields: ['name', 'type'], + allowedFields: ['name', 'type', 'context'], }), ) filters?: TFilterQuery, @@ -90,7 +96,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 +122,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( @UploadedFiles() files: { file: Express.Multer.File[] }, + @Req() req: Request, + @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 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; } /** diff --git a/api/src/attachment/dto/attachment.dto.ts b/api/src/attachment/dto/attachment.dto.ts index ce180dc0..f698b135 100644 --- a/api/src/attachment/dto/attachment.dto.ts +++ b/api/src/attachment/dto/attachment.dto.ts @@ -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>; + + /** + * 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; +} 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..782cb040 --- /dev/null +++ b/api/src/attachment/guards/attachment-ability.guard.spec.ts @@ -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); + 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..16588b94 --- /dev/null +++ b/api/src/attachment/guards/attachment-ability.guard.ts @@ -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 + > = { + // 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 { + 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..d0dbfef7 100644 --- a/api/src/attachment/mocks/attachment.mock.ts +++ b/api/src/attachment/mocks/attachment.mock.ts @@ -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', }, ]; diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index fed8dd90..f9355c4c 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -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; -} - @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>; + 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. @@ -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; 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']; diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 3cdae64a..785337a5 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -155,43 +155,6 @@ 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 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. diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts new file mode 100644 index 00000000..e7cdc6ee --- /dev/null +++ b/api/src/attachment/types/index.ts @@ -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}`; diff --git a/api/src/attachment/utilities/index.ts b/api/src/attachment/utilities/index.ts index ed3905cc..99c1c580 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; /** @@ -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) + ); +}; diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index ebc85516..dcb27049 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -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(), }; diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 99d2992f..08e8fb32 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -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', diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 5f8ff25d..b9bc75a3 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -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, ) diff --git a/api/src/user/user.module.ts b/api/src/user/user.module.ts index 2b123ace..eba2f176 100644 --- a/api/src/user/user.module.ts +++ b/api/src/user/user.module.ts @@ -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, diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index 3319401d..e60880bc 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -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, }, ]; From 7be61150ac1a6947e3260d0caf603e5fdcca71d8 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 8 Jan 2025 18:11:28 +0100 Subject: [PATCH 02/23] feat(frontend): apply frontend updates (context) --- .../attachment/AttachmentInput.tsx | 8 ++- .../attachment/AttachmentUploader.tsx | 49 ++++++++++------- .../attachment/MultipleAttachmentInput.tsx | 8 ++- .../src/components/contents/ContentDialog.tsx | 1 + .../contents/ContentImportDialog.tsx | 4 +- .../src/components/settings/SettingInput.tsx | 5 +- .../form/AttachmentMessageForm.tsx | 1 + frontend/src/hooks/crud/useUpload.tsx | 12 +++-- frontend/src/services/api.class.ts | 17 ++++-- frontend/src/types/attachment.types.ts | 52 +++++++++++++++++-- frontend/src/types/base.types.ts | 5 +- 11 files changed, 121 insertions(+), 41 deletions(-) diff --git a/frontend/src/app-components/attachment/AttachmentInput.tsx b/frontend/src/app-components/attachment/AttachmentInput.tsx index 28c7a5ec..67edd5da 100644 --- a/frontend/src/app-components/attachment/AttachmentInput.tsx +++ b/frontend/src/app-components/attachment/AttachmentInput.tsx @@ -1,18 +1,19 @@ /* - * 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 { Box, FormHelperText, FormLabel } from "@mui/material"; 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 +29,7 @@ type AttachmentThumbnailProps = { onChange?: (id: string | null, mimeType: string | null) => void; error?: boolean; helperText?: string; + context: TAttachmentContext; }; const AttachmentInput = forwardRef( @@ -42,6 +44,7 @@ const AttachmentInput = forwardRef( onChange, error, helperText, + context, }, ref, ) => { @@ -81,6 +84,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..a2d662c1 100644 --- a/frontend/src/app-components/attachment/AttachmentUploader.tsx +++ b/frontend/src/app-components/attachment/AttachmentUploader.tsx @@ -1,11 +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 CloudUploadIcon from "@mui/icons-material/CloudUpload"; import FolderCopyIcon from "@mui/icons-material/FolderCopy"; import { Box, Button, Divider, Grid, styled, Typography } from "@mui/material"; @@ -16,7 +17,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 +68,7 @@ export type FileUploadProps = { enableMediaLibrary?: boolean; onChange?: (data?: IAttachment | null) => void; onUploadComplete?: () => void; + context: TAttachmentContext; }; const AttachmentUploader: FC = ({ @@ -74,6 +76,7 @@ const AttachmentUploader: FC = ({ enableMediaLibrary, onChange, onUploadComplete, + context, }) => { const [attachment, setAttachment] = useState( undefined, @@ -97,34 +100,40 @@ const AttachmentUploader: FC = ({ e.stopPropagation(); e.preventDefault(); }; + const handleUpload = (file: File | null) => { + if (file) { + const acceptedTypes = accept.split(","); + 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")); + + return; + } + uploadAttachment({ file, context }); + } + }; const handleChange = (event: ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { const file = event.target.files.item(0); - if (file) { - const acceptedTypes = accept.split(","); - const isValidType = acceptedTypes.some( - (type) => - file.type === type || file.name.endsWith(type.replace(".*", "")), - ); - - if (!isValidType) { - toast.error(t("message.invalid_file_type")); - - return; - } - - uploadAttachment(file); - } + handleUpload(file); } }; const onDrop = (event: DragEvent) => { if (event.dataTransfer.files && event.dataTransfer.files.length > 0) { const file = event.dataTransfer.files.item(0); - if (file) { - uploadAttachment(file); - } + handleUpload(file); } }; diff --git a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx index 6ba968cc..80b76283 100644 --- a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx +++ b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx @@ -1,17 +1,18 @@ /* - * 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 { Box, Button, FormHelperText, FormLabel } from "@mui/material"; 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 +28,7 @@ type MultipleAttachmentInputProps = { onChange?: (ids: string[]) => void; error?: boolean; helperText?: string; + context: TAttachmentContext; }; const MultipleAttachmentInput = forwardRef< @@ -44,6 +46,7 @@ const MultipleAttachmentInput = forwardRef< onChange, error, helperText, + context, }, ref, ) => { @@ -106,6 +109,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 c04b3a75..885dfe73 100644 --- a/frontend/src/components/contents/ContentDialog.tsx +++ b/frontend/src/components/contents/ContentDialog.tsx @@ -116,6 +116,7 @@ const ContentFieldInput: React.FC = ({ value={field.value?.payload?.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..61855149 100644 --- a/frontend/src/components/contents/ContentImportDialog.tsx +++ b/frontend/src/components/contents/ContentImportDialog.tsx @@ -1,11 +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 CloseIcon from "@mui/icons-material/Close"; import { Button, Dialog, DialogActions, DialogContent } from "@mui/material"; import { FC, useState } from "react"; @@ -80,6 +81,7 @@ export const ContentImportDialog: FC = ({ }} label="" value={attachmentId} + context="content_attachment" /> diff --git a/frontend/src/components/settings/SettingInput.tsx b/frontend/src/components/settings/SettingInput.tsx index f09ef9be..fd41381c 100644 --- a/frontend/src/components/settings/SettingInput.tsx +++ b/frontend/src/components/settings/SettingInput.tsx @@ -1,11 +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 KeyIcon from "@mui/icons-material/Key"; import { FormControlLabel, MenuItem, Switch } from "@mui/material"; import { ControllerRenderProps } from "react-hook-form"; @@ -185,6 +186,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} + context="setting_attachment" /> ); @@ -197,6 +199,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} + context="setting_attachment" /> ); default: diff --git a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx index 35da99fb..0579d9dd 100644 --- a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx @@ -69,6 +69,7 @@ const AttachmentMessageForm = () => { }, }); }} + context="block_attachment" /> ); }} diff --git a/frontend/src/hooks/crud/useUpload.tsx b/frontend/src/hooks/crud/useUpload.tsx index 51202889..342645a9 100644 --- a/frontend/src/hooks/crud/useUpload.tsx +++ b/frontend/src/hooks/crud/useUpload.tsx @@ -9,6 +9,7 @@ import { useMutation, useQueryClient } from "react-query"; import { QueryType, TMutationOptions } from "@/services/types"; +import { TAttachmentContext } from "@/types/attachment.types"; import { IBaseSchema, IDynamicProps, TType } from "@/types/base.types"; import { useEntityApiClient } from "../useApiClient"; @@ -23,7 +24,12 @@ export const useUpload = < >( entity: TEntity, options?: Omit< - TMutationOptions, + TMutationOptions< + TBasic, + Error, + { file: File; context: TAttachmentContext }, + TBasic + >, "mutationFn" | "mutationKey" >, ) => { @@ -33,8 +39,8 @@ export const useUpload = < const { invalidate = true, ...otherOptions } = options || {}; return useMutation({ - mutationFn: async (variables: File) => { - const data = await api.upload(variables); + mutationFn: async ({ file, context }) => { + const data = await api.upload(file, context); const { entities, result } = normalizeAndCache(data); // Invalidate all counts & collections diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index 091c691d..dc062504 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -9,6 +9,7 @@ import { AxiosInstance, AxiosResponse } from "axios"; +import { TAttachmentContext } from "@/types/attachment.types"; import { ILoginAttributes } from "@/types/auth/login.types"; import { IUserPermissions } from "@/types/auth/permission.types"; import { StatsType } from "@/types/bot-stat.types"; @@ -301,7 +302,7 @@ export class EntityApiClient extends ApiClient { return data; } - async upload(file: File) { + async upload(file: File, context?: TAttachmentContext) { const { _csrf } = await this.getCsrf(); const formData = new FormData(); @@ -311,11 +312,17 @@ export class EntityApiClient extends ApiClient { TBasic[], AxiosResponse, FormData - >(`${ROUTES[this.type]}/upload?_csrf=${_csrf}`, formData, { - headers: { - "Content-Type": "multipart/form-data", + >( + `${ROUTES[this.type]}/upload?_csrf=${_csrf}${ + context ? `&context=${context}` : "" + }`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, }, - }); + ); return data[0]; } diff --git a/frontend/src/types/attachment.types.ts b/frontend/src/types/attachment.types.ts index 24570254..adfdc8aa 100644 --- a/frontend/src/types/attachment.types.ts +++ b/frontend/src/types/attachment.types.ts @@ -1,14 +1,43 @@ /* - * 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 { Format } from "@/services/types"; -import { IBaseSchema, IFormat } from "./base.types"; +import { EntityType, Format } from "@/services/types"; + +import { IBaseSchema, IFormat, OmitPopulate } from "./base.types"; +import { ISubscriber } from "./subscriber.types"; +import { IUser } from "./user.types"; + +/** + * 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}`; export interface IAttachmentAttributes { name: string; @@ -17,8 +46,21 @@ export interface IAttachmentAttributes { location: string; url: string; channel?: Record; + context: TAttachmentContext; + ownerType: TAttachmentOwnerType; + owner: string | null; } -export interface IAttachmentStub extends IBaseSchema, IAttachmentAttributes {} +export interface IAttachmentStub + extends IBaseSchema, + OmitPopulate {} -export interface IAttachment extends IAttachmentStub, IFormat {} +export interface IAttachment extends IAttachmentStub, IFormat { + owner: string | null; +} + +export interface ISubscriberAttachmentFull + extends IAttachmentStub, + IFormat { + owner: (ISubscriber | IUser)[]; +} diff --git a/frontend/src/types/base.types.ts b/frontend/src/types/base.types.ts index d20d123a..77dd4b11 100644 --- a/frontend/src/types/base.types.ts +++ b/frontend/src/types/base.types.ts @@ -1,11 +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 { GridPaginationModel, GridSortModel } from "@mui/x-data-grid"; import { EntityType, Format } from "@/services/types"; @@ -109,7 +110,7 @@ export const POPULATE_BY_TYPE = { [EntityType.MENUTREE]: [], [EntityType.LANGUAGE]: [], [EntityType.TRANSLATION]: [], - [EntityType.ATTACHMENT]: [], + [EntityType.ATTACHMENT]: ["owner"], [EntityType.CUSTOM_BLOCK]: [], [EntityType.CUSTOM_BLOCK_SETTINGS]: [], [EntityType.CHANNEL]: [], From 67831034ad19f033183ffddaef38888691a14a7e Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 8 Jan 2025 18:22:07 +0100 Subject: [PATCH 03/23] fix(api): unit test --- .../controllers/attachment.controller.spec.ts | 9 ++++++- .../attachment/services/attachment.service.ts | 25 +++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index 920c14c7..0d84d349 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -6,6 +6,8 @@ * 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 fs from 'fs'; + import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException } from '@nestjs/common/exceptions'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; @@ -86,7 +88,10 @@ describe('AttachmentController', () => { afterAll(closeInMongodConnection); - afterEach(jest.clearAllMocks); + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); describe('count', () => { it('should count attachments', async () => { @@ -113,6 +118,8 @@ describe('AttachmentController', () => { }); it('should upload attachment', async () => { + jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); + jest.spyOn(attachmentService, 'create'); const result = await attachmentController.uploadFile( { diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 785337a5..242d19c4 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -6,8 +6,9 @@ * 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 fs, { createReadStream, promises as fsPromises } from 'fs'; -import { join, resolve } from 'path'; +import fs from 'fs'; +import os from 'os'; +import { join, normalize, resolve } from 'path'; import { Readable, Stream } from 'stream'; import { @@ -95,7 +96,7 @@ export class AttachmentService extends BaseService { join(config.parameters.avatarDir, `${foreign_id}.jpeg`), ); if (fs.existsSync(path)) { - const picturetream = createReadStream(path); + const picturetream = fs.createReadStream(path); return new StreamableFile(picturetream); } else { throw new NotFoundException('Profile picture not found'); @@ -181,7 +182,7 @@ export class AttachmentService extends BaseService { const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename))); if (Buffer.isBuffer(file)) { - await fsPromises.writeFile(filePath, file); + await fs.promises.writeFile(filePath, file); } else if (file instanceof Readable || file instanceof Stream) { await new Promise((resolve, reject) => { const writeStream = fs.createWriteStream(filePath); @@ -193,11 +194,19 @@ export class AttachmentService extends BaseService { } else { if (file.path) { // For example, if the file is an instance of `Express.Multer.File` (diskStorage case) - const srcFilePath = resolve(file.path); - await fsPromises.copyFile(srcFilePath, filePath); - await fsPromises.unlink(srcFilePath); + const srcFilePath = fs.realpathSync(resolve(file.path)); + // Get the system's temporary directory in a cross-platform way + const tempDir = os.tmpdir(); + const normalizedTempDir = normalize(tempDir); + + if (!srcFilePath.startsWith(normalizedTempDir)) { + throw new Error('Invalid file path'); + } + + await fs.promises.copyFile(srcFilePath, filePath); + await fs.promises.unlink(srcFilePath); } else { - await fsPromises.writeFile(filePath, file.buffer); + await fs.promises.writeFile(filePath, file.buffer); } } From 9e96055a55c41f6c78b84993aad98049a0308d34 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 8 Jan 2025 19:07:12 +0100 Subject: [PATCH 04/23] feat: update migration to populate subscriber avatar attachments --- .../1735836154221-v-2-2-0.migration.ts | 69 +++++++++++++++++-- api/src/user/user.module.ts | 2 +- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 9dd667db..bb874560 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -15,17 +15,34 @@ import { v4 as uuidv4 } from 'uuid'; import attachmentSchema, { Attachment, } from '@/attachment/schemas/attachment.schema'; +import { AttachmentContext, AttachmentOwnerType } from '@/attachment/types'; import blockSchema, { Block } from '@/chat/schemas/block.schema'; import messageSchema, { Message } from '@/chat/schemas/message.schema'; import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; import { StdOutgoingAttachmentMessage } from '@/chat/schemas/types/message'; import contentSchema, { Content } from '@/cms/schemas/content.schema'; import { config } from '@/config'; +import roleSchema, { Role } from '@/user/schemas/role.schema'; import userSchema, { User } from '@/user/schemas/user.schema'; import { moveFile, moveFiles } from '@/utils/helpers/fs'; import { MigrationAction, MigrationServices } from '../types'; +/** + * @returns The admin user or null + */ +const getAdminUser = async () => { + const RoleModel = mongoose.model(Role.name, roleSchema); + const UserModel = mongoose.model(User.name, userSchema); + + const adminRole = await RoleModel.findOne({ name: 'admin' }); + const user = await UserModel.findOne({ roles: [adminRole._id] }).sort({ + createdAt: 'asc', + }); + + return user; +}; + /** * Updates subscriber documents with their corresponding avatar attachments * and moves avatar files to a new directory. @@ -58,10 +75,29 @@ const populateSubscriberAvatar = async ({ logger }: MigrationServices) => { if (attachment) { await SubscriberModel.updateOne( { _id: subscriber._id }, - { $set: { avatar: attachment._id } }, + { + $set: { + avatar: attachment._id, + }, + }, ); logger.log( - `Subscriber ${subscriber._id} avatar attachment successfully updated for `, + `Subscriber ${subscriber._id} avatar attribute successfully updated`, + ); + + await AttachmentModel.updateOne( + { _id: attachment._id }, + { + $set: { + context: AttachmentContext.SubscriberAvatar, + ownerType: AttachmentOwnerType.Subscriber, + owner: subscriber._id, + }, + }, + ); + + logger.log( + `Subscriber ${subscriber._id} avatar attachment attributes successfully populated`, ); const src = resolve( @@ -144,10 +180,30 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { // Reset avatar to null await SubscriberModel.updateOne( { _id: subscriber._id }, - { $set: { avatar: null } }, + { + $set: { avatar: null }, + $unset: { + context: 1, + ownerType: 1, + owner: 1, + }, + }, ); logger.log( - `Avatar attachment successfully updated for subscriber ${subscriber._id}`, + `Subscriber ${subscriber._id} avatar attribute successfully reverted to null`, + ); + await AttachmentModel.updateOne( + { _id: attachment._id }, + { + $unset: { + context: 1, + ownerType: 1, + owner: 1, + }, + }, + ); + logger.log( + `Subscriber ${subscriber._id} avatar attachment attributes successfully unpopulated`, ); } else { logger.warn( @@ -407,6 +463,8 @@ const migrateAttachmentMessages = async ({ ); }; + const adminUser = await getAdminUser(); + for await (const msg of cursor) { try { if ( @@ -441,6 +499,9 @@ const migrateAttachmentMessages = async ({ size: fileBuffer.length, type: response.headers['content-type'], channel: {}, + owner: msg.sender ? msg.sender : adminUser.id, + ownerType: msg.sender ? 'Subscriber' : 'User', + context: 'message_attachment', }); if (attachment) { diff --git a/api/src/user/user.module.ts b/api/src/user/user.module.ts index eba2f176..a44c414f 100644 --- a/api/src/user/user.module.ts +++ b/api/src/user/user.module.ts @@ -90,6 +90,6 @@ import { ValidateAccountService } from './services/validate-account.service'; PermissionController, ModelController, ], - exports: [UserService, PermissionService], + exports: [UserService, PermissionService, ModelService], }) export class UserModule {} From 65fad2f9e683dd37d8ab080297cdb1b0d657192a Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 8 Jan 2025 19:57:42 +0100 Subject: [PATCH 05/23] feat: update migration to populate user avatar attachments --- .../1735836154221-v-2-2-0.migration.ts | 81 ++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index bb874560..99f8ad57 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -44,8 +44,83 @@ const getAdminUser = async () => { }; /** - * Updates subscriber documents with their corresponding avatar attachments - * and moves avatar files to a new directory. + * Updates user attachment documents to populate new attributes (context, owner, ownerType) + * + * @returns Resolves when the migration process is complete. + */ +const populateUserAvatar = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + const UserModel = mongoose.model(User.name, userSchema); + + const cursor = UserModel.find({ + avatar: { $exists: true, $ne: null }, + }).cursor(); + + for await (const user of cursor) { + try { + await AttachmentModel.updateOne( + { _id: user.avatar }, + { + $set: { + context: AttachmentContext.UserAvatar, + ownerType: AttachmentOwnerType.User, + owner: user._id, + }, + }, + ); + logger.log(`User ${user._id} avatar attributes successfully populated`); + } catch (error) { + logger.error( + `Failed to populate avatar attributes for user ${user._id}: ${error.message}`, + ); + } + } +}; + +/** + * Reverts what the previous function does + * + * @returns Resolves when the migration process is complete. + */ +const unpopulateUserAvatar = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + const UserModel = mongoose.model(User.name, userSchema); + + const cursor = UserModel.find({ + avatar: { $exists: true, $ne: null }, + }).cursor(); + + for await (const user of cursor) { + try { + // Undo the updates made by populateUserAvatar + await AttachmentModel.updateOne( + { _id: user.avatar }, + { + $unset: { + context: '', + ownerType: '', + owner: '', + }, + }, + ); + logger.log(`User ${user._id} avatar attributes successfully reverted`); + } catch (error) { + logger.error( + `Failed to revert avatar attributes for user ${user._id}: ${error.message}`, + ); + } + } +}; + +/** + * Updates subscriber documents with their corresponding avatar attachments, + * populate new attributes (context, owner, ownerType) and moves avatar files to a new directory. * * @returns Resolves when the migration process is complete. */ @@ -539,6 +614,7 @@ const migrateAttachmentMessages = async ({ module.exports = { async up(services: MigrationServices) { + await populateUserAvatar(services); await populateSubscriberAvatar(services); await updateOldAvatarsPath(services); await migrateAttachmentBlocks(MigrationAction.UP, services); @@ -549,6 +625,7 @@ module.exports = { return true; }, async down(services: MigrationServices) { + await unpopulateUserAvatar(services); await unpopulateSubscriberAvatar(services); await restoreOldAvatarsPath(services); await migrateAttachmentBlocks(MigrationAction.DOWN, services); From e8c917affd8d2958b56c3bb46556548f670e1111 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 9 Jan 2025 07:54:00 +0100 Subject: [PATCH 06/23] feat: migrate setting attachments --- .../1735836154221-v-2-2-0.migration.ts | 146 +++++++++++------- 1 file changed, 89 insertions(+), 57 deletions(-) diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 99f8ad57..19cd0a10 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -22,6 +22,8 @@ import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; import { StdOutgoingAttachmentMessage } from '@/chat/schemas/types/message'; import contentSchema, { Content } from '@/cms/schemas/content.schema'; import { config } from '@/config'; +import settingSchema, { Setting } from '@/setting/schemas/setting.schema'; +import { SettingType } from '@/setting/schemas/types'; import roleSchema, { Role } from '@/user/schemas/role.schema'; import userSchema, { User } from '@/user/schemas/user.schema'; import { moveFile, moveFiles } from '@/utils/helpers/fs'; @@ -43,6 +45,50 @@ const getAdminUser = async () => { return user; }; +/** + * Updates setting attachment documents to populate new attributes (context, owner, ownerType) + * + * @returns Resolves when the migration process is complete. + */ +const populateSettingAttachment = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + const SettingModel = mongoose.model(Setting.name, settingSchema); + const user = await getAdminUser(); + + if (!user) { + logger.warn('Unable to populate setting attachments, no admin user found'); + } + + const cursor = SettingModel.find({ + type: SettingType.attachment, + }).cursor(); + + for await (const setting of cursor) { + try { + if (setting.value) { + await AttachmentModel.updateOne( + { _id: setting.value }, + { + $set: { + context: AttachmentContext.SettingAttachment, + ownerType: AttachmentOwnerType.User, + owner: user._id, + }, + }, + ); + logger.log(`User ${user._id} avatar attributes successfully populated`); + } + } catch (error) { + logger.error( + `Failed to populate avatar attributes for user ${user._id}: ${error.message}`, + ); + } + } +}; + /** * Updates user attachment documents to populate new attributes (context, owner, ownerType) * @@ -80,44 +126,6 @@ const populateUserAvatar = async ({ logger }: MigrationServices) => { } }; -/** - * Reverts what the previous function does - * - * @returns Resolves when the migration process is complete. - */ -const unpopulateUserAvatar = async ({ logger }: MigrationServices) => { - const AttachmentModel = mongoose.model( - Attachment.name, - attachmentSchema, - ); - const UserModel = mongoose.model(User.name, userSchema); - - const cursor = UserModel.find({ - avatar: { $exists: true, $ne: null }, - }).cursor(); - - for await (const user of cursor) { - try { - // Undo the updates made by populateUserAvatar - await AttachmentModel.updateOne( - { _id: user.avatar }, - { - $unset: { - context: '', - ownerType: '', - owner: '', - }, - }, - ); - logger.log(`User ${user._id} avatar attributes successfully reverted`); - } catch (error) { - logger.error( - `Failed to revert avatar attributes for user ${user._id}: ${error.message}`, - ); - } - } -}; - /** * Updates subscriber documents with their corresponding avatar attachments, * populate new attributes (context, owner, ownerType) and moves avatar files to a new directory. @@ -257,29 +265,11 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { { _id: subscriber._id }, { $set: { avatar: null }, - $unset: { - context: 1, - ownerType: 1, - owner: 1, - }, }, ); logger.log( `Subscriber ${subscriber._id} avatar attribute successfully reverted to null`, ); - await AttachmentModel.updateOne( - { _id: attachment._id }, - { - $unset: { - context: 1, - ownerType: 1, - owner: 1, - }, - }, - ); - logger.log( - `Subscriber ${subscriber._id} avatar attachment attributes successfully unpopulated`, - ); } else { logger.warn( `No avatar attachment found for subscriber ${subscriber._id}`, @@ -289,6 +279,47 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { } }; +/** + * Reverts the attachments additional attribute populate + * + * @returns Resolves when the migration process is complete. + */ +const undoPopulateAttachment = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + + try { + const result = await AttachmentModel.updateMany( + { + context: { + $in: [ + AttachmentContext.SettingAttachment, + AttachmentContext.UserAvatar, + AttachmentContext.SubscriberAvatar, + ], + }, + }, + { + $unset: { + context: '', + ownerType: '', + owner: '', + }, + }, + ); + + logger.log( + `Successfully reverted attributes for ${result.modifiedCount} attachments with context 'setting_attachment'`, + ); + } catch (error) { + logger.error( + `Failed to revert attributes for attachments with context 'setting_attachment': ${error.message}`, + ); + } +}; + /** * Migrates and updates the paths of old folder "avatars" files for subscribers and users. * @@ -614,6 +645,7 @@ const migrateAttachmentMessages = async ({ module.exports = { async up(services: MigrationServices) { + await populateSettingAttachment(services); await populateUserAvatar(services); await populateSubscriberAvatar(services); await updateOldAvatarsPath(services); @@ -625,8 +657,8 @@ module.exports = { return true; }, async down(services: MigrationServices) { - await unpopulateUserAvatar(services); await unpopulateSubscriberAvatar(services); + await undoPopulateAttachment(services); await restoreOldAvatarsPath(services); await migrateAttachmentBlocks(MigrationAction.DOWN, services); await migrateAttachmentContents(MigrationAction.DOWN, services); From e859cf193087a77306cbe12a8219a692e6a81468 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 9 Jan 2025 10:47:34 +0100 Subject: [PATCH 07/23] feat: add migration to populate block attachment new attributes --- .../1735836154221-v-2-2-0.migration.ts | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 19cd0a10..640777d6 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -45,12 +45,68 @@ const getAdminUser = async () => { return user; }; +/** + * Updates attachment documents for blocks that contain "message.attachment". + * + * @returns Resolves when the migration process is complete. + */ +const populateBlockAttachments = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + const BlockModel = mongoose.model(Block.name, blockSchema); + const user = await getAdminUser(); + + if (!user) { + logger.warn('Unable to process block attachments, no admin user found'); + return; + } + + // Find blocks where "message.attachment" exists + const cursor = BlockModel.find({ + 'message.attachment': { $exists: true }, + }).cursor(); + + for await (const block of cursor) { + try { + const msgPayload = (block.message as StdOutgoingAttachmentMessage) + .attachment.payload; + if (msgPayload && 'id' in msgPayload && msgPayload.id) { + const attachmentId = msgPayload.id; + // Update the corresponding attachment document + await AttachmentModel.updateOne( + { _id: attachmentId }, + { + $set: { + context: AttachmentContext.BlockAttachment, + ownerType: AttachmentOwnerType.User, + owner: user._id, + }, + }, + ); + logger.log( + `Attachment ${attachmentId} attributes successfully updated for block ${block._id}`, + ); + } else { + logger.warn( + `Block ${block._id} has a "message.attachment" but no "id" found`, + ); + } + } catch (error) { + logger.error( + `Failed to update attachment for block ${block._id}: ${error.message}`, + ); + } + } +}; + /** * Updates setting attachment documents to populate new attributes (context, owner, ownerType) * * @returns Resolves when the migration process is complete. */ -const populateSettingAttachment = async ({ logger }: MigrationServices) => { +const populateSettingAttachments = async ({ logger }: MigrationServices) => { const AttachmentModel = mongoose.model( Attachment.name, attachmentSchema, @@ -94,7 +150,7 @@ const populateSettingAttachment = async ({ logger }: MigrationServices) => { * * @returns Resolves when the migration process is complete. */ -const populateUserAvatar = async ({ logger }: MigrationServices) => { +const populateUserAvatars = async ({ logger }: MigrationServices) => { const AttachmentModel = mongoose.model( Attachment.name, attachmentSchema, @@ -132,7 +188,7 @@ const populateUserAvatar = async ({ logger }: MigrationServices) => { * * @returns Resolves when the migration process is complete. */ -const populateSubscriberAvatar = async ({ logger }: MigrationServices) => { +const populateSubscriberAvatars = async ({ logger }: MigrationServices) => { const AttachmentModel = mongoose.model( Attachment.name, attachmentSchema, @@ -284,7 +340,7 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { * * @returns Resolves when the migration process is complete. */ -const undoPopulateAttachment = async ({ logger }: MigrationServices) => { +const undoPopulateAttachments = async ({ logger }: MigrationServices) => { const AttachmentModel = mongoose.model( Attachment.name, attachmentSchema, @@ -295,6 +351,7 @@ const undoPopulateAttachment = async ({ logger }: MigrationServices) => { { context: { $in: [ + AttachmentContext.BlockAttachment, AttachmentContext.SettingAttachment, AttachmentContext.UserAvatar, AttachmentContext.SubscriberAvatar, @@ -645,20 +702,21 @@ const migrateAttachmentMessages = async ({ module.exports = { async up(services: MigrationServices) { - await populateSettingAttachment(services); - await populateUserAvatar(services); - await populateSubscriberAvatar(services); await updateOldAvatarsPath(services); await migrateAttachmentBlocks(MigrationAction.UP, services); await migrateAttachmentContents(MigrationAction.UP, services); // Given the complexity and inconsistency data, this method does not have // a revert equivalent, at the same time, thus, it doesn't "unset" any attribute await migrateAttachmentMessages(services); + await populateBlockAttachments(services); + await populateSettingAttachments(services); + await populateUserAvatars(services); + await populateSubscriberAvatars(services); return true; }, async down(services: MigrationServices) { + await undoPopulateAttachments(services); await unpopulateSubscriberAvatar(services); - await undoPopulateAttachment(services); await restoreOldAvatarsPath(services); await migrateAttachmentBlocks(MigrationAction.DOWN, services); await migrateAttachmentContents(MigrationAction.DOWN, services); From 39213e8b0b30c63503430d6f1a05d47b8e62622c Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 15 Jan 2025 15:46:50 +0100 Subject: [PATCH 08/23] refactor: channels design to allow attachment ownership --- .../attachment/schemas/attachment.schema.ts | 4 +- .../attachment/services/attachment.service.ts | 27 +++++---- api/src/attachment/types/index.ts | 24 ++++++++ api/src/channel/lib/EventWrapper.ts | 24 +++++--- api/src/channel/lib/Handler.ts | 60 +++++++++++++++++-- .../chat/controllers/subscriber.controller.ts | 6 +- api/src/chat/services/chat.service.ts | 46 +++++++++++++- .../channels/web/base-web-channel.ts | 6 +- api/src/plugins/base-storage-plugin.ts | 6 +- api/src/user/controllers/user.controller.ts | 25 +++----- api/src/utils/types/misc.ts | 9 +++ 11 files changed, 184 insertions(+), 53 deletions(-) create mode 100644 api/src/utils/types/misc.ts diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index f9355c4c..8a7a9b4e 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -149,13 +149,13 @@ export class Attachment extends AttachmentStub { @Schema({ timestamps: true }) export class UserAttachmentFull extends AttachmentStub { @Type(() => User) - owner: User | null; + owner: User | undefined; } @Schema({ timestamps: true }) export class SubscriberAttachmentFull extends AttachmentStub { @Type(() => Subscriber) - owner: Subscriber | null; + owner: Subscriber | undefined; } export type AttachmentDocument = THydratedDocument; diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 242d19c4..1d4ac708 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -30,6 +30,7 @@ import { BaseService } from '@/utils/generics/base-service'; import { AttachmentMetadataDto } from '../dto/attachment.dto'; import { AttachmentRepository } from '../repositories/attachment.repository'; import { Attachment } from '../schemas/attachment.schema'; +import { TAttachmentContext } from '../types'; import { fileExists, generateUniqueFilename, @@ -156,6 +157,18 @@ export class AttachmentService extends BaseService { } } + /** + * Get the attachment root directory given the context + * + * @param context The attachment context + * @returns The root directory path + */ + getRootDirByContext(context: TAttachmentContext) { + return context === 'subscriber_avatar' || context === 'user_avatar' + ? config.parameters.avatarDir + : config.parameters.uploadDir; + } + /** * Uploads files to the server. If a storage plugin is configured it uploads files accordingly. * Otherwise, uploads files to the local directory. @@ -168,16 +181,12 @@ export class AttachmentService extends BaseService { async store( file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, - rootDir = config.parameters.uploadDir, ): Promise { if (this.getStoragePlugin()) { - const storedDto = await this.getStoragePlugin()?.store?.( - file, - metadata, - rootDir, - ); + const storedDto = await this.getStoragePlugin()?.store?.(file, metadata); return storedDto ? await this.create(storedDto) : undefined; } else { + const rootDir = this.getRootDirByContext(metadata.context); const uniqueFilename = generateUniqueFilename(metadata.name); const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename))); @@ -225,10 +234,7 @@ export class AttachmentService extends BaseService { * @param rootDir - The root directory where attachment shoud be located. * @returns A promise that resolves to a StreamableFile representing the downloaded attachment. */ - async download( - attachment: Attachment, - rootDir = config.parameters.uploadDir, - ): Promise { + async download(attachment: Attachment): Promise { if (this.getStoragePlugin()) { const streamableFile = await this.getStoragePlugin()?.download(attachment); @@ -238,6 +244,7 @@ export class AttachmentService extends BaseService { return streamableFile; } else { + const rootDir = this.getRootDirByContext(attachment.context); const path = resolve(join(rootDir, attachment.location)); if (!fileExists(path)) { diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts index e7cdc6ee..9accd75c 100644 --- a/api/src/attachment/types/index.ts +++ b/api/src/attachment/types/index.ts @@ -6,6 +6,8 @@ * 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 { Readable, Stream } from 'stream'; + /** * Defines the types of owners for an attachment, * indicating whether the file belongs to a User or a Subscriber. @@ -31,3 +33,25 @@ export enum AttachmentContext { } export type TAttachmentContext = `${AttachmentContext}`; + +export class AttachmentFile { + /** + * File original file name + */ + file: Buffer | Stream | Readable | Express.Multer.File; + + /** + * File original file name + */ + name?: string; + + /** + * File size in bytes + */ + size: number; + + /** + * File MIME type + */ + type: string; +} diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index c8a9e47a..03c37a8f 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.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 { Subscriber } from '@/chat/schemas/subscriber.schema'; import { AttachmentPayload } from '@/chat/schemas/types/attachment'; import { SubscriberChannelData } from '@/chat/schemas/types/channel'; @@ -29,19 +30,24 @@ export default abstract class EventWrapper< eventType: StdEventType; messageType?: IncomingMessageType; raw: E; + attachments?: Attachment[]; }, E, N extends ChannelName = ChannelName, C extends ChannelHandler = ChannelHandler, S = SubscriberChannelDict[N], > { - _adapter: A = { raw: {}, eventType: StdEventType.unknown } as A; + _adapter: A = { + raw: {}, + eventType: StdEventType.unknown, + attachments: undefined, + } as A; _handler: C; channelAttrs: S; - _profile!: Subscriber; + subscriber!: Subscriber; _nlp!: NLU.ParseEntities; @@ -177,7 +183,7 @@ export default abstract class EventWrapper< * @returns event sender data */ getSender(): Subscriber { - return this._profile; + return this.subscriber; } /** @@ -186,7 +192,7 @@ export default abstract class EventWrapper< * @param profile - Sender data */ setSender(profile: Subscriber) { - this._profile = profile; + this.subscriber = profile; } /** @@ -194,9 +200,13 @@ export default abstract class EventWrapper< * * Child class can perform operations such as storing files as attachments. */ - preprocess() { - // Nothing ... - return Promise.resolve(); + async preprocess() { + if ( + this._adapter.eventType === StdEventType.message && + this._adapter.messageType === IncomingMessageType.attachments + ) { + await this._handler.persistMessageAttachments(this); + } } /** diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index d488cc72..ee0e7ccb 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -17,12 +17,17 @@ import { import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import { plainToClass } from 'class-transformer'; import { NextFunction, Request, Response } from 'express'; +import mime from 'mime'; +import { v4 as uuidv4 } from 'uuid'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { AttachmentFile } from '@/attachment/types'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; import { AttachmentRef } from '@/chat/schemas/types/attachment'; import { + IncomingMessageType, + StdEventType, StdOutgoingEnvelope, StdOutgoingMessage, } from '@/chat/schemas/types/message'; @@ -50,7 +55,7 @@ export default abstract class ChannelHandler< private readonly settings: ChannelSetting[]; @Inject(AttachmentService) - protected readonly attachmentService: AttachmentService; + public readonly attachmentService: AttachmentService; @Inject(JwtService) protected readonly jwtService: JwtService; @@ -206,15 +211,62 @@ export default abstract class ChannelHandler< ): Promise<{ mid: string }>; /** - * Fetch the end user profile data + * Calls the channel handler to fetch attachments and stores them + * + * @param event + * @returns An attachment array + */ + getMessageAttachments?( + event: EventWrapper, + ): Promise; + + /** + * Fetch the subscriber profile data * @param event - The message event received * @returns {Promise} - The channel's response, otherwise an error - */ - abstract getUserData( + getSubscriberAvatar?( + event: EventWrapper, + ): Promise; + + /** + * Fetch the subscriber profile data + * + * @param event - The message event received + * @returns {Promise} - The channel's response, otherwise an error + */ + abstract getSubscriberData( event: EventWrapper, ): Promise; + /** + * Persist Message attachments + * + * @returns Resolves the promise once attachments are fetched and stored + */ + async persistMessageAttachments(event: EventWrapper) { + if ( + event._adapter.eventType === StdEventType.message && + event._adapter.messageType === IncomingMessageType.attachments && + this.getMessageAttachments + ) { + const metadatas = await this.getMessageAttachments(event); + const subscriber = event.getSender(); + event._adapter.attachments = await Promise.all( + metadatas.map(({ file, name, type, size }) => { + return this.attachmentService.store(file, { + name: `${name ? `${name}-` : ''}${uuidv4()}.${mime.extension(type)}`, + type, + size, + context: 'message_attachment', + ownerType: 'Subscriber', + owner: subscriber.id, + }); + }), + ); + } + } + /** * Custom channel middleware * @param req diff --git a/api/src/chat/controllers/subscriber.controller.ts b/api/src/chat/controllers/subscriber.controller.ts index d0688df0..b88df3cb 100644 --- a/api/src/chat/controllers/subscriber.controller.ts +++ b/api/src/chat/controllers/subscriber.controller.ts @@ -20,7 +20,6 @@ import { import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { config } from '@/config'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; import { BaseController } from '@/utils/generics/base-controller'; @@ -159,10 +158,7 @@ export class SubscriberController extends BaseController< throw new Error('User has no avatar'); } - return await this.attachmentService.download( - subscriber.avatar, - config.parameters.avatarDir, - ); + return await this.attachmentService.download(subscriber.avatar); } catch (err) { this.logger.verbose( 'Subscriber has no avatar, generating initials avatar ...', diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 5fa9fcc2..8b8dc5ea 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -8,7 +8,10 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import mime from 'mime'; +import { v4 as uuidv4 } from 'uuid'; +import { AttachmentService } from '@/attachment/services/attachment.service'; import EventWrapper from '@/channel/lib/EventWrapper'; import { config } from '@/config'; import { HelperService } from '@/helper/helper.service'; @@ -36,6 +39,7 @@ export class ChatService { private readonly botService: BotService, private readonly websocketGateway: WebsocketGateway, private readonly helperService: HelperService, + private readonly attachmentService: AttachmentService, ) {} /** @@ -248,7 +252,7 @@ export class ChatService { }); if (!subscriber) { - const subscriberData = await handler.getUserData(event); + const subscriberData = await handler.getSubscriberData(event); this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users'); subscriberData.channel = event.getChannelData(); subscriber = await this.subscriberService.create(subscriberData); @@ -260,9 +264,47 @@ export class ChatService { this.websocketGateway.broadcastSubscriberUpdate(subscriber); + // Retrieve and store the subscriber avatar + if (handler.getSubscriberAvatar) { + try { + const metadata = await handler.getSubscriberAvatar(event); + if (metadata) { + const { file, type, size } = metadata; + const extension = mime.extension(type); + + const avatar = await this.attachmentService.store(file, { + name: `avatar-${uuidv4()}.${extension}`, + size, + type, + context: 'subscriber_avatar', + ownerType: 'Subscriber', + owner: subscriber.id, + }); + + if (avatar) { + subscriber = await this.subscriberService.updateOne( + subscriber.id, + { + avatar: avatar.id, + }, + ); + } + } + } catch (err) { + this.logger.error( + `Unable to retrieve avatar for subscriber ${subscriber.id}`, + err, + ); + } + } + + // Set the subscriber object event.setSender(subscriber); - await event.preprocess(); + // Preprocess the event (persist attachments, ...) + if (event.preprocess) { + await event.preprocess(); + } // Trigger message received event this.eventEmitter.emit('hook:chatbot:received', event); diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 08e8fb32..44421ebd 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -70,7 +70,7 @@ export default abstract class BaseWebChannelHandler< protected readonly eventEmitter: EventEmitter2, protected readonly i18n: I18nService, protected readonly subscriberService: SubscriberService, - protected readonly attachmentService: AttachmentService, + public readonly attachmentService: AttachmentService, protected readonly messageService: MessageService, protected readonly menuService: MenuService, protected readonly websocketGateway: WebsocketGateway, @@ -1321,7 +1321,9 @@ export default abstract class BaseWebChannelHandler< * * @returns The web's response, otherwise an error */ - async getUserData(event: WebEventWrapper): Promise { + async getSubscriberData( + event: WebEventWrapper, + ): Promise { const sender = event.getSender(); const { id: _id, diff --git a/api/src/plugins/base-storage-plugin.ts b/api/src/plugins/base-storage-plugin.ts index 8a0ac262..ebf3ee5f 100644 --- a/api/src/plugins/base-storage-plugin.ts +++ b/api/src/plugins/base-storage-plugin.ts @@ -37,10 +37,7 @@ export abstract class BaseStoragePlugin extends BasePlugin { /** @deprecated use store() instead */ uploadAvatar?(file: Express.Multer.File): Promise; - abstract download( - attachment: Attachment, - rootLocation?: string, - ): Promise; + abstract download(attachment: Attachment): Promise; /** @deprecated use download() instead */ downloadProfilePic?(name: string): Promise; @@ -52,6 +49,5 @@ export abstract class BaseStoragePlugin extends BasePlugin { store?( file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, - rootDir?: string, ): Promise; } diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index b9bc75a3..4cd16405 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -110,10 +110,7 @@ export class ReadOnlyUserController extends BaseController< throw new Error('User has no avatar'); } - return await this.attachmentService.download( - user.avatar, - config.parameters.avatarDir, - ); + return await this.attachmentService.download(user.avatar); } catch (err) { this.logger.verbose( 'User has no avatar, generating initials avatar ...', @@ -293,18 +290,14 @@ export class ReadWriteUserController extends ReadOnlyUserController { // Upload Avatar if provided const avatar = avatarFile - ? await this.attachmentService.store( - avatarFile, - { - name: avatarFile.originalname, - size: avatarFile.size, - type: avatarFile.mimetype, - context: 'user_avatar', - ownerType: 'User', - owner: req.user.id, - }, - config.parameters.avatarDir, - ) + ? await this.attachmentService.store(avatarFile, { + name: avatarFile.originalname, + size: avatarFile.size, + type: avatarFile.mimetype, + context: 'user_avatar', + ownerType: 'User', + owner: req.user.id, + }) : undefined; const result = await this.userService.updateOne( diff --git a/api/src/utils/types/misc.ts b/api/src/utils/types/misc.ts new file mode 100644 index 00000000..197ca81f --- /dev/null +++ b/api/src/utils/types/misc.ts @@ -0,0 +1,9 @@ +/* + * 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). + */ + +export type PartialExcept = Partial & Pick; From 90aad93356d487fdc088ce0f8367cebc815b82cd Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 15 Jan 2025 15:53:44 +0100 Subject: [PATCH 09/23] fix: update migration to populate the message attachments with extra attributes --- .../migrations/1735836154221-v-2-2-0.migration.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 640777d6..052e02d7 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -602,7 +602,7 @@ const migrateAttachmentContents = async ( * * @returns Resolves when the migration process is complete. */ -const migrateAttachmentMessages = async ({ +const migrateAndPopulateAttachmentMessages = async ({ logger, http, attachmentService, @@ -636,6 +636,16 @@ const migrateAttachmentMessages = async ({ msg.message.attachment.payload ) { if ('attachment_id' in msg.message.attachment.payload) { + // Add extra attrs + await attachmentService.updateOne( + msg.message.attachment.payload.attachment_id, + { + ownerType: msg.sender ? 'Subscriber' : 'User', + owner: msg.sender ? msg.sender : adminUser.id, + context: 'message_attachment', + }, + ); + // Rename `attachment_id` to `id` await updateAttachmentId( msg._id, msg.message.attachment.payload.attachment_id as string, @@ -707,7 +717,7 @@ module.exports = { await migrateAttachmentContents(MigrationAction.UP, services); // Given the complexity and inconsistency data, this method does not have // a revert equivalent, at the same time, thus, it doesn't "unset" any attribute - await migrateAttachmentMessages(services); + await migrateAndPopulateAttachmentMessages(services); await populateBlockAttachments(services); await populateSettingAttachments(services); await populateUserAvatars(services); From bdf17635030af8540a9334bbc794e4769b46f3b6 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 15 Jan 2025 15:59:30 +0100 Subject: [PATCH 10/23] refactor: rename owner to createdBy --- .../controllers/attachment.controller.spec.ts | 8 ++--- .../controllers/attachment.controller.ts | 4 +-- api/src/attachment/dto/attachment.dto.ts | 12 +++---- api/src/attachment/mocks/attachment.mock.ts | 12 +++---- .../attachment/schemas/attachment.schema.ts | 26 +++++++------- api/src/attachment/types/index.ts | 6 ++-- api/src/channel/lib/Handler.ts | 4 +-- api/src/channel/lib/__test__/common.mock.ts | 4 +-- api/src/chat/services/chat.service.ts | 4 +-- .../channels/web/base-web-channel.ts | 8 ++--- .../1735836154221-v-2-2-0.migration.ts | 36 +++++++++---------- api/src/user/controllers/user.controller.ts | 4 +-- api/src/user/types/index.type.ts | 4 +-- api/src/utils/test/fixtures/attachment.ts | 8 ++--- frontend/src/types/attachment.types.ts | 12 +++---- frontend/src/types/base.types.ts | 2 +- frontend/src/types/model.types.ts | 5 +-- 17 files changed, 80 insertions(+), 79 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index 0d84d349..2aaf2a0f 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -137,16 +137,16 @@ describe('AttachmentController', () => { name: attachmentFile.originalname, location: expect.stringMatching(new RegExp(`^/${name}`)), context: 'block_attachment', - ownerType: 'User', - owner: '9'.repeat(24), + createdByRef: 'User', + createdBy: '9'.repeat(24), }); expect(result).toEqualPayload( [ { ...attachment, context: 'block_attachment', - ownerType: 'User', - owner: '9'.repeat(24), + createdByRef: 'User', + createdBy: '9'.repeat(24), }, ], [...IGNORED_TEST_FIELDS, 'location', 'url'], diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index c93dd014..ef118204 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -150,8 +150,8 @@ export class AttachmentController extends BaseController { size: file.size, type: file.mimetype, context, - owner: userId, - ownerType: 'User', + createdBy: userId, + createdByRef: 'User', }); attachments.push(attachment); } diff --git a/api/src/attachment/dto/attachment.dto.ts b/api/src/attachment/dto/attachment.dto.ts index f698b135..6c3c982f 100644 --- a/api/src/attachment/dto/attachment.dto.ts +++ b/api/src/attachment/dto/attachment.dto.ts @@ -25,9 +25,9 @@ import { IsObjectId } from '@/utils/validation-rules/is-object-id'; import { AttachmentContext, - AttachmentOwnerType, + AttachmentCreatedByRef, TAttachmentContext, - TAttachmentOwnerType, + TAttachmentCreatedByRef, } from '../types'; export class AttachmentMetadataDto { @@ -81,12 +81,12 @@ export class AttachmentMetadataDto { */ @ApiPropertyOptional({ description: 'Attachment Owner Type', - enum: Object.values(AttachmentOwnerType), + enum: Object.values(AttachmentCreatedByRef), }) @IsString() @IsNotEmpty() - @IsIn(Object.values(AttachmentOwnerType)) - ownerType: TAttachmentOwnerType; + @IsIn(Object.values(AttachmentCreatedByRef)) + createdByRef: TAttachmentCreatedByRef; /** * Attachment Owner : Subscriber or User ID @@ -98,7 +98,7 @@ export class AttachmentMetadataDto { @IsString() @IsNotEmpty() @IsObjectId({ message: 'Owner must be a valid ObjectId' }) - owner: string; + createdBy: string; } export class AttachmentCreateDto extends AttachmentMetadataDto { diff --git a/api/src/attachment/mocks/attachment.mock.ts b/api/src/attachment/mocks/attachment.mock.ts index d0dbfef7..112201a8 100644 --- a/api/src/attachment/mocks/attachment.mock.ts +++ b/api/src/attachment/mocks/attachment.mock.ts @@ -20,8 +20,8 @@ export const attachment: Attachment = { id: '65940d115178607da65c82b6', createdAt: new Date(), updatedAt: new Date(), - owner: '1', - ownerType: 'User', + createdBy: '1', + createdByRef: 'User', }; export const attachmentFile: Express.Multer.File = { @@ -50,8 +50,8 @@ export const attachments: Attachment[] = [ id: '65940d115178607da65c82b7', createdAt: new Date(), updatedAt: new Date(), - owner: '1', - ownerType: 'User', + createdBy: '1', + createdByRef: 'User', }, { name: 'Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png', @@ -64,7 +64,7 @@ export const attachments: Attachment[] = [ id: '65940d115178607da65c82b8', createdAt: new Date(), updatedAt: new Date(), - owner: '1', - ownerType: 'User', + createdBy: '1', + createdByRef: 'User', }, ]; diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index 8a7a9b4e..10d341c4 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -25,9 +25,9 @@ import { import { AttachmentContext, - AttachmentOwnerType, + AttachmentCreatedByRef, TAttachmentContext, - TAttachmentOwnerType, + TAttachmentCreatedByRef, } from '../types'; import { MIME_REGEX } from '../utilities'; @@ -79,20 +79,20 @@ export class AttachmentStub extends BaseSchema { channel?: Partial>; /** - * Object ID of the owner (depending on the owner type) + * Object ID of the createdBy (depending on the createdBy type) */ @Prop({ type: MongooseSchema.Types.ObjectId, - refPath: 'ownerType', + refPath: 'createdByRef', default: null, }) - owner: unknown; + createdBy: unknown; /** - * Type of the owner (depending on the owner type) + * Type of the createdBy (depending on the createdBy type) */ - @Prop({ type: String, enum: Object.values(AttachmentOwnerType) }) - ownerType: TAttachmentOwnerType; + @Prop({ type: String, enum: Object.values(AttachmentCreatedByRef) }) + createdByRef: TAttachmentCreatedByRef; /** * Context of the attachment @@ -142,20 +142,20 @@ export class AttachmentStub extends BaseSchema { @Schema({ timestamps: true }) export class Attachment extends AttachmentStub { - @Transform(({ obj }) => obj.owner?.toString() || null) - owner: string | null; + @Transform(({ obj }) => obj.createdBy?.toString() || null) + createdBy: string | null; } @Schema({ timestamps: true }) export class UserAttachmentFull extends AttachmentStub { @Type(() => User) - owner: User | undefined; + createdBy: User | undefined; } @Schema({ timestamps: true }) export class SubscriberAttachmentFull extends AttachmentStub { @Type(() => Subscriber) - owner: Subscriber | undefined; + createdBy: Subscriber | undefined; } export type AttachmentDocument = THydratedDocument; @@ -182,4 +182,4 @@ export type AttachmentPopulate = keyof TFilterPopulateFields< AttachmentStub >; -export const ATTACHMENT_POPULATE: AttachmentPopulate[] = ['owner']; +export const ATTACHMENT_POPULATE: AttachmentPopulate[] = ['createdBy']; diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts index 9accd75c..b2a136b3 100644 --- a/api/src/attachment/types/index.ts +++ b/api/src/attachment/types/index.ts @@ -9,15 +9,15 @@ import { Readable, Stream } from 'stream'; /** - * Defines the types of owners for an attachment, + * Defines the types of createdBys for an attachment, * indicating whether the file belongs to a User or a Subscriber. */ -export enum AttachmentOwnerType { +export enum AttachmentCreatedByRef { User = 'User', Subscriber = 'Subscriber', } -export type TAttachmentOwnerType = `${AttachmentOwnerType}`; +export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; /** * Defines the various contexts in which an attachment can exist. diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index ee0e7ccb..49604f43 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -259,8 +259,8 @@ export default abstract class ChannelHandler< type, size, context: 'message_attachment', - ownerType: 'Subscriber', - owner: subscriber.id, + createdByRef: 'Subscriber', + createdBy: subscriber.id, }); }), ); diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index dcb27049..34826cbe 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -89,8 +89,8 @@ const attachment: Attachment = { }, }, context: 'block_attachment', - ownerType: 'User', - owner: null, + createdByRef: 'User', + createdBy: null, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 8b8dc5ea..672bccd7 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -277,8 +277,8 @@ export class ChatService { size, type, context: 'subscriber_avatar', - ownerType: 'Subscriber', - owner: subscriber.id, + createdByRef: 'Subscriber', + createdBy: subscriber.id, }); if (avatar) { diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 44421ebd..8f165677 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -621,8 +621,8 @@ export default abstract class BaseWebChannelHandler< size: Buffer.byteLength(data.file), type: data.type, context: 'message_attachment', - ownerType: 'Subscriber', - owner: req.session.web.profile?.id, + createdByRef: 'Subscriber', + createdBy: req.session.web.profile?.id, }); } catch (err) { this.logger.error( @@ -687,8 +687,8 @@ export default abstract class BaseWebChannelHandler< size: file.size, type: file.mimetype, context: 'message_attachment', - ownerType: 'Subscriber', - owner: req.session.web.profile?.id, + createdByRef: 'Subscriber', + createdBy: req.session.web.profile?.id, }); } catch (err) { this.logger.error( diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 052e02d7..a92ef24c 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -15,7 +15,7 @@ import { v4 as uuidv4 } from 'uuid'; import attachmentSchema, { Attachment, } from '@/attachment/schemas/attachment.schema'; -import { AttachmentContext, AttachmentOwnerType } from '@/attachment/types'; +import { AttachmentContext, AttachmentCreatedByRef } from '@/attachment/types'; import blockSchema, { Block } from '@/chat/schemas/block.schema'; import messageSchema, { Message } from '@/chat/schemas/message.schema'; import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; @@ -80,8 +80,8 @@ const populateBlockAttachments = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.BlockAttachment, - ownerType: AttachmentOwnerType.User, - owner: user._id, + createdByRef: AttachmentCreatedByRef.User, + createdBy: user._id, }, }, ); @@ -102,7 +102,7 @@ const populateBlockAttachments = async ({ logger }: MigrationServices) => { }; /** - * Updates setting attachment documents to populate new attributes (context, owner, ownerType) + * Updates setting attachment documents to populate new attributes (context, createdBy, createdByRef) * * @returns Resolves when the migration process is complete. */ @@ -130,8 +130,8 @@ const populateSettingAttachments = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.SettingAttachment, - ownerType: AttachmentOwnerType.User, - owner: user._id, + createdByRef: AttachmentCreatedByRef.User, + createdBy: user._id, }, }, ); @@ -146,7 +146,7 @@ const populateSettingAttachments = async ({ logger }: MigrationServices) => { }; /** - * Updates user attachment documents to populate new attributes (context, owner, ownerType) + * Updates user attachment documents to populate new attributes (context, createdBy, createdByRef) * * @returns Resolves when the migration process is complete. */ @@ -168,8 +168,8 @@ const populateUserAvatars = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.UserAvatar, - ownerType: AttachmentOwnerType.User, - owner: user._id, + createdByRef: AttachmentCreatedByRef.User, + createdBy: user._id, }, }, ); @@ -184,7 +184,7 @@ const populateUserAvatars = async ({ logger }: MigrationServices) => { /** * Updates subscriber documents with their corresponding avatar attachments, - * populate new attributes (context, owner, ownerType) and moves avatar files to a new directory. + * populate new attributes (context, createdBy, createdByRef) and moves avatar files to a new directory. * * @returns Resolves when the migration process is complete. */ @@ -229,8 +229,8 @@ const populateSubscriberAvatars = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.SubscriberAvatar, - ownerType: AttachmentOwnerType.Subscriber, - owner: subscriber._id, + createdByRef: AttachmentCreatedByRef.Subscriber, + createdBy: subscriber._id, }, }, ); @@ -361,8 +361,8 @@ const undoPopulateAttachments = async ({ logger }: MigrationServices) => { { $unset: { context: '', - ownerType: '', - owner: '', + createdByRef: '', + createdBy: '', }, }, ); @@ -640,8 +640,8 @@ const migrateAndPopulateAttachmentMessages = async ({ await attachmentService.updateOne( msg.message.attachment.payload.attachment_id, { - ownerType: msg.sender ? 'Subscriber' : 'User', - owner: msg.sender ? msg.sender : adminUser.id, + createdByRef: msg.sender ? 'Subscriber' : 'User', + createdBy: msg.sender ? msg.sender : adminUser.id, context: 'message_attachment', }, ); @@ -672,8 +672,8 @@ const migrateAndPopulateAttachmentMessages = async ({ size: fileBuffer.length, type: response.headers['content-type'], channel: {}, - owner: msg.sender ? msg.sender : adminUser.id, - ownerType: msg.sender ? 'Subscriber' : 'User', + createdBy: msg.sender ? msg.sender : adminUser.id, + createdByRef: msg.sender ? 'Subscriber' : 'User', context: 'message_attachment', }); diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 4cd16405..5c528450 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -295,8 +295,8 @@ export class ReadWriteUserController extends ReadOnlyUserController { size: avatarFile.size, type: avatarFile.mimetype, context: 'user_avatar', - ownerType: 'User', - owner: req.user.id, + createdByRef: 'User', + createdBy: req.user.id, }) : undefined; diff --git a/api/src/user/types/index.type.ts b/api/src/user/types/index.type.ts index 59eec88a..9eee3c2c 100644 --- a/api/src/user/types/index.type.ts +++ b/api/src/user/types/index.type.ts @@ -1,9 +1,9 @@ /* - * 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). */ -export type TRelation = 'role' | 'owner'; +export type TRelation = 'role' | 'createdBy'; diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index e60880bc..2bef61c7 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -23,8 +23,8 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, }, context: 'content_attachment', - ownerType: 'User', - owner: null, + createdByRef: 'User', + createdBy: null, }, { name: 'store2.jpg', @@ -37,8 +37,8 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, }, context: 'content_attachment', - ownerType: 'User', - owner: null, + createdByRef: 'User', + createdBy: null, }, ]; diff --git a/frontend/src/types/attachment.types.ts b/frontend/src/types/attachment.types.ts index adfdc8aa..aafd5aac 100644 --- a/frontend/src/types/attachment.types.ts +++ b/frontend/src/types/attachment.types.ts @@ -17,12 +17,12 @@ import { IUser } from "./user.types"; * Defines the types of owners for an attachment, * indicating whether the file belongs to a User or a Subscriber. */ -export enum AttachmentOwnerType { +export enum AttachmentCreatedByRef { User = "User", Subscriber = "Subscriber", } -export type TAttachmentOwnerType = `${AttachmentOwnerType}`; +export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; /** * Defines the various contexts in which an attachment can exist. @@ -47,8 +47,8 @@ export interface IAttachmentAttributes { url: string; channel?: Record; context: TAttachmentContext; - ownerType: TAttachmentOwnerType; - owner: string | null; + createdByRef: TAttachmentCreatedByRef; + createdBy: string | null; } export interface IAttachmentStub @@ -56,11 +56,11 @@ export interface IAttachmentStub OmitPopulate {} export interface IAttachment extends IAttachmentStub, IFormat { - owner: string | null; + createdBy: string | null; } export interface ISubscriberAttachmentFull extends IAttachmentStub, IFormat { - owner: (ISubscriber | IUser)[]; + createdBy: (ISubscriber | IUser)[]; } diff --git a/frontend/src/types/base.types.ts b/frontend/src/types/base.types.ts index 77dd4b11..29bb91d6 100644 --- a/frontend/src/types/base.types.ts +++ b/frontend/src/types/base.types.ts @@ -110,7 +110,7 @@ export const POPULATE_BY_TYPE = { [EntityType.MENUTREE]: [], [EntityType.LANGUAGE]: [], [EntityType.TRANSLATION]: [], - [EntityType.ATTACHMENT]: ["owner"], + [EntityType.ATTACHMENT]: ["createdBy"], [EntityType.CUSTOM_BLOCK]: [], [EntityType.CUSTOM_BLOCK_SETTINGS]: [], [EntityType.CHANNEL]: [], diff --git a/frontend/src/types/model.types.ts b/frontend/src/types/model.types.ts index 83145b06..51bccd40 100644 --- a/frontend/src/types/model.types.ts +++ b/frontend/src/types/model.types.ts @@ -1,17 +1,18 @@ /* - * 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 { EntityType, Format } from "@/services/types"; import { IBaseSchema, IFormat, OmitPopulate } from "./base.types"; import { IPermission } from "./permission.types"; -export type TRelation = "role" | "owner"; +export type TRelation = "role" | "createdBy"; export interface IModelAttributes { name: string; From 0b4a1085eca9e2e7c1cbb4e66f62e994ddc9a8a3 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 15 Jan 2025 19:05:28 +0100 Subject: [PATCH 11/23] fix: minor --- .../controllers/attachment.controller.ts | 7 +++-- api/src/attachment/dto/attachment.dto.ts | 3 ++- .../attachment/services/attachment.service.ts | 4 +-- api/src/channel/lib/EventWrapper.ts | 27 +++++++------------ api/src/chat/services/chat.service.ts | 14 +++++++--- api/src/chat/services/conversation.service.ts | 13 +-------- .../channels/web/base-web-channel.ts | 9 +++++-- api/src/extensions/channels/web/wrapper.ts | 12 --------- .../1735836154221-v-2-2-0.migration.ts | 8 +++--- api/src/utils/test/fixtures/attachment.ts | 4 +-- 10 files changed, 44 insertions(+), 57 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index ef118204..11a88868 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -143,7 +143,7 @@ export class AttachmentController extends BaseController { ); } - const attachments = []; + const attachments: Attachment[] = []; for (const file of files.file) { const attachment = await this.attachmentService.store(file, { name: file.originalname, @@ -153,7 +153,10 @@ export class AttachmentController extends BaseController { createdBy: userId, createdByRef: 'User', }); - attachments.push(attachment); + + if (attachment) { + attachments.push(attachment); + } } return attachments; diff --git a/api/src/attachment/dto/attachment.dto.ts b/api/src/attachment/dto/attachment.dto.ts index 6c3c982f..14bdca30 100644 --- a/api/src/attachment/dto/attachment.dto.ts +++ b/api/src/attachment/dto/attachment.dto.ts @@ -132,5 +132,6 @@ export class AttachmentContextParamDto { }) @IsString() @IsIn(Object.values(AttachmentContext)) - context?: TAttachmentContext; + @IsNotEmpty() + context: TAttachmentContext; } diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 1d4ac708..b81e84b6 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -181,10 +181,10 @@ export class AttachmentService extends BaseService { async store( file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, - ): Promise { + ): Promise { if (this.getStoragePlugin()) { const storedDto = await this.getStoragePlugin()?.store?.(file, metadata); - return storedDto ? await this.create(storedDto) : undefined; + return storedDto ? await this.create(storedDto) : null; } else { const rootDir = this.getRootDirByContext(metadata.context); const uniqueFilename = generateUniqueFilename(metadata.name); diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index 03c37a8f..73b4d121 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -8,7 +8,6 @@ import { Attachment } from '@/attachment/schemas/attachment.schema'; import { Subscriber } from '@/chat/schemas/subscriber.schema'; -import { AttachmentPayload } from '@/chat/schemas/types/attachment'; import { SubscriberChannelData } from '@/chat/schemas/types/channel'; import { IncomingMessageType, @@ -78,7 +77,6 @@ export default abstract class EventWrapper< messageType: this.getMessageType(), payload: this.getPayload(), message: this.getMessage(), - attachments: this.getAttachments(), deliveredMessages: this.getDeliveredMessages(), watermark: this.getWatermark(), }, @@ -205,7 +203,15 @@ export default abstract class EventWrapper< this._adapter.eventType === StdEventType.message && this._adapter.messageType === IncomingMessageType.attachments ) { - await this._handler.persistMessageAttachments(this); + await this._handler.persistMessageAttachments( + this as EventWrapper< + any, + any, + ChannelName, + ChannelHandler, + Record + >, + ); } } @@ -263,13 +269,6 @@ export default abstract class EventWrapper< return ''; } - /** - * Returns the list of received attachments - * - * @returns Received attachments message - */ - abstract getAttachments(): AttachmentPayload[]; - /** * Returns the list of delivered messages * @@ -393,14 +392,6 @@ export class GenericEventWrapper extends EventWrapper< throw new Error('Unknown incoming message type'); } - /** - * @returns A list of received attachments - * @deprecated - This method is deprecated - */ - getAttachments(): AttachmentPayload[] { - return []; - } - /** * Returns the delivered messages ids * diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 672bccd7..67c27774 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -256,6 +256,10 @@ export class ChatService { this.eventEmitter.emit('hook:stats:entry', 'new_users', 'New users'); subscriberData.channel = event.getChannelData(); subscriber = await this.subscriberService.create(subscriberData); + + if (!subscriber) { + throw new Error('Unable to create a new subscriber'); + } } else { // Already existing user profile // Exec lastvisit hook @@ -288,18 +292,22 @@ export class ChatService { avatar: avatar.id, }, ); + + if (!subscriber) { + throw new Error('Unable to update the subscriber avatar'); + } } } } catch (err) { this.logger.error( - `Unable to retrieve avatar for subscriber ${subscriber.id}`, + `Unable to retrieve avatar for subscriber ${event.getSenderForeignId()}`, err, ); } } // Set the subscriber object - event.setSender(subscriber); + event.setSender(subscriber!); // Preprocess the event (persist attachments, ...) if (event.preprocess) { @@ -309,7 +317,7 @@ export class ChatService { // Trigger message received event this.eventEmitter.emit('hook:chatbot:received', event); - if (subscriber.assignedTo) { + if (subscriber?.assignedTo) { this.logger.debug('Conversation taken over', subscriber.assignedTo); return; } diff --git a/api/src/chat/services/conversation.service.ts b/api/src/chat/services/conversation.service.ts index 118f367f..5e8ce422 100644 --- a/api/src/chat/services/conversation.service.ts +++ b/api/src/chat/services/conversation.service.ts @@ -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. @@ -149,17 +149,6 @@ export class ConversationService extends BaseService< lat: parseFloat(coordinates.lat.toString()), lon: parseFloat(coordinates.lon.toString()), }; - } else if (msgType === 'attachments') { - // @TODO : deprecated in favor of geolocation msgType - const attachments = event.getAttachments(); - // @ts-expect-error deprecated - if (attachments.length === 1 && attachments[0].type === 'location') { - // @ts-expect-error deprecated - const coord = attachments[0].payload.coordinates; - convo.context.user_location = { lat: 0, lon: 0 }; - convo.context.user_location.lat = parseFloat(coord.lat); - convo.context.user_location.lon = parseFloat(coord.long); - } } // Deal with load more in the case of a list display diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 8f165677..851ce843 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -604,6 +604,11 @@ export default abstract class BaseWebChannelHandler< try { const { type, data } = req.body as Web.IncomingMessage; + if (!req.session?.web?.profile?.id) { + this.logger.debug('Web Channel Handler : No session'); + return null; + } + // Check if any file is provided if (type !== 'file' || !('file' in data) || !data.file) { this.logger.debug('Web Channel Handler : No files provided'); @@ -622,7 +627,7 @@ export default abstract class BaseWebChannelHandler< type: data.type, context: 'message_attachment', createdByRef: 'Subscriber', - createdBy: req.session.web.profile?.id, + createdBy: req.session?.web?.profile?.id, }); } catch (err) { this.logger.error( @@ -677,7 +682,7 @@ export default abstract class BaseWebChannelHandler< const file = await multerUpload; // Check if any file is provided - if (!req.file) { + if (!file) { this.logger.debug('Web Channel Handler : No files provided'); return null; } diff --git a/api/src/extensions/channels/web/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts index 094ee5ae..e576c7d6 100644 --- a/api/src/extensions/channels/web/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -9,7 +9,6 @@ import { Attachment } from '@/attachment/schemas/attachment.schema'; import EventWrapper from '@/channel/lib/EventWrapper'; import { ChannelName } from '@/channel/types'; -import { AttachmentPayload } from '@/chat/schemas/types/attachment'; import { IncomingMessageType, PayloadType, @@ -296,17 +295,6 @@ export default class WebEventWrapper< } } - /** - * Return the list of recieved attachments - * - * @deprecated - * @returns Received attachments message - */ - getAttachments(): AttachmentPayload[] { - const message = this.getMessage() as any; - return 'attachment' in message ? [].concat(message.attachment) : []; - } - /** * Return the delivered messages ids * diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index a92ef24c..4c14bb40 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -38,11 +38,11 @@ const getAdminUser = async () => { const UserModel = mongoose.model(User.name, userSchema); const adminRole = await RoleModel.findOne({ name: 'admin' }); - const user = await UserModel.findOne({ roles: [adminRole._id] }).sort({ + const user = await UserModel.findOne({ roles: [adminRole!._id] }).sort({ createdAt: 'asc', }); - return user; + return user!; }; /** @@ -638,7 +638,7 @@ const migrateAndPopulateAttachmentMessages = async ({ if ('attachment_id' in msg.message.attachment.payload) { // Add extra attrs await attachmentService.updateOne( - msg.message.attachment.payload.attachment_id, + msg.message.attachment.payload.attachment_id as string, { createdByRef: msg.sender ? 'Subscriber' : 'User', createdBy: msg.sender ? msg.sender : adminUser.id, @@ -679,6 +679,8 @@ const migrateAndPopulateAttachmentMessages = async ({ if (attachment) { await updateAttachmentId(msg._id, attachment.id); + } else { + logger.warn(`Unable to store attachment for message ${msg._id}`); } } } else { diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index 2bef61c7..2bb0469e 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -24,7 +24,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, context: 'content_attachment', createdByRef: 'User', - createdBy: null, + createdBy: '9'.repeat(24), }, { name: 'store2.jpg', @@ -38,7 +38,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, context: 'content_attachment', createdByRef: 'User', - createdBy: null, + createdBy: '9'.repeat(24), }, ]; From 505cd247a12e222892c9f2caa062fadfa0f59c1e Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 16 Jan 2025 06:46:41 +0100 Subject: [PATCH 12/23] feat: add the access attribute --- .../controllers/attachment.controller.spec.ts | 1 + .../controllers/attachment.controller.ts | 3 +- api/src/attachment/dto/attachment.dto.ts | 33 ++++++++++++++++--- api/src/attachment/mocks/attachment.mock.ts | 3 ++ .../attachment/schemas/attachment.schema.ts | 8 +++++ api/src/attachment/types/index.ts | 7 ++++ api/src/channel/lib/Handler.ts | 1 + api/src/channel/lib/__test__/common.mock.ts | 1 + api/src/chat/services/chat.service.ts | 1 + .../channels/web/base-web-channel.ts | 2 ++ .../1735836154221-v-2-2-0.migration.ts | 11 +++++-- api/src/user/controllers/user.controller.ts | 1 + api/src/utils/test/fixtures/attachment.ts | 2 ++ 13 files changed, 66 insertions(+), 8 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index 2aaf2a0f..e22622d3 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -137,6 +137,7 @@ describe('AttachmentController', () => { name: attachmentFile.originalname, location: expect.stringMatching(new RegExp(`^/${name}`)), context: 'block_attachment', + access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), }); diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index 11a88868..8286f31f 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -130,7 +130,7 @@ export class AttachmentController extends BaseController { async uploadFile( @UploadedFiles() files: { file: Express.Multer.File[] }, @Req() req: Request, - @Query() { context }: AttachmentContextParamDto, + @Query() { context, access = 'public' }: AttachmentContextParamDto, ): Promise { if (!files || !Array.isArray(files?.file) || files.file.length === 0) { throw new BadRequestException('No file was selected'); @@ -150,6 +150,7 @@ export class AttachmentController extends BaseController { size: file.size, type: file.mimetype, context, + access, createdBy: userId, createdByRef: 'User', }); diff --git a/api/src/attachment/dto/attachment.dto.ts b/api/src/attachment/dto/attachment.dto.ts index 14bdca30..cb91bbe5 100644 --- a/api/src/attachment/dto/attachment.dto.ts +++ b/api/src/attachment/dto/attachment.dto.ts @@ -24,8 +24,10 @@ import { ObjectIdDto } from '@/utils/dto/object-id.dto'; import { IsObjectId } from '@/utils/validation-rules/is-object-id'; import { + AttachmentAccess, AttachmentContext, AttachmentCreatedByRef, + TAttachmentAccess, TAttachmentContext, TAttachmentCreatedByRef, } from '../types'; @@ -67,7 +69,7 @@ export class AttachmentMetadataDto { /** * Attachment context */ - @ApiPropertyOptional({ + @ApiProperty({ description: 'Attachment Context', enum: Object.values(AttachmentContext), }) @@ -79,7 +81,7 @@ export class AttachmentMetadataDto { /** * Attachment Owner Type */ - @ApiPropertyOptional({ + @ApiProperty({ description: 'Attachment Owner Type', enum: Object.values(AttachmentCreatedByRef), }) @@ -88,12 +90,24 @@ export class AttachmentMetadataDto { @IsIn(Object.values(AttachmentCreatedByRef)) createdByRef: TAttachmentCreatedByRef; + /** + * Attachment Access + */ + @ApiProperty({ + description: 'Attachment Access', + enum: Object.values(AttachmentAccess), + }) + @IsString() + @IsNotEmpty() + @IsIn(Object.values(AttachmentAccess)) + access: TAttachmentAccess; + /** * Attachment Owner : Subscriber or User ID */ - @ApiPropertyOptional({ + @ApiProperty({ description: 'Attachment Owner : Subscriber / User ID', - enum: Object.values(AttachmentContext), + type: String, }) @IsString() @IsNotEmpty() @@ -126,7 +140,7 @@ export class AttachmentDownloadDto extends ObjectIdDto { } export class AttachmentContextParamDto { - @ApiPropertyOptional({ + @ApiProperty({ description: 'Attachment Context', enum: Object.values(AttachmentContext), }) @@ -134,4 +148,13 @@ export class AttachmentContextParamDto { @IsIn(Object.values(AttachmentContext)) @IsNotEmpty() context: TAttachmentContext; + + @ApiPropertyOptional({ + description: 'Attachment Access', + enum: Object.values(AttachmentAccess), + }) + @IsString() + @IsIn(Object.values(AttachmentAccess)) + @IsOptional() + access?: TAttachmentAccess; } diff --git a/api/src/attachment/mocks/attachment.mock.ts b/api/src/attachment/mocks/attachment.mock.ts index 112201a8..dde89f84 100644 --- a/api/src/attachment/mocks/attachment.mock.ts +++ b/api/src/attachment/mocks/attachment.mock.ts @@ -17,6 +17,7 @@ export const attachment: Attachment = { location: '/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', context: 'block_attachment', + access: 'public', id: '65940d115178607da65c82b6', createdAt: new Date(), updatedAt: new Date(), @@ -47,6 +48,7 @@ export const attachments: Attachment[] = [ '/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', channel: { ['some-channel']: {} }, context: 'block_attachment', + access: 'public', id: '65940d115178607da65c82b7', createdAt: new Date(), updatedAt: new Date(), @@ -61,6 +63,7 @@ export const attachments: Attachment[] = [ '/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png', channel: { ['some-channel']: {} }, context: 'block_attachment', + access: 'public', id: '65940d115178607da65c82b8', createdAt: new Date(), updatedAt: new Date(), diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index 10d341c4..1a23f370 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -24,8 +24,10 @@ import { } from '@/utils/types/filter.types'; import { + AttachmentAccess, AttachmentContext, AttachmentCreatedByRef, + TAttachmentAccess, TAttachmentContext, TAttachmentCreatedByRef, } from '../types'; @@ -100,6 +102,12 @@ export class AttachmentStub extends BaseSchema { @Prop({ type: String, enum: Object.values(AttachmentContext) }) context: TAttachmentContext; + /** + * Context of the attachment + */ + @Prop({ type: String, enum: Object.values(AttachmentAccess) }) + access: TAttachmentAccess; + /** * Optional property representing the URL of the attachment. * diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts index b2a136b3..cf3f0ce3 100644 --- a/api/src/attachment/types/index.ts +++ b/api/src/attachment/types/index.ts @@ -34,6 +34,13 @@ export enum AttachmentContext { export type TAttachmentContext = `${AttachmentContext}`; +export enum AttachmentAccess { + Public = 'public', + Private = 'private', +} + +export type TAttachmentAccess = `${AttachmentAccess}`; + export class AttachmentFile { /** * File original file name diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 49604f43..53ccdefc 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -259,6 +259,7 @@ export default abstract class ChannelHandler< type, size, context: 'message_attachment', + access: 'private', createdByRef: 'Subscriber', createdBy: subscriber.id, }); diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index 34826cbe..e825cac4 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -89,6 +89,7 @@ const attachment: Attachment = { }, }, context: 'block_attachment', + access: 'public', createdByRef: 'User', createdBy: null, createdAt: new Date(), diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 67c27774..72b63a1a 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -281,6 +281,7 @@ export class ChatService { size, type, context: 'subscriber_avatar', + access: 'private', createdByRef: 'Subscriber', createdBy: subscriber.id, }); diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 851ce843..5cc56eb0 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -626,6 +626,7 @@ export default abstract class BaseWebChannelHandler< size: Buffer.byteLength(data.file), type: data.type, context: 'message_attachment', + access: 'private', createdByRef: 'Subscriber', createdBy: req.session?.web?.profile?.id, }); @@ -692,6 +693,7 @@ export default abstract class BaseWebChannelHandler< size: file.size, type: file.mimetype, context: 'message_attachment', + access: 'private', createdByRef: 'Subscriber', createdBy: req.session.web.profile?.id, }); diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 4c14bb40..dfcebfa0 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -80,6 +80,7 @@ const populateBlockAttachments = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.BlockAttachment, + access: 'public', createdByRef: AttachmentCreatedByRef.User, createdBy: user._id, }, @@ -130,6 +131,7 @@ const populateSettingAttachments = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.SettingAttachment, + access: 'public', createdByRef: AttachmentCreatedByRef.User, createdBy: user._id, }, @@ -168,6 +170,7 @@ const populateUserAvatars = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.UserAvatar, + access: 'private', createdByRef: AttachmentCreatedByRef.User, createdBy: user._id, }, @@ -229,6 +232,7 @@ const populateSubscriberAvatars = async ({ logger }: MigrationServices) => { { $set: { context: AttachmentContext.SubscriberAvatar, + access: 'private', createdByRef: AttachmentCreatedByRef.Subscriber, createdBy: subscriber._id, }, @@ -361,6 +365,7 @@ const undoPopulateAttachments = async ({ logger }: MigrationServices) => { { $unset: { context: '', + access: '', createdByRef: '', createdBy: '', }, @@ -640,9 +645,10 @@ const migrateAndPopulateAttachmentMessages = async ({ await attachmentService.updateOne( msg.message.attachment.payload.attachment_id as string, { + context: 'message_attachment', + access: 'private', createdByRef: msg.sender ? 'Subscriber' : 'User', createdBy: msg.sender ? msg.sender : adminUser.id, - context: 'message_attachment', }, ); // Rename `attachment_id` to `id` @@ -672,9 +678,10 @@ const migrateAndPopulateAttachmentMessages = async ({ size: fileBuffer.length, type: response.headers['content-type'], channel: {}, + context: 'message_attachment', + access: msg.sender ? 'private' : 'public', createdBy: msg.sender ? msg.sender : adminUser.id, createdByRef: msg.sender ? 'Subscriber' : 'User', - context: 'message_attachment', }); if (attachment) { diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 5c528450..a322ceff 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -295,6 +295,7 @@ export class ReadWriteUserController extends ReadOnlyUserController { size: avatarFile.size, type: avatarFile.mimetype, context: 'user_avatar', + access: 'private', createdByRef: 'User', createdBy: req.user.id, }) diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index 2bb0469e..cfbe046f 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -23,6 +23,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, }, context: 'content_attachment', + access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), }, @@ -37,6 +38,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, }, context: 'content_attachment', + access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), }, From 3f9dd692bf202508f90963f12030372e8d094b8a Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 16 Jan 2025 08:36:21 +0100 Subject: [PATCH 13/23] refactor: rename context to resourceRef --- .../controllers/attachment.controller.spec.ts | 8 +-- .../controllers/attachment.controller.ts | 9 ++-- api/src/attachment/dto/attachment.dto.ts | 22 ++++---- .../guards/attachment-ability.guard.spec.ts | 16 +++--- .../guards/attachment-ability.guard.ts | 51 +++++++++++-------- api/src/attachment/mocks/attachment.mock.ts | 6 +-- .../attachment/schemas/attachment.schema.ts | 12 ++--- .../attachment/services/attachment.service.ts | 14 ++--- api/src/attachment/types/index.ts | 8 +-- api/src/attachment/utilities/index.ts | 33 ++++++------ api/src/channel/lib/Handler.ts | 2 +- api/src/channel/lib/__test__/common.mock.ts | 2 +- api/src/chat/services/chat.service.ts | 2 +- .../channels/web/base-web-channel.ts | 4 +- .../1735836154221-v-2-2-0.migration.ts | 39 +++++++------- api/src/user/controllers/user.controller.ts | 2 +- api/src/utils/test/fixtures/attachment.ts | 4 +- .../attachment/AttachmentInput.tsx | 8 +-- .../attachment/AttachmentUploader.tsx | 8 +-- .../attachment/MultipleAttachmentInput.tsx | 8 +-- .../src/components/contents/ContentDialog.tsx | 2 +- .../contents/ContentImportDialog.tsx | 2 +- .../src/components/settings/SettingInput.tsx | 4 +- .../form/AttachmentMessageForm.tsx | 2 +- frontend/src/hooks/crud/useUpload.tsx | 8 +-- frontend/src/services/api.class.ts | 6 +-- frontend/src/types/attachment.types.ts | 10 ++-- 27 files changed, 153 insertions(+), 139 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index e22622d3..12a902f3 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -110,7 +110,7 @@ describe('AttachmentController', () => { file: [], }, {} as Request, - { context: 'block_attachment' }, + { resourceRef: 'block_attachment' }, ); await expect(promiseResult).rejects.toThrow( new BadRequestException('No file was selected'), @@ -128,7 +128,7 @@ describe('AttachmentController', () => { { session: { passport: { user: { id: '9'.repeat(24) } } }, } as unknown as Request, - { context: 'block_attachment' }, + { resourceRef: 'block_attachment' }, ); const [name] = attachmentFile.filename.split('.'); expect(attachmentService.create).toHaveBeenCalledWith({ @@ -136,7 +136,7 @@ describe('AttachmentController', () => { type: attachmentFile.mimetype, name: attachmentFile.originalname, location: expect.stringMatching(new RegExp(`^/${name}`)), - context: 'block_attachment', + resourceRef: 'block_attachment', access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), @@ -145,7 +145,7 @@ describe('AttachmentController', () => { [ { ...attachment, - context: 'block_attachment', + resourceRef: 'block_attachment', createdByRef: 'User', createdBy: '9'.repeat(24), }, diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index 8286f31f..278fa7c4 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -67,7 +67,7 @@ export class AttachmentController extends BaseController { async filterCount( @Query( new SearchFilterPipe({ - allowedFields: ['name', 'type', 'context'], + allowedFields: ['name', 'type', 'resourceRef'], }), ) filters?: TFilterQuery, @@ -97,7 +97,7 @@ export class AttachmentController extends BaseController { @Query(PageQueryPipe) pageQuery: PageQueryDto, @Query( new SearchFilterPipe({ - allowedFields: ['name', 'type', 'context'], + allowedFields: ['name', 'type', 'resourceRef'], }), ) filters: TFilterQuery, @@ -130,7 +130,8 @@ export class AttachmentController extends BaseController { async uploadFile( @UploadedFiles() files: { file: Express.Multer.File[] }, @Req() req: Request, - @Query() { context, access = 'public' }: AttachmentContextParamDto, + @Query() + { resourceRef, access = 'public' }: AttachmentContextParamDto, ): Promise { if (!files || !Array.isArray(files?.file) || files.file.length === 0) { throw new BadRequestException('No file was selected'); @@ -149,7 +150,7 @@ export class AttachmentController extends BaseController { name: file.originalname, size: file.size, type: file.mimetype, - context, + resourceRef, access, createdBy: userId, createdByRef: 'User', diff --git a/api/src/attachment/dto/attachment.dto.ts b/api/src/attachment/dto/attachment.dto.ts index cb91bbe5..d5e0f68a 100644 --- a/api/src/attachment/dto/attachment.dto.ts +++ b/api/src/attachment/dto/attachment.dto.ts @@ -25,11 +25,11 @@ import { IsObjectId } from '@/utils/validation-rules/is-object-id'; import { AttachmentAccess, - AttachmentContext, AttachmentCreatedByRef, + AttachmentResourceRef, TAttachmentAccess, - TAttachmentContext, TAttachmentCreatedByRef, + TAttachmentResourceRef, } from '../types'; export class AttachmentMetadataDto { @@ -67,16 +67,16 @@ export class AttachmentMetadataDto { channel?: Partial>; /** - * Attachment context + * Attachment resource reference */ @ApiProperty({ - description: 'Attachment Context', - enum: Object.values(AttachmentContext), + description: 'Attachment Resource Ref', + enum: Object.values(AttachmentResourceRef), }) @IsString() @IsNotEmpty() - @IsIn(Object.values(AttachmentContext)) - context: TAttachmentContext; + @IsIn(Object.values(AttachmentResourceRef)) + resourceRef: TAttachmentResourceRef; /** * Attachment Owner Type @@ -141,13 +141,13 @@ export class AttachmentDownloadDto extends ObjectIdDto { export class AttachmentContextParamDto { @ApiProperty({ - description: 'Attachment Context', - enum: Object.values(AttachmentContext), + description: 'Attachment Resource Reference', + enum: Object.values(AttachmentResourceRef), }) @IsString() - @IsIn(Object.values(AttachmentContext)) + @IsIn(Object.values(AttachmentResourceRef)) @IsNotEmpty() - context: TAttachmentContext; + resourceRef: TAttachmentResourceRef; @ApiPropertyOptional({ description: 'Attachment Access', diff --git a/api/src/attachment/guards/attachment-ability.guard.spec.ts b/api/src/attachment/guards/attachment-ability.guard.spec.ts index 782cb040..75d0a4d5 100644 --- a/api/src/attachment/guards/attachment-ability.guard.spec.ts +++ b/api/src/attachment/guards/attachment-ability.guard.spec.ts @@ -53,9 +53,9 @@ describe('AttachmentGuard', () => { }); describe('canActivate', () => { - it('should allow GET requests with valid context', async () => { + it('should allow GET requests with valid ref', async () => { const mockUser = { roles: ['admin-id'] } as any; - const mockContext = ['user_avatar']; + const mockRef = ['user_avatar']; jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => { return typeof criteria === 'string' || @@ -84,7 +84,7 @@ describe('AttachmentGuard', () => { const mockExecutionContext = { switchToHttp: jest.fn().mockReturnValue({ getRequest: jest.fn().mockReturnValue({ - query: { where: { context: mockContext } }, + query: { where: { resourceRef: mockRef } }, method: 'GET', user: mockUser, }), @@ -95,11 +95,11 @@ describe('AttachmentGuard', () => { expect(result).toBe(true); }); - it('should throw BadRequestException for GET requests with invalid context', async () => { + it('should throw BadRequestException for GET requests with invalid ref', async () => { const mockExecutionContext = { switchToHttp: jest.fn().mockReturnValue({ getRequest: jest.fn().mockReturnValue({ - query: { where: { context: 'invalid_context' } }, + query: { where: { resourceRef: 'invalid_ref' } }, method: 'GET', }), }), @@ -120,7 +120,7 @@ describe('AttachmentGuard', () => { ? Promise.reject('Invalid ID') : Promise.resolve({ id: '9'.repeat(24), - context: `user_avatar`, + resourceRef: `user_avatar`, } as Attachment); }); @@ -162,7 +162,7 @@ describe('AttachmentGuard', () => { expect(result).toBe(true); }); - it('should allow POST requests with valid context', async () => { + it('should allow POST requests with a valid ref', async () => { const mockUser = { roles: ['editor-id'] } as any; jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => { @@ -191,7 +191,7 @@ describe('AttachmentGuard', () => { const mockExecutionContext = { switchToHttp: jest.fn().mockReturnValue({ getRequest: jest.fn().mockReturnValue({ - query: { context: 'block_attachment' }, + query: { resourceRef: 'block_attachment' }, method: 'POST', user: mockUser, }), diff --git a/api/src/attachment/guards/attachment-ability.guard.ts b/api/src/attachment/guards/attachment-ability.guard.ts index 16588b94..cfa1a583 100644 --- a/api/src/attachment/guards/attachment-ability.guard.ts +++ b/api/src/attachment/guards/attachment-ability.guard.ts @@ -26,8 +26,11 @@ 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'; +import { TAttachmentResourceRef } from '../types'; +import { + isAttachmentResourceRef, + isAttachmentResourceRefArray, +} from '../utilities'; @Injectable() export class AttachmentGuard implements CanActivate { @@ -39,9 +42,9 @@ export class AttachmentGuard implements CanActivate { private permissionMap: Record< Action, - Record + Record > = { - // Read attachments by context + // Read attachments by ref [Action.READ]: { setting_attachment: [ ['setting', Action.READ], @@ -62,7 +65,7 @@ export class AttachmentGuard implements CanActivate { ['attachment', Action.READ], ], }, - // Create attachments by context + // Create attachments by ref [Action.CREATE]: { setting_attachment: [ ['setting', Action.UPDATE], @@ -88,7 +91,7 @@ export class AttachmentGuard implements CanActivate { ['attachment', Action.CREATE], ], }, - // Delete attachments by context + // Delete attachments by ref [Action.DELETE]: { setting_attachment: [ ['setting', Action.UPDATE], @@ -156,27 +159,27 @@ export class AttachmentGuard implements CanActivate { } /** - * Checks if the user is authorized to perform a given action on a attachment based on its context and user roles. + * Checks if the user is authorized to perform a given action on a attachment based on the resource reference 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). + * @param resourceRef - The resource ref 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, + resourceRef: TAttachmentResourceRef, ): Promise { if (!action) { throw new TypeError('Invalid action'); } - if (!context) { - throw new TypeError('Invalid context'); + if (!resourceRef) { + throw new TypeError('Invalid resource ref'); } - const permissions = this.permissionMap[action][context]; + const permissions = this.permissionMap[action][resourceRef]; if (!permissions.length) { return false; @@ -214,17 +217,21 @@ export class AttachmentGuard implements CanActivate { throw new NotFoundException('Attachment not found!'); } - return await this.isAuthorized(Action.READ, user, attachment.context); + return await this.isAuthorized( + Action.READ, + user, + attachment.resourceRef, + ); } else if (query.where) { - const { context = [] } = query.where as qs.ParsedQs; + const { resourceRef = [] } = query.where as qs.ParsedQs; - if (!isAttachmentContextArray(context)) { - throw new BadRequestException('Invalid context param'); + if (!isAttachmentResourceRefArray(resourceRef)) { + throw new BadRequestException('Invalid resource ref'); } return ( await Promise.all( - context.map((c) => this.isAuthorized(Action.READ, user, c)), + resourceRef.map((c) => this.isAuthorized(Action.READ, user, c)), ) ).every(Boolean); } else { @@ -233,12 +240,12 @@ export class AttachmentGuard implements CanActivate { } // upload() endpoint case 'POST': { - const { context = '' } = query; - if (!isAttachmentContext(context)) { - throw new BadRequestException('Invalid context param'); + const { resourceRef = '' } = query; + if (!isAttachmentResourceRef(resourceRef)) { + throw new BadRequestException('Invalid resource ref'); } - return await this.isAuthorized(Action.CREATE, user, context); + return await this.isAuthorized(Action.CREATE, user, resourceRef); } // deleteOne() endpoint case 'DELETE': { @@ -252,7 +259,7 @@ export class AttachmentGuard implements CanActivate { return await this.isAuthorized( Action.DELETE, user, - attachment.context, + attachment.resourceRef, ); } else { throw new BadRequestException('Invalid params'); diff --git a/api/src/attachment/mocks/attachment.mock.ts b/api/src/attachment/mocks/attachment.mock.ts index dde89f84..fd54334f 100644 --- a/api/src/attachment/mocks/attachment.mock.ts +++ b/api/src/attachment/mocks/attachment.mock.ts @@ -16,7 +16,7 @@ export const attachment: Attachment = { size: 343370, location: '/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', - context: 'block_attachment', + resourceRef: 'block_attachment', access: 'public', id: '65940d115178607da65c82b6', createdAt: new Date(), @@ -47,7 +47,7 @@ export const attachments: Attachment[] = [ location: '/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', channel: { ['some-channel']: {} }, - context: 'block_attachment', + resourceRef: 'block_attachment', access: 'public', id: '65940d115178607da65c82b7', createdAt: new Date(), @@ -62,7 +62,7 @@ export const attachments: Attachment[] = [ location: '/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png', channel: { ['some-channel']: {} }, - context: 'block_attachment', + resourceRef: 'block_attachment', access: 'public', id: '65940d115178607da65c82b8', createdAt: new Date(), diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index 1a23f370..7d8e6b8e 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -25,11 +25,11 @@ import { import { AttachmentAccess, - AttachmentContext, AttachmentCreatedByRef, + AttachmentResourceRef, TAttachmentAccess, - TAttachmentContext, TAttachmentCreatedByRef, + TAttachmentResourceRef, } from '../types'; import { MIME_REGEX } from '../utilities'; @@ -97,13 +97,13 @@ export class AttachmentStub extends BaseSchema { createdByRef: TAttachmentCreatedByRef; /** - * Context of the attachment + * Resource reference of the attachment */ - @Prop({ type: String, enum: Object.values(AttachmentContext) }) - context: TAttachmentContext; + @Prop({ type: String, enum: Object.values(AttachmentResourceRef) }) + resourceRef: TAttachmentResourceRef; /** - * Context of the attachment + * Access level of the attachment */ @Prop({ type: String, enum: Object.values(AttachmentAccess) }) access: TAttachmentAccess; diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index b81e84b6..c26dea8d 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -30,7 +30,7 @@ import { BaseService } from '@/utils/generics/base-service'; import { AttachmentMetadataDto } from '../dto/attachment.dto'; import { AttachmentRepository } from '../repositories/attachment.repository'; import { Attachment } from '../schemas/attachment.schema'; -import { TAttachmentContext } from '../types'; +import { TAttachmentResourceRef } from '../types'; import { fileExists, generateUniqueFilename, @@ -158,13 +158,13 @@ export class AttachmentService extends BaseService { } /** - * Get the attachment root directory given the context + * Get the attachment root directory given the resource reference * - * @param context The attachment context + * @param ref The attachment resource reference * @returns The root directory path */ - getRootDirByContext(context: TAttachmentContext) { - return context === 'subscriber_avatar' || context === 'user_avatar' + getRootDirByResourceRef(ref: TAttachmentResourceRef) { + return ref === 'subscriber_avatar' || ref === 'user_avatar' ? config.parameters.avatarDir : config.parameters.uploadDir; } @@ -186,7 +186,7 @@ export class AttachmentService extends BaseService { const storedDto = await this.getStoragePlugin()?.store?.(file, metadata); return storedDto ? await this.create(storedDto) : null; } else { - const rootDir = this.getRootDirByContext(metadata.context); + const rootDir = this.getRootDirByResourceRef(metadata.resourceRef); const uniqueFilename = generateUniqueFilename(metadata.name); const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename))); @@ -244,7 +244,7 @@ export class AttachmentService extends BaseService { return streamableFile; } else { - const rootDir = this.getRootDirByContext(attachment.context); + const rootDir = this.getRootDirByResourceRef(attachment.resourceRef); const path = resolve(join(rootDir, attachment.location)); if (!fileExists(path)) { diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts index cf3f0ce3..267410bb 100644 --- a/api/src/attachment/types/index.ts +++ b/api/src/attachment/types/index.ts @@ -20,10 +20,10 @@ export enum AttachmentCreatedByRef { export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; /** - * Defines the various contexts in which an attachment can exist. - * These contexts influence how the attachment is uploaded, stored, and accessed: + * Defines the various resource references in which an attachment can exist. + * These resource references influence how the attachment is uploaded, stored, and accessed: */ -export enum AttachmentContext { +export enum AttachmentResourceRef { 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. @@ -32,7 +32,7 @@ export enum AttachmentContext { MessageAttachment = 'message_attachment', // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.; } -export type TAttachmentContext = `${AttachmentContext}`; +export type TAttachmentResourceRef = `${AttachmentResourceRef}`; export enum AttachmentAccess { Public = 'public', diff --git a/api/src/attachment/utilities/index.ts b/api/src/attachment/utilities/index.ts index 99c1c580..79649f76 100644 --- a/api/src/attachment/utilities/index.ts +++ b/api/src/attachment/utilities/index.ts @@ -15,7 +15,7 @@ import { v4 as uuidv4 } from 'uuid'; import { config } from '@/config'; -import { AttachmentContext, TAttachmentContext } from '../types'; +import { AttachmentResourceRef, TAttachmentResourceRef } from '../types'; export const MIME_REGEX = /^[a-z-]+\/[0-9a-z\-.]+$/gm; @@ -82,27 +82,30 @@ export const generateUniqueFilename = (originalname: string) => { }; /** - * Checks if the given context is of type TAttachmentContext. + * Checks if the given ref is of type TAttachmentResourceRef. * - * @param ctx - The context to check. - * @returns True if the context is of type TAttachmentContext, otherwise false. + * @param ref - The ref to check. + * @returns True if the ref is of type TAttachmentResourceRef, otherwise false. */ -export const isAttachmentContext = (ctx: any): ctx is TAttachmentContext => { - return Object.values(AttachmentContext).includes(ctx); +export const isAttachmentResourceRef = ( + ref: any, +): ref is TAttachmentResourceRef => { + return Object.values(AttachmentResourceRef).includes(ref); }; +AttachmentResourceRef; /** - * Checks if the given list is an array of TAttachmentContext. + * Checks if the given list is an array of TAttachmentResourceRef. * - * @param ctxList - The list of contexts to check. - * @returns True if all items in the list are of type TAttachmentContext, otherwise false. + * @param refList - The list of resource references to check. + * @returns True if all items in the list are of type TAttachmentResourceRef, otherwise false. */ -export const isAttachmentContextArray = ( - ctxList: any, -): ctxList is TAttachmentContext[] => { +export const isAttachmentResourceRefArray = ( + refList: any, +): refList is TAttachmentResourceRef[] => { return ( - Array.isArray(ctxList) && - ctxList.length > 0 && - ctxList.every(isAttachmentContext) + Array.isArray(refList) && + refList.length > 0 && + refList.every(isAttachmentResourceRef) ); }; diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 53ccdefc..f524d819 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -258,7 +258,7 @@ export default abstract class ChannelHandler< name: `${name ? `${name}-` : ''}${uuidv4()}.${mime.extension(type)}`, type, size, - context: 'message_attachment', + resourceRef: 'message_attachment', access: 'private', createdByRef: 'Subscriber', createdBy: subscriber.id, diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index e825cac4..c23b6d06 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -88,7 +88,7 @@ const attachment: Attachment = { id: 'any-channel-attachment-id', }, }, - context: 'block_attachment', + resourceRef: 'block_attachment', access: 'public', createdByRef: 'User', createdBy: null, diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 72b63a1a..25090cc5 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -280,7 +280,7 @@ export class ChatService { name: `avatar-${uuidv4()}.${extension}`, size, type, - context: 'subscriber_avatar', + resourceRef: 'subscriber_avatar', access: 'private', createdByRef: 'Subscriber', createdBy: subscriber.id, diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 5cc56eb0..eb70e8aa 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -625,7 +625,7 @@ export default abstract class BaseWebChannelHandler< name: data.name, size: Buffer.byteLength(data.file), type: data.type, - context: 'message_attachment', + resourceRef: 'message_attachment', access: 'private', createdByRef: 'Subscriber', createdBy: req.session?.web?.profile?.id, @@ -692,7 +692,7 @@ export default abstract class BaseWebChannelHandler< name: file.originalname, size: file.size, type: file.mimetype, - context: 'message_attachment', + resourceRef: 'message_attachment', access: 'private', createdByRef: 'Subscriber', createdBy: req.session.web.profile?.id, diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index dfcebfa0..e9c039e8 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -15,7 +15,10 @@ import { v4 as uuidv4 } from 'uuid'; import attachmentSchema, { Attachment, } from '@/attachment/schemas/attachment.schema'; -import { AttachmentContext, AttachmentCreatedByRef } from '@/attachment/types'; +import { + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import blockSchema, { Block } from '@/chat/schemas/block.schema'; import messageSchema, { Message } from '@/chat/schemas/message.schema'; import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; @@ -79,7 +82,7 @@ const populateBlockAttachments = async ({ logger }: MigrationServices) => { { _id: attachmentId }, { $set: { - context: AttachmentContext.BlockAttachment, + resourceRef: AttachmentResourceRef.BlockAttachment, access: 'public', createdByRef: AttachmentCreatedByRef.User, createdBy: user._id, @@ -103,7 +106,7 @@ const populateBlockAttachments = async ({ logger }: MigrationServices) => { }; /** - * Updates setting attachment documents to populate new attributes (context, createdBy, createdByRef) + * Updates setting attachment documents to populate new attributes (resourceRef, createdBy, createdByRef) * * @returns Resolves when the migration process is complete. */ @@ -130,7 +133,7 @@ const populateSettingAttachments = async ({ logger }: MigrationServices) => { { _id: setting.value }, { $set: { - context: AttachmentContext.SettingAttachment, + resourceRef: AttachmentResourceRef.SettingAttachment, access: 'public', createdByRef: AttachmentCreatedByRef.User, createdBy: user._id, @@ -148,7 +151,7 @@ const populateSettingAttachments = async ({ logger }: MigrationServices) => { }; /** - * Updates user attachment documents to populate new attributes (context, createdBy, createdByRef) + * Updates user attachment documents to populate new attributes (resourceRef, createdBy, createdByRef) * * @returns Resolves when the migration process is complete. */ @@ -169,7 +172,7 @@ const populateUserAvatars = async ({ logger }: MigrationServices) => { { _id: user.avatar }, { $set: { - context: AttachmentContext.UserAvatar, + resourceRef: AttachmentResourceRef.UserAvatar, access: 'private', createdByRef: AttachmentCreatedByRef.User, createdBy: user._id, @@ -187,7 +190,7 @@ const populateUserAvatars = async ({ logger }: MigrationServices) => { /** * Updates subscriber documents with their corresponding avatar attachments, - * populate new attributes (context, createdBy, createdByRef) and moves avatar files to a new directory. + * populate new attributes (resourceRef, createdBy, createdByRef) and moves avatar files to a new directory. * * @returns Resolves when the migration process is complete. */ @@ -231,7 +234,7 @@ const populateSubscriberAvatars = async ({ logger }: MigrationServices) => { { _id: attachment._id }, { $set: { - context: AttachmentContext.SubscriberAvatar, + resourceRef: AttachmentResourceRef.SubscriberAvatar, access: 'private', createdByRef: AttachmentCreatedByRef.Subscriber, createdBy: subscriber._id, @@ -353,18 +356,18 @@ const undoPopulateAttachments = async ({ logger }: MigrationServices) => { try { const result = await AttachmentModel.updateMany( { - context: { + resourceRef: { $in: [ - AttachmentContext.BlockAttachment, - AttachmentContext.SettingAttachment, - AttachmentContext.UserAvatar, - AttachmentContext.SubscriberAvatar, + AttachmentResourceRef.BlockAttachment, + AttachmentResourceRef.SettingAttachment, + AttachmentResourceRef.UserAvatar, + AttachmentResourceRef.SubscriberAvatar, ], }, }, { $unset: { - context: '', + resourceRef: '', access: '', createdByRef: '', createdBy: '', @@ -373,11 +376,11 @@ const undoPopulateAttachments = async ({ logger }: MigrationServices) => { ); logger.log( - `Successfully reverted attributes for ${result.modifiedCount} attachments with context 'setting_attachment'`, + `Successfully reverted attributes for ${result.modifiedCount} attachments with ref 'setting_attachment'`, ); } catch (error) { logger.error( - `Failed to revert attributes for attachments with context 'setting_attachment': ${error.message}`, + `Failed to revert attributes for attachments with ref 'setting_attachment': ${error.message}`, ); } }; @@ -645,7 +648,7 @@ const migrateAndPopulateAttachmentMessages = async ({ await attachmentService.updateOne( msg.message.attachment.payload.attachment_id as string, { - context: 'message_attachment', + resourceRef: 'message_attachment', access: 'private', createdByRef: msg.sender ? 'Subscriber' : 'User', createdBy: msg.sender ? msg.sender : adminUser.id, @@ -678,7 +681,7 @@ const migrateAndPopulateAttachmentMessages = async ({ size: fileBuffer.length, type: response.headers['content-type'], channel: {}, - context: 'message_attachment', + resourceRef: 'message_attachment', access: msg.sender ? 'private' : 'public', createdBy: msg.sender ? msg.sender : adminUser.id, createdByRef: msg.sender ? 'Subscriber' : 'User', diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index a322ceff..1af1674b 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -294,7 +294,7 @@ export class ReadWriteUserController extends ReadOnlyUserController { name: avatarFile.originalname, size: avatarFile.size, type: avatarFile.mimetype, - context: 'user_avatar', + resourceRef: 'user_avatar', access: 'private', createdByRef: 'User', createdBy: req.user.id, diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index cfbe046f..cece4802 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -22,7 +22,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '1', }, }, - context: 'content_attachment', + resourceRef: 'content_attachment', access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), @@ -37,7 +37,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '2', }, }, - context: 'content_attachment', + resourceRef: 'content_attachment', access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), diff --git a/frontend/src/app-components/attachment/AttachmentInput.tsx b/frontend/src/app-components/attachment/AttachmentInput.tsx index 67edd5da..7ed77c6d 100644 --- a/frontend/src/app-components/attachment/AttachmentInput.tsx +++ b/frontend/src/app-components/attachment/AttachmentInput.tsx @@ -13,7 +13,7 @@ import { forwardRef } from "react"; import { useGet } from "@/hooks/crud/useGet"; import { useHasPermission } from "@/hooks/useHasPermission"; import { EntityType } from "@/services/types"; -import { IAttachment, TAttachmentContext } from "@/types/attachment.types"; +import { IAttachment, TAttachmentResourceRef } from "@/types/attachment.types"; import { PermissionAction } from "@/types/permission.types"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -29,7 +29,7 @@ type AttachmentThumbnailProps = { onChange?: (id: string | null, mimeType: string | null) => void; error?: boolean; helperText?: string; - context: TAttachmentContext; + resourceRef: TAttachmentResourceRef; }; const AttachmentInput = forwardRef( @@ -44,7 +44,7 @@ const AttachmentInput = forwardRef( onChange, error, helperText, - context, + resourceRef, }, ref, ) => { @@ -84,7 +84,7 @@ const AttachmentInput = forwardRef( accept={accept} enableMediaLibrary={enableMediaLibrary} onChange={handleChange} - context={context} + resourceRef={resourceRef} /> ) : null} {helperText ? ( diff --git a/frontend/src/app-components/attachment/AttachmentUploader.tsx b/frontend/src/app-components/attachment/AttachmentUploader.tsx index a2d662c1..112f111c 100644 --- a/frontend/src/app-components/attachment/AttachmentUploader.tsx +++ b/frontend/src/app-components/attachment/AttachmentUploader.tsx @@ -17,7 +17,7 @@ import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType } from "@/services/types"; -import { IAttachment, TAttachmentContext } from "@/types/attachment.types"; +import { IAttachment, TAttachmentResourceRef } from "@/types/attachment.types"; import { AttachmentDialog } from "./AttachmentDialog"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -68,7 +68,7 @@ export type FileUploadProps = { enableMediaLibrary?: boolean; onChange?: (data?: IAttachment | null) => void; onUploadComplete?: () => void; - context: TAttachmentContext; + resourceRef: TAttachmentResourceRef; }; const AttachmentUploader: FC = ({ @@ -76,7 +76,7 @@ const AttachmentUploader: FC = ({ enableMediaLibrary, onChange, onUploadComplete, - context, + resourceRef, }) => { const [attachment, setAttachment] = useState( undefined, @@ -119,7 +119,7 @@ const AttachmentUploader: FC = ({ return; } - uploadAttachment({ file, context }); + uploadAttachment({ file, resourceRef }); } }; const handleChange = (event: ChangeEvent) => { diff --git a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx index 80b76283..11a3e069 100644 --- a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx +++ b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx @@ -12,7 +12,7 @@ import { forwardRef, useState } from "react"; import { useHasPermission } from "@/hooks/useHasPermission"; import { EntityType } from "@/services/types"; -import { IAttachment, TAttachmentContext } from "@/types/attachment.types"; +import { IAttachment, TAttachmentResourceRef } from "@/types/attachment.types"; import { PermissionAction } from "@/types/permission.types"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -28,7 +28,7 @@ type MultipleAttachmentInputProps = { onChange?: (ids: string[]) => void; error?: boolean; helperText?: string; - context: TAttachmentContext; + resourceRef: TAttachmentResourceRef; }; const MultipleAttachmentInput = forwardRef< @@ -46,7 +46,7 @@ const MultipleAttachmentInput = forwardRef< onChange, error, helperText, - context, + resourceRef, }, ref, ) => { @@ -109,7 +109,7 @@ const MultipleAttachmentInput = forwardRef< accept={accept} enableMediaLibrary={enableMediaLibrary} onChange={(attachment) => handleChange(attachment)} - context={context} + resourceRef={resourceRef} /> )} {helperText && ( diff --git a/frontend/src/components/contents/ContentDialog.tsx b/frontend/src/components/contents/ContentDialog.tsx index 885dfe73..761e9eec 100644 --- a/frontend/src/components/contents/ContentDialog.tsx +++ b/frontend/src/components/contents/ContentDialog.tsx @@ -116,7 +116,7 @@ const ContentFieldInput: React.FC = ({ value={field.value?.payload?.id} accept={MIME_TYPES["images"].join(",")} format="full" - context="content_attachment" + resourceRef="content_attachment" /> ); default: diff --git a/frontend/src/components/contents/ContentImportDialog.tsx b/frontend/src/components/contents/ContentImportDialog.tsx index 61855149..ba1bece0 100644 --- a/frontend/src/components/contents/ContentImportDialog.tsx +++ b/frontend/src/components/contents/ContentImportDialog.tsx @@ -81,7 +81,7 @@ export const ContentImportDialog: FC = ({ }} label="" value={attachmentId} - context="content_attachment" + resourceRef="content_attachment" /> diff --git a/frontend/src/components/settings/SettingInput.tsx b/frontend/src/components/settings/SettingInput.tsx index fd41381c..174f3eea 100644 --- a/frontend/src/components/settings/SettingInput.tsx +++ b/frontend/src/components/settings/SettingInput.tsx @@ -186,7 +186,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} - context="setting_attachment" + resourceRef="setting_attachment" /> ); @@ -199,7 +199,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} - context="setting_attachment" + resourceRef="setting_attachment" /> ); default: diff --git a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx index 0579d9dd..a5b12b2e 100644 --- a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx @@ -69,7 +69,7 @@ const AttachmentMessageForm = () => { }, }); }} - context="block_attachment" + resourceRef="block_attachment" /> ); }} diff --git a/frontend/src/hooks/crud/useUpload.tsx b/frontend/src/hooks/crud/useUpload.tsx index 342645a9..5f5a5e44 100644 --- a/frontend/src/hooks/crud/useUpload.tsx +++ b/frontend/src/hooks/crud/useUpload.tsx @@ -9,7 +9,7 @@ import { useMutation, useQueryClient } from "react-query"; import { QueryType, TMutationOptions } from "@/services/types"; -import { TAttachmentContext } from "@/types/attachment.types"; +import { TAttachmentResourceRef } from "@/types/attachment.types"; import { IBaseSchema, IDynamicProps, TType } from "@/types/base.types"; import { useEntityApiClient } from "../useApiClient"; @@ -27,7 +27,7 @@ export const useUpload = < TMutationOptions< TBasic, Error, - { file: File; context: TAttachmentContext }, + { file: File; resourceRef: TAttachmentResourceRef }, TBasic >, "mutationFn" | "mutationKey" @@ -39,8 +39,8 @@ export const useUpload = < const { invalidate = true, ...otherOptions } = options || {}; return useMutation({ - mutationFn: async ({ file, context }) => { - const data = await api.upload(file, context); + mutationFn: async ({ file, resourceRef }) => { + const data = await api.upload(file, resourceRef); const { entities, result } = normalizeAndCache(data); // Invalidate all counts & collections diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index dc062504..ddf14d0c 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -9,7 +9,7 @@ import { AxiosInstance, AxiosResponse } from "axios"; -import { TAttachmentContext } from "@/types/attachment.types"; +import { TAttachmentResourceRef } from "@/types/attachment.types"; import { ILoginAttributes } from "@/types/auth/login.types"; import { IUserPermissions } from "@/types/auth/permission.types"; import { StatsType } from "@/types/bot-stat.types"; @@ -302,7 +302,7 @@ export class EntityApiClient extends ApiClient { return data; } - async upload(file: File, context?: TAttachmentContext) { + async upload(file: File, resourceRef?: TAttachmentResourceRef) { const { _csrf } = await this.getCsrf(); const formData = new FormData(); @@ -314,7 +314,7 @@ export class EntityApiClient extends ApiClient { FormData >( `${ROUTES[this.type]}/upload?_csrf=${_csrf}${ - context ? `&context=${context}` : "" + resourceRef ? `&resourceRef=${resourceRef}` : "" }`, formData, { diff --git a/frontend/src/types/attachment.types.ts b/frontend/src/types/attachment.types.ts index aafd5aac..213ea268 100644 --- a/frontend/src/types/attachment.types.ts +++ b/frontend/src/types/attachment.types.ts @@ -25,10 +25,10 @@ export enum AttachmentCreatedByRef { export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; /** - * Defines the various contexts in which an attachment can exist. - * These contexts influence how the attachment is uploaded, stored, and accessed: + * Defines the various resource references in which an attachment can exist. + * These references influence how the attachment is uploaded, stored, and accessed: */ -export enum AttachmentContext { +export enum AttachmentResourceRef { 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. @@ -37,7 +37,7 @@ export enum AttachmentContext { MessageAttachment = "message_attachment", // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.; } -export type TAttachmentContext = `${AttachmentContext}`; +export type TAttachmentResourceRef = `${AttachmentResourceRef}`; export interface IAttachmentAttributes { name: string; @@ -46,7 +46,7 @@ export interface IAttachmentAttributes { location: string; url: string; channel?: Record; - context: TAttachmentContext; + resourceRef: TAttachmentResourceRef; createdByRef: TAttachmentCreatedByRef; createdBy: string | null; } From c27f37a6e6af4bbeae772943ef1a10ea88469e13 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 16 Jan 2025 10:14:50 +0100 Subject: [PATCH 14/23] feat: rename enum instead of string --- .../controllers/attachment.controller.spec.ts | 9 ++-- .../guards/attachment-ability.guard.spec.ts | 7 +-- .../guards/attachment-ability.guard.ts | 52 +++++++++---------- api/src/attachment/mocks/attachment.mock.ts | 7 +-- .../attachment/services/attachment.service.ts | 5 +- api/src/attachment/types/index.ts | 12 ++--- api/src/channel/lib/Handler.ts | 4 +- api/src/channel/lib/__test__/common.mock.ts | 3 +- api/src/chat/services/chat.service.ts | 3 +- .../channels/web/base-web-channel.ts | 5 +- .../1735836154221-v-2-2-0.migration.ts | 45 +++++++++++++--- api/src/user/controllers/user.controller.ts | 3 +- api/src/utils/test/fixtures/attachment.ts | 5 +- .../src/components/contents/ContentDialog.tsx | 3 +- .../contents/ContentImportDialog.tsx | 3 +- .../src/components/settings/SettingInput.tsx | 5 +- .../form/AttachmentMessageForm.tsx | 3 +- frontend/src/types/attachment.types.ts | 12 ++--- 18 files changed, 116 insertions(+), 70 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index 12a902f3..34b19ef0 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -39,6 +39,7 @@ import { attachment, attachmentFile } from '../mocks/attachment.mock'; import { AttachmentRepository } from '../repositories/attachment.repository'; import { Attachment, AttachmentModel } from '../schemas/attachment.schema'; import { AttachmentService } from '../services/attachment.service'; +import { AttachmentResourceRef } from '../types'; import { AttachmentController } from './attachment.controller'; @@ -110,7 +111,7 @@ describe('AttachmentController', () => { file: [], }, {} as Request, - { resourceRef: 'block_attachment' }, + { resourceRef: AttachmentResourceRef.BlockAttachment }, ); await expect(promiseResult).rejects.toThrow( new BadRequestException('No file was selected'), @@ -128,7 +129,7 @@ describe('AttachmentController', () => { { session: { passport: { user: { id: '9'.repeat(24) } } }, } as unknown as Request, - { resourceRef: 'block_attachment' }, + { resourceRef: AttachmentResourceRef.BlockAttachment }, ); const [name] = attachmentFile.filename.split('.'); expect(attachmentService.create).toHaveBeenCalledWith({ @@ -136,7 +137,7 @@ describe('AttachmentController', () => { type: attachmentFile.mimetype, name: attachmentFile.originalname, location: expect.stringMatching(new RegExp(`^/${name}`)), - resourceRef: 'block_attachment', + resourceRef: AttachmentResourceRef.BlockAttachment, access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), @@ -145,7 +146,7 @@ describe('AttachmentController', () => { [ { ...attachment, - resourceRef: 'block_attachment', + resourceRef: AttachmentResourceRef.BlockAttachment, createdByRef: 'User', createdBy: '9'.repeat(24), }, diff --git a/api/src/attachment/guards/attachment-ability.guard.spec.ts b/api/src/attachment/guards/attachment-ability.guard.spec.ts index 75d0a4d5..395163ae 100644 --- a/api/src/attachment/guards/attachment-ability.guard.spec.ts +++ b/api/src/attachment/guards/attachment-ability.guard.spec.ts @@ -18,6 +18,7 @@ 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 { AttachmentResourceRef } from '../types'; import { AttachmentGuard } from './attachment-ability.guard'; @@ -55,7 +56,7 @@ describe('AttachmentGuard', () => { describe('canActivate', () => { it('should allow GET requests with valid ref', async () => { const mockUser = { roles: ['admin-id'] } as any; - const mockRef = ['user_avatar']; + const mockRef = [AttachmentResourceRef.UserAvatar]; jest.spyOn(modelService, 'findOne').mockImplementation((criteria) => { return typeof criteria === 'string' || @@ -120,7 +121,7 @@ describe('AttachmentGuard', () => { ? Promise.reject('Invalid ID') : Promise.resolve({ id: '9'.repeat(24), - resourceRef: `user_avatar`, + resourceRef: AttachmentResourceRef.UserAvatar, } as Attachment); }); @@ -191,7 +192,7 @@ describe('AttachmentGuard', () => { const mockExecutionContext = { switchToHttp: jest.fn().mockReturnValue({ getRequest: jest.fn().mockReturnValue({ - query: { resourceRef: 'block_attachment' }, + query: { resourceRef: AttachmentResourceRef.BlockAttachment }, method: 'POST', user: mockUser, }), diff --git a/api/src/attachment/guards/attachment-ability.guard.ts b/api/src/attachment/guards/attachment-ability.guard.ts index cfa1a583..be5deea3 100644 --- a/api/src/attachment/guards/attachment-ability.guard.ts +++ b/api/src/attachment/guards/attachment-ability.guard.ts @@ -26,7 +26,7 @@ import { Action } from '@/user/types/action.type'; import { TModel } from '@/user/types/model.type'; import { AttachmentService } from '../services/attachment.service'; -import { TAttachmentResourceRef } from '../types'; +import { AttachmentResourceRef, TAttachmentResourceRef } from '../types'; import { isAttachmentResourceRef, isAttachmentResourceRefArray, @@ -46,46 +46,46 @@ export class AttachmentGuard implements CanActivate { > = { // Read attachments by ref [Action.READ]: { - setting_attachment: [ + [AttachmentResourceRef.SettingAttachment]: [ ['setting', Action.READ], ['attachment', Action.READ], ], - user_avatar: [['user', Action.READ]], - block_attachment: [ + [AttachmentResourceRef.UserAvatar]: [['user', Action.READ]], + [AttachmentResourceRef.BlockAttachment]: [ ['block', Action.READ], ['attachment', Action.READ], ], - content_attachment: [ + [AttachmentResourceRef.ContentAttachment]: [ ['content', Action.READ], ['attachment', Action.READ], ], - subscriber_avatar: [['subscriber', Action.READ]], - message_attachment: [ + [AttachmentResourceRef.SubscriberAvatar]: [['subscriber', Action.READ]], + [AttachmentResourceRef.MessageAttachment]: [ ['message', Action.READ], ['attachment', Action.READ], ], }, // Create attachments by ref [Action.CREATE]: { - setting_attachment: [ + [AttachmentResourceRef.SettingAttachment]: [ ['setting', Action.UPDATE], ['attachment', Action.CREATE], ], - user_avatar: [ + [AttachmentResourceRef.UserAvatar]: [ // Not authorized, done via /user/:id/edit endpoint ], - block_attachment: [ + [AttachmentResourceRef.BlockAttachment]: [ ['block', Action.UPDATE], ['attachment', Action.CREATE], ], - content_attachment: [ + [AttachmentResourceRef.ContentAttachment]: [ ['content', Action.UPDATE], ['attachment', Action.CREATE], ], - subscriber_avatar: [ + [AttachmentResourceRef.SubscriberAvatar]: [ // Not authorized, done programmatically by the channel ], - message_attachment: [ + [AttachmentResourceRef.MessageAttachment]: [ // Unless we're in case of a handover, done programmatically by the channel ['message', Action.CREATE], ['attachment', Action.CREATE], @@ -93,36 +93,36 @@ export class AttachmentGuard implements CanActivate { }, // Delete attachments by ref [Action.DELETE]: { - setting_attachment: [ + [AttachmentResourceRef.SettingAttachment]: [ ['setting', Action.UPDATE], ['attachment', Action.DELETE], ], - user_avatar: [ + [AttachmentResourceRef.UserAvatar]: [ // Not authorized ], - block_attachment: [ + [AttachmentResourceRef.BlockAttachment]: [ ['block', Action.UPDATE], ['attachment', Action.DELETE], ], - content_attachment: [ + [AttachmentResourceRef.ContentAttachment]: [ ['content', Action.UPDATE], ['attachment', Action.DELETE], ], - subscriber_avatar: [ + [AttachmentResourceRef.SubscriberAvatar]: [ // Not authorized, done programmatically by the channel ], - message_attachment: [ + [AttachmentResourceRef.MessageAttachment]: [ // Not authorized ], }, // Update attachments is not possible [Action.UPDATE]: { - setting_attachment: [], - user_avatar: [], - block_attachment: [], - content_attachment: [], - subscriber_avatar: [], - message_attachment: [], + [AttachmentResourceRef.SettingAttachment]: [], + [AttachmentResourceRef.UserAvatar]: [], + [AttachmentResourceRef.BlockAttachment]: [], + [AttachmentResourceRef.ContentAttachment]: [], + [AttachmentResourceRef.SubscriberAvatar]: [], + [AttachmentResourceRef.MessageAttachment]: [], }, }; @@ -163,7 +163,7 @@ export class AttachmentGuard implements CanActivate { * * @param action - The action on the attachment. * @param user - The current user. - * @param resourceRef - The resource ref of the attachment (e.g., user_avatar, setting_attachment). + * @param resourceRef - The resource ref of the attachment (e.g., [AttachmentResourceRef.UserAvatar], [AttachmentResourceRef.SettingAttachment]). * @returns A promise that resolves to `true` if the user has the required upload permission, otherwise `false`. */ private async isAuthorized( diff --git a/api/src/attachment/mocks/attachment.mock.ts b/api/src/attachment/mocks/attachment.mock.ts index fd54334f..cbc885b8 100644 --- a/api/src/attachment/mocks/attachment.mock.ts +++ b/api/src/attachment/mocks/attachment.mock.ts @@ -9,6 +9,7 @@ import { Stream } from 'node:stream'; import { Attachment } from '../schemas/attachment.schema'; +import { AttachmentResourceRef } from '../types'; export const attachment: Attachment = { name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', @@ -16,7 +17,7 @@ export const attachment: Attachment = { size: 343370, location: '/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', - resourceRef: 'block_attachment', + resourceRef: AttachmentResourceRef.BlockAttachment, access: 'public', id: '65940d115178607da65c82b6', createdAt: new Date(), @@ -47,7 +48,7 @@ export const attachments: Attachment[] = [ location: '/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', channel: { ['some-channel']: {} }, - resourceRef: 'block_attachment', + resourceRef: AttachmentResourceRef.BlockAttachment, access: 'public', id: '65940d115178607da65c82b7', createdAt: new Date(), @@ -62,7 +63,7 @@ export const attachments: Attachment[] = [ location: '/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png', channel: { ['some-channel']: {} }, - resourceRef: 'block_attachment', + resourceRef: AttachmentResourceRef.BlockAttachment, access: 'public', id: '65940d115178607da65c82b8', createdAt: new Date(), diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index c26dea8d..f4721b2b 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -30,7 +30,7 @@ import { BaseService } from '@/utils/generics/base-service'; import { AttachmentMetadataDto } from '../dto/attachment.dto'; import { AttachmentRepository } from '../repositories/attachment.repository'; import { Attachment } from '../schemas/attachment.schema'; -import { TAttachmentResourceRef } from '../types'; +import { AttachmentResourceRef, TAttachmentResourceRef } from '../types'; import { fileExists, generateUniqueFilename, @@ -164,7 +164,8 @@ export class AttachmentService extends BaseService { * @returns The root directory path */ getRootDirByResourceRef(ref: TAttachmentResourceRef) { - return ref === 'subscriber_avatar' || ref === 'user_avatar' + return ref === AttachmentResourceRef.SubscriberAvatar || + ref === AttachmentResourceRef.UserAvatar ? config.parameters.avatarDir : config.parameters.uploadDir; } diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts index 267410bb..ae2ae728 100644 --- a/api/src/attachment/types/index.ts +++ b/api/src/attachment/types/index.ts @@ -24,12 +24,12 @@ export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; * These resource references influence how the attachment is uploaded, stored, and accessed: */ export enum AttachmentResourceRef { - 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.; + SettingAttachment = 'Setting', // Attachments related to app settings, restricted to users with specific permissions. + UserAvatar = 'User', // Avatar files for users, only the current user can upload, accessible to those with appropriate permissions. + SubscriberAvatar = 'Subscriber', // Avatar files for subscribers, uploaded programmatically, accessible to authorized users. + BlockAttachment = 'Block', // Files sent by the bot, public or private based on the channel and user authentication. + ContentAttachment = 'Content', // Files in the knowledge base, usually public but could vary based on specific needs. + MessageAttachment = 'Message', // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.; } export type TAttachmentResourceRef = `${AttachmentResourceRef}`; diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index f524d819..d1525758 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -22,7 +22,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { AttachmentFile } from '@/attachment/types'; +import { AttachmentFile, AttachmentResourceRef } from '@/attachment/types'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; import { AttachmentRef } from '@/chat/schemas/types/attachment'; import { @@ -258,7 +258,7 @@ export default abstract class ChannelHandler< name: `${name ? `${name}-` : ''}${uuidv4()}.${mime.extension(type)}`, type, size, - resourceRef: 'message_attachment', + resourceRef: AttachmentResourceRef.MessageAttachment, access: 'private', createdByRef: 'Subscriber', createdBy: subscriber.id, diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index c23b6d06..0f6f04a8 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -7,6 +7,7 @@ */ import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { AttachmentResourceRef } from '@/attachment/types'; import { ButtonType } from '@/chat/schemas/types/button'; import { FileType, @@ -88,7 +89,7 @@ const attachment: Attachment = { id: 'any-channel-attachment-id', }, }, - resourceRef: 'block_attachment', + resourceRef: AttachmentResourceRef.BlockAttachment, access: 'public', createdByRef: 'User', createdBy: null, diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 25090cc5..78ef8866 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -12,6 +12,7 @@ import mime from 'mime'; import { v4 as uuidv4 } from 'uuid'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { AttachmentResourceRef } from '@/attachment/types'; import EventWrapper from '@/channel/lib/EventWrapper'; import { config } from '@/config'; import { HelperService } from '@/helper/helper.service'; @@ -280,7 +281,7 @@ export class ChatService { name: `avatar-${uuidv4()}.${extension}`, size, type, - resourceRef: 'subscriber_avatar', + resourceRef: AttachmentResourceRef.SubscriberAvatar, access: 'private', createdByRef: 'Subscriber', createdBy: subscriber.id, diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index eb70e8aa..454a2dd5 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -15,6 +15,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { AttachmentResourceRef } from '@/attachment/types'; import { ChannelService } from '@/channel/channel.service'; import ChannelHandler from '@/channel/lib/Handler'; import { ChannelName } from '@/channel/types'; @@ -625,7 +626,7 @@ export default abstract class BaseWebChannelHandler< name: data.name, size: Buffer.byteLength(data.file), type: data.type, - resourceRef: 'message_attachment', + resourceRef: AttachmentResourceRef.MessageAttachment, access: 'private', createdByRef: 'Subscriber', createdBy: req.session?.web?.profile?.id, @@ -692,7 +693,7 @@ export default abstract class BaseWebChannelHandler< name: file.originalname, size: file.size, type: file.mimetype, - resourceRef: 'message_attachment', + resourceRef: AttachmentResourceRef.MessageAttachment, access: 'private', createdByRef: 'Subscriber', createdBy: req.session.web.profile?.id, diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index e9c039e8..b94a68af 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -362,6 +362,8 @@ const undoPopulateAttachments = async ({ logger }: MigrationServices) => { AttachmentResourceRef.SettingAttachment, AttachmentResourceRef.UserAvatar, AttachmentResourceRef.SubscriberAvatar, + AttachmentResourceRef.ContentAttachment, + AttachmentResourceRef.MessageAttachment, ], }, }, @@ -376,11 +378,11 @@ const undoPopulateAttachments = async ({ logger }: MigrationServices) => { ); logger.log( - `Successfully reverted attributes for ${result.modifiedCount} attachments with ref 'setting_attachment'`, + `Successfully reverted attributes for ${result.modifiedCount} attachments with ref AttachmentResourceRef.SettingAttachment`, ); } catch (error) { logger.error( - `Failed to revert attributes for attachments with ref 'setting_attachment': ${error.message}`, + `Failed to revert attributes for attachments with ref AttachmentResourceRef.SettingAttachment: ${error.message}`, ); } }; @@ -552,7 +554,7 @@ const buildRenameAttributeCallback = }; /** - * Traverses an content document to search for any attachment object + * Traverses a content document to search for any attachment object * @param obj * @param callback * @returns @@ -584,7 +586,14 @@ const migrateAttachmentContents = async ( const updateField = action === MigrationAction.UP ? 'id' : 'attachment_id'; const unsetField = action === MigrationAction.UP ? 'attachment_id' : 'id'; const ContentModel = mongoose.model(Content.name, contentSchema); - // Find blocks where "message.attachment" exists + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + + const adminUser = await getAdminUser(); + + // Process all contents const cursor = ContentModel.find({}).cursor(); for await (const content of cursor) { @@ -594,6 +603,30 @@ const migrateAttachmentContents = async ( buildRenameAttributeCallback(unsetField, updateField), ); + for (const key in content.dynamicFields) { + if ( + content.dynamicFields[key] && + typeof content.dynamicFields[key] === 'object' && + 'payload' in content.dynamicFields[key] && + 'id' in content.dynamicFields[key].payload && + content.dynamicFields[key].payload.id + ) { + await AttachmentModel.updateOne( + { + _id: content.dynamicFields[key].payload.id, + }, + { + $set: { + resourceRef: AttachmentResourceRef.ContentAttachment, + createdBy: adminUser.id, + createdByRef: 'User', + access: 'public', + }, + }, + ); + } + } + await ContentModel.replaceOne({ _id: content._id }, content); } catch (error) { logger.error(`Failed to update content ${content._id}: ${error.message}`); @@ -648,7 +681,7 @@ const migrateAndPopulateAttachmentMessages = async ({ await attachmentService.updateOne( msg.message.attachment.payload.attachment_id as string, { - resourceRef: 'message_attachment', + resourceRef: AttachmentResourceRef.MessageAttachment, access: 'private', createdByRef: msg.sender ? 'Subscriber' : 'User', createdBy: msg.sender ? msg.sender : adminUser.id, @@ -681,7 +714,7 @@ const migrateAndPopulateAttachmentMessages = async ({ size: fileBuffer.length, type: response.headers['content-type'], channel: {}, - resourceRef: 'message_attachment', + resourceRef: AttachmentResourceRef.MessageAttachment, access: msg.sender ? 'private' : 'public', createdBy: msg.sender ? msg.sender : adminUser.id, createdByRef: msg.sender ? 'Subscriber' : 'User', diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 1af1674b..968855b8 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -31,6 +31,7 @@ import { Session as ExpressSession } from 'express-session'; import { diskStorage, memoryStorage } from 'multer'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { AttachmentResourceRef } from '@/attachment/types'; import { config } from '@/config'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; @@ -294,7 +295,7 @@ export class ReadWriteUserController extends ReadOnlyUserController { name: avatarFile.originalname, size: avatarFile.size, type: avatarFile.mimetype, - resourceRef: 'user_avatar', + resourceRef: AttachmentResourceRef.UserAvatar, access: 'private', createdByRef: 'User', createdBy: req.user.id, diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index cece4802..73fda51c 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -10,6 +10,7 @@ import mongoose from 'mongoose'; import { AttachmentCreateDto } from '@/attachment/dto/attachment.dto'; import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; +import { AttachmentResourceRef } from '@/attachment/types'; export const attachmentFixtures: AttachmentCreateDto[] = [ { @@ -22,7 +23,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '1', }, }, - resourceRef: 'content_attachment', + resourceRef: AttachmentResourceRef.ContentAttachment, access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), @@ -37,7 +38,7 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '2', }, }, - resourceRef: 'content_attachment', + resourceRef: AttachmentResourceRef.ContentAttachment, access: 'public', createdByRef: 'User', createdBy: '9'.repeat(24), diff --git a/frontend/src/components/contents/ContentDialog.tsx b/frontend/src/components/contents/ContentDialog.tsx index 761e9eec..642f9d91 100644 --- a/frontend/src/components/contents/ContentDialog.tsx +++ b/frontend/src/components/contents/ContentDialog.tsx @@ -38,6 +38,7 @@ import { DialogControlProps } from "@/hooks/useDialog"; import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType } from "@/services/types"; +import { AttachmentResourceRef } from "@/types/attachment.types"; import { ContentField, ContentFieldType, @@ -116,7 +117,7 @@ const ContentFieldInput: React.FC = ({ value={field.value?.payload?.id} accept={MIME_TYPES["images"].join(",")} format="full" - resourceRef="content_attachment" + resourceRef={AttachmentResourceRef.ContentAttachment} /> ); default: diff --git a/frontend/src/components/contents/ContentImportDialog.tsx b/frontend/src/components/contents/ContentImportDialog.tsx index ba1bece0..ddf613bb 100644 --- a/frontend/src/components/contents/ContentImportDialog.tsx +++ b/frontend/src/components/contents/ContentImportDialog.tsx @@ -20,6 +20,7 @@ import { useApiClient } from "@/hooks/useApiClient"; import { DialogControlProps } from "@/hooks/useDialog"; import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; +import { AttachmentResourceRef } from "@/types/attachment.types"; import { IContentType } from "@/types/content-type.types"; export type ContentImportDialogProps = DialogControlProps<{ @@ -81,7 +82,7 @@ export const ContentImportDialog: FC = ({ }} label="" value={attachmentId} - resourceRef="content_attachment" + resourceRef={AttachmentResourceRef.ContentAttachment} /> diff --git a/frontend/src/components/settings/SettingInput.tsx b/frontend/src/components/settings/SettingInput.tsx index 174f3eea..889ce979 100644 --- a/frontend/src/components/settings/SettingInput.tsx +++ b/frontend/src/components/settings/SettingInput.tsx @@ -20,6 +20,7 @@ import MultipleInput from "@/app-components/inputs/MultipleInput"; import { PasswordInput } from "@/app-components/inputs/PasswordInput"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType, Format } from "@/services/types"; +import { AttachmentResourceRef } from "@/types/attachment.types"; import { IBlock } from "@/types/block.types"; import { IHelper } from "@/types/helper.types"; import { ISetting, SettingType } from "@/types/setting.types"; @@ -186,7 +187,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} - resourceRef="setting_attachment" + resourceRef={AttachmentResourceRef.SettingAttachment} /> ); @@ -199,7 +200,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} - resourceRef="setting_attachment" + resourceRef={AttachmentResourceRef.SettingAttachment} /> ); default: diff --git a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx index a5b12b2e..13cb4877 100644 --- a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx @@ -13,6 +13,7 @@ import AttachmentInput from "@/app-components/attachment/AttachmentInput"; import { ContentItem } from "@/app-components/dialogs"; import AttachmentIcon from "@/app-components/svg/toolbar/AttachmentIcon"; import { useTranslate } from "@/hooks/useTranslate"; +import { AttachmentResourceRef } from "@/types/attachment.types"; import { IBlockAttributes } from "@/types/block.types"; import { FileType } from "@/types/message.types"; import { MIME_TYPES, getFileType } from "@/utils/attachment"; @@ -69,7 +70,7 @@ const AttachmentMessageForm = () => { }, }); }} - resourceRef="block_attachment" + resourceRef={AttachmentResourceRef.BlockAttachment} /> ); }} diff --git a/frontend/src/types/attachment.types.ts b/frontend/src/types/attachment.types.ts index 213ea268..d70ba7b8 100644 --- a/frontend/src/types/attachment.types.ts +++ b/frontend/src/types/attachment.types.ts @@ -29,12 +29,12 @@ export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; * These references influence how the attachment is uploaded, stored, and accessed: */ export enum AttachmentResourceRef { - 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.; + SettingAttachment = "Setting", // Attachments related to app settings, restricted to users with specific permissions. + UserAvatar = "User", // Avatar files for users, only the current user can upload, accessible to those with appropriate permissions. + SubscriberAvatar = "Subscriber", // Avatar files for subscribers, uploaded programmatically, accessible to authorized users. + BlockAttachment = "Block", // Files sent by the bot, public or private based on the channel and user authentication. + ContentAttachment = "Content", // Files in the knowledge base, usually public but could vary based on specific needs. + MessageAttachment = "Message", // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.; } export type TAttachmentResourceRef = `${AttachmentResourceRef}`; From 4fac5d4fc93311c88f2e47d73d742a3c94febf00 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 16 Jan 2025 17:25:16 +0100 Subject: [PATCH 15/23] feat: enforce security to access own attachment --- api/src/channel/lib/Handler.ts | 26 +++++++++++- .../channels/web/base-web-channel.ts | 40 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index d1525758..037f64fd 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -9,6 +9,7 @@ import path from 'path'; import { + ForbiddenException, Inject, Injectable, NotFoundException, @@ -316,6 +317,19 @@ export default abstract class ChannelHandler< } } + /** + * Checks if the request is authorized to download a given attachment file. + * Can be overriden by the channel handler to customize, by default it shouldn't + * allow any client to download a subscriber attachment for example. + * + * @param attachment The attachment object + * @param req - The HTTP express request object. + * @return True, if requester is authorized to download the attachment + */ + public async hasDownloadAccess(attachment: Attachment, _req: Request) { + return attachment.access === 'public'; + } + /** * Downloads an attachment using a signed token. * @@ -326,9 +340,8 @@ export default abstract class ChannelHandler< * @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) { + public async download(token: string, req: Request) { try { const { exp: _exp, @@ -336,6 +349,15 @@ export default abstract class ChannelHandler< ...result } = this.jwtService.verify(token, this.jwtSignOptions); const attachment = plainToClass(Attachment, result); + + // Check access + const canDownload = await this.hasDownloadAccess(attachment, req); + if (!canDownload) { + throw new ForbiddenException( + 'You are not authorized to download the attachment', + ); + } + return await this.attachmentService.download(attachment); } catch (err) { this.logger.error('Failed to download attachment', err); diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 454a2dd5..3e0f911b 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -1345,4 +1345,44 @@ export default abstract class BaseWebChannelHandler< }; return subscriber; } + + /** + * Checks if the request is authorized to download a given attachment file. + * + * @param attachment The attachment object + * @param req - The HTTP express request object. + * @return True, if requester is authorized to download the attachment + */ + public async hasDownloadAccess(attachment: Attachment, req: Request) { + const subscriberId = req.session?.web?.profile?.id as string; + if (attachment.access === 'public') { + return true; + } else if (!subscriberId) { + this.logger.warn( + `Unauthorized access attempt to attachment ${attachment.id}`, + ); + return false; + } else if ( + attachment.createdByRef === 'Subscriber' && + subscriberId === attachment.createdBy + ) { + // Either subscriber wants to access the attachment he sent + return true; + } else { + // Or, he would like to access an attachment sent to him privately + const message = await this.messageService.findOne({ + ['recipient' as any]: subscriberId, + $or: [ + { 'message.attachment.payload.id': attachment.id }, + { + 'message.attachment': { + $elemMatch: { 'payload.id': attachment.id }, + }, + }, + ], + }); + + return !!message; + } + } } From 359049ff3da90be971ea5100a55240b3517e5767 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 16 Jan 2025 17:41:30 +0100 Subject: [PATCH 16/23] refactor: use enums --- .../controllers/attachment.controller.spec.ts | 12 ++++++++---- .../controllers/attachment.controller.ts | 8 ++++++-- api/src/attachment/dto/attachment.dto.ts | 13 +++++-------- .../guards/attachment-ability.guard.ts | 6 +++--- api/src/attachment/mocks/attachment.mock.ts | 18 +++++++++++------- .../attachment/schemas/attachment.schema.ts | 9 +++------ .../attachment/services/attachment.service.ts | 4 ++-- api/src/attachment/types/index.ts | 6 ------ api/src/attachment/utilities/index.ts | 12 ++++++------ api/src/channel/lib/Handler.ts | 13 +++++++++---- api/src/channel/lib/__test__/common.mock.ts | 10 +++++++--- api/src/chat/services/chat.service.ts | 10 +++++++--- .../channels/web/base-web-channel.ts | 18 +++++++++++------- .../1735836154221-v-2-2-0.migration.ts | 19 +++++++++++++------ api/src/user/controllers/user.controller.ts | 10 +++++++--- api/src/utils/test/fixtures/attachment.ts | 14 +++++++++----- .../attachment/AttachmentInput.tsx | 4 ++-- .../attachment/AttachmentUploader.tsx | 4 ++-- .../attachment/MultipleAttachmentInput.tsx | 4 ++-- frontend/src/hooks/crud/useUpload.tsx | 4 ++-- frontend/src/services/api.class.ts | 4 ++-- frontend/src/types/attachment.types.ts | 12 +++++++----- 22 files changed, 124 insertions(+), 90 deletions(-) diff --git a/api/src/attachment/controllers/attachment.controller.spec.ts b/api/src/attachment/controllers/attachment.controller.spec.ts index 34b19ef0..e8c96d88 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -39,7 +39,11 @@ import { attachment, attachmentFile } from '../mocks/attachment.mock'; import { AttachmentRepository } from '../repositories/attachment.repository'; import { Attachment, AttachmentModel } from '../schemas/attachment.schema'; import { AttachmentService } from '../services/attachment.service'; -import { AttachmentResourceRef } from '../types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '../types'; import { AttachmentController } from './attachment.controller'; @@ -138,8 +142,8 @@ describe('AttachmentController', () => { name: attachmentFile.originalname, location: expect.stringMatching(new RegExp(`^/${name}`)), resourceRef: AttachmentResourceRef.BlockAttachment, - access: 'public', - createdByRef: 'User', + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, createdBy: '9'.repeat(24), }); expect(result).toEqualPayload( @@ -147,7 +151,7 @@ describe('AttachmentController', () => { { ...attachment, resourceRef: AttachmentResourceRef.BlockAttachment, - createdByRef: 'User', + createdByRef: AttachmentCreatedByRef.User, createdBy: '9'.repeat(24), }, ], diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index 278fa7c4..8f794e03 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -46,6 +46,7 @@ import { import { AttachmentGuard } from '../guards/attachment-ability.guard'; import { Attachment } from '../schemas/attachment.schema'; import { AttachmentService } from '../services/attachment.service'; +import { AttachmentAccess, AttachmentCreatedByRef } from '../types'; @UseInterceptors(CsrfInterceptor) @Controller('attachment') @@ -131,7 +132,10 @@ export class AttachmentController extends BaseController { @UploadedFiles() files: { file: Express.Multer.File[] }, @Req() req: Request, @Query() - { resourceRef, access = 'public' }: AttachmentContextParamDto, + { + resourceRef, + access = AttachmentAccess.Public, + }: AttachmentContextParamDto, ): Promise { if (!files || !Array.isArray(files?.file) || files.file.length === 0) { throw new BadRequestException('No file was selected'); @@ -153,7 +157,7 @@ export class AttachmentController extends BaseController { resourceRef, access, createdBy: userId, - createdByRef: 'User', + createdByRef: AttachmentCreatedByRef.User, }); if (attachment) { diff --git a/api/src/attachment/dto/attachment.dto.ts b/api/src/attachment/dto/attachment.dto.ts index d5e0f68a..c10d0096 100644 --- a/api/src/attachment/dto/attachment.dto.ts +++ b/api/src/attachment/dto/attachment.dto.ts @@ -27,9 +27,6 @@ import { AttachmentAccess, AttachmentCreatedByRef, AttachmentResourceRef, - TAttachmentAccess, - TAttachmentCreatedByRef, - TAttachmentResourceRef, } from '../types'; export class AttachmentMetadataDto { @@ -76,7 +73,7 @@ export class AttachmentMetadataDto { @IsString() @IsNotEmpty() @IsIn(Object.values(AttachmentResourceRef)) - resourceRef: TAttachmentResourceRef; + resourceRef: AttachmentResourceRef; /** * Attachment Owner Type @@ -88,7 +85,7 @@ export class AttachmentMetadataDto { @IsString() @IsNotEmpty() @IsIn(Object.values(AttachmentCreatedByRef)) - createdByRef: TAttachmentCreatedByRef; + createdByRef: AttachmentCreatedByRef; /** * Attachment Access @@ -100,7 +97,7 @@ export class AttachmentMetadataDto { @IsString() @IsNotEmpty() @IsIn(Object.values(AttachmentAccess)) - access: TAttachmentAccess; + access: AttachmentAccess; /** * Attachment Owner : Subscriber or User ID @@ -147,7 +144,7 @@ export class AttachmentContextParamDto { @IsString() @IsIn(Object.values(AttachmentResourceRef)) @IsNotEmpty() - resourceRef: TAttachmentResourceRef; + resourceRef: AttachmentResourceRef; @ApiPropertyOptional({ description: 'Attachment Access', @@ -156,5 +153,5 @@ export class AttachmentContextParamDto { @IsString() @IsIn(Object.values(AttachmentAccess)) @IsOptional() - access?: TAttachmentAccess; + access?: AttachmentAccess; } diff --git a/api/src/attachment/guards/attachment-ability.guard.ts b/api/src/attachment/guards/attachment-ability.guard.ts index be5deea3..224f0947 100644 --- a/api/src/attachment/guards/attachment-ability.guard.ts +++ b/api/src/attachment/guards/attachment-ability.guard.ts @@ -26,7 +26,7 @@ import { Action } from '@/user/types/action.type'; import { TModel } from '@/user/types/model.type'; import { AttachmentService } from '../services/attachment.service'; -import { AttachmentResourceRef, TAttachmentResourceRef } from '../types'; +import { AttachmentResourceRef } from '../types'; import { isAttachmentResourceRef, isAttachmentResourceRefArray, @@ -42,7 +42,7 @@ export class AttachmentGuard implements CanActivate { private permissionMap: Record< Action, - Record + Record > = { // Read attachments by ref [Action.READ]: { @@ -169,7 +169,7 @@ export class AttachmentGuard implements CanActivate { private async isAuthorized( action: Action, user: Express.User & User, - resourceRef: TAttachmentResourceRef, + resourceRef: AttachmentResourceRef, ): Promise { if (!action) { throw new TypeError('Invalid action'); diff --git a/api/src/attachment/mocks/attachment.mock.ts b/api/src/attachment/mocks/attachment.mock.ts index cbc885b8..025b929b 100644 --- a/api/src/attachment/mocks/attachment.mock.ts +++ b/api/src/attachment/mocks/attachment.mock.ts @@ -9,7 +9,11 @@ import { Stream } from 'node:stream'; import { Attachment } from '../schemas/attachment.schema'; -import { AttachmentResourceRef } from '../types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '../types'; export const attachment: Attachment = { name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', @@ -18,12 +22,12 @@ export const attachment: Attachment = { location: '/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', resourceRef: AttachmentResourceRef.BlockAttachment, - access: 'public', + access: AttachmentAccess.Public, id: '65940d115178607da65c82b6', createdAt: new Date(), updatedAt: new Date(), createdBy: '1', - createdByRef: 'User', + createdByRef: AttachmentCreatedByRef.User, }; export const attachmentFile: Express.Multer.File = { @@ -49,12 +53,12 @@ export const attachments: Attachment[] = [ '/app/src/attachment/uploads/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', channel: { ['some-channel']: {} }, resourceRef: AttachmentResourceRef.BlockAttachment, - access: 'public', + access: AttachmentAccess.Public, id: '65940d115178607da65c82b7', createdAt: new Date(), updatedAt: new Date(), createdBy: '1', - createdByRef: 'User', + createdByRef: AttachmentCreatedByRef.User, }, { name: 'Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png', @@ -64,11 +68,11 @@ export const attachments: Attachment[] = [ '/app/src/attachment/uploads/Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png', channel: { ['some-channel']: {} }, resourceRef: AttachmentResourceRef.BlockAttachment, - access: 'public', + access: AttachmentAccess.Public, id: '65940d115178607da65c82b8', createdAt: new Date(), updatedAt: new Date(), createdBy: '1', - createdByRef: 'User', + createdByRef: AttachmentCreatedByRef.User, }, ]; diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index 7d8e6b8e..a4675379 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -27,9 +27,6 @@ import { AttachmentAccess, AttachmentCreatedByRef, AttachmentResourceRef, - TAttachmentAccess, - TAttachmentCreatedByRef, - TAttachmentResourceRef, } from '../types'; import { MIME_REGEX } from '../utilities'; @@ -94,19 +91,19 @@ export class AttachmentStub extends BaseSchema { * Type of the createdBy (depending on the createdBy type) */ @Prop({ type: String, enum: Object.values(AttachmentCreatedByRef) }) - createdByRef: TAttachmentCreatedByRef; + createdByRef: AttachmentCreatedByRef; /** * Resource reference of the attachment */ @Prop({ type: String, enum: Object.values(AttachmentResourceRef) }) - resourceRef: TAttachmentResourceRef; + resourceRef: AttachmentResourceRef; /** * Access level of the attachment */ @Prop({ type: String, enum: Object.values(AttachmentAccess) }) - access: TAttachmentAccess; + access: AttachmentAccess; /** * Optional property representing the URL of the attachment. diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index f4721b2b..94e55985 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -30,7 +30,7 @@ import { BaseService } from '@/utils/generics/base-service'; import { AttachmentMetadataDto } from '../dto/attachment.dto'; import { AttachmentRepository } from '../repositories/attachment.repository'; import { Attachment } from '../schemas/attachment.schema'; -import { AttachmentResourceRef, TAttachmentResourceRef } from '../types'; +import { AttachmentResourceRef } from '../types'; import { fileExists, generateUniqueFilename, @@ -163,7 +163,7 @@ export class AttachmentService extends BaseService { * @param ref The attachment resource reference * @returns The root directory path */ - getRootDirByResourceRef(ref: TAttachmentResourceRef) { + getRootDirByResourceRef(ref: AttachmentResourceRef) { return ref === AttachmentResourceRef.SubscriberAvatar || ref === AttachmentResourceRef.UserAvatar ? config.parameters.avatarDir diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts index ae2ae728..66c613f7 100644 --- a/api/src/attachment/types/index.ts +++ b/api/src/attachment/types/index.ts @@ -17,8 +17,6 @@ export enum AttachmentCreatedByRef { Subscriber = 'Subscriber', } -export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; - /** * Defines the various resource references in which an attachment can exist. * These resource references influence how the attachment is uploaded, stored, and accessed: @@ -32,15 +30,11 @@ export enum AttachmentResourceRef { MessageAttachment = 'Message', // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.; } -export type TAttachmentResourceRef = `${AttachmentResourceRef}`; - export enum AttachmentAccess { Public = 'public', Private = 'private', } -export type TAttachmentAccess = `${AttachmentAccess}`; - export class AttachmentFile { /** * File original file name diff --git a/api/src/attachment/utilities/index.ts b/api/src/attachment/utilities/index.ts index 79649f76..13dead20 100644 --- a/api/src/attachment/utilities/index.ts +++ b/api/src/attachment/utilities/index.ts @@ -15,7 +15,7 @@ import { v4 as uuidv4 } from 'uuid'; import { config } from '@/config'; -import { AttachmentResourceRef, TAttachmentResourceRef } from '../types'; +import { AttachmentResourceRef } from '../types'; export const MIME_REGEX = /^[a-z-]+\/[0-9a-z\-.]+$/gm; @@ -84,13 +84,13 @@ export const generateUniqueFilename = (originalname: string) => { /** * Checks if the given ref is of type TAttachmentResourceRef. * - * @param ref - The ref to check. + * @param resourceRef - The ref to check. * @returns True if the ref is of type TAttachmentResourceRef, otherwise false. */ export const isAttachmentResourceRef = ( - ref: any, -): ref is TAttachmentResourceRef => { - return Object.values(AttachmentResourceRef).includes(ref); + resourceRef: any, +): resourceRef is AttachmentResourceRef => { + return Object.values(AttachmentResourceRef).includes(resourceRef); }; AttachmentResourceRef; @@ -102,7 +102,7 @@ AttachmentResourceRef; */ export const isAttachmentResourceRefArray = ( refList: any, -): refList is TAttachmentResourceRef[] => { +): refList is AttachmentResourceRef[] => { return ( Array.isArray(refList) && refList.length > 0 && diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 037f64fd..a803ae5b 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -23,7 +23,12 @@ import { v4 as uuidv4 } from 'uuid'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { AttachmentFile, AttachmentResourceRef } from '@/attachment/types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentFile, + AttachmentResourceRef, +} from '@/attachment/types'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; import { AttachmentRef } from '@/chat/schemas/types/attachment'; import { @@ -260,8 +265,8 @@ export default abstract class ChannelHandler< type, size, resourceRef: AttachmentResourceRef.MessageAttachment, - access: 'private', - createdByRef: 'Subscriber', + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, createdBy: subscriber.id, }); }), @@ -327,7 +332,7 @@ export default abstract class ChannelHandler< * @return True, if requester is authorized to download the attachment */ public async hasDownloadAccess(attachment: Attachment, _req: Request) { - return attachment.access === 'public'; + return attachment.access === AttachmentAccess.Public; } /** diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index 0f6f04a8..7146dc80 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -7,7 +7,11 @@ */ import { Attachment } from '@/attachment/schemas/attachment.schema'; -import { AttachmentResourceRef } from '@/attachment/types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import { ButtonType } from '@/chat/schemas/types/button'; import { FileType, @@ -90,8 +94,8 @@ const attachment: Attachment = { }, }, resourceRef: AttachmentResourceRef.BlockAttachment, - access: 'public', - createdByRef: 'User', + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, createdBy: null, createdAt: new Date(), updatedAt: new Date(), diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 78ef8866..6f7512fd 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -12,7 +12,11 @@ import mime from 'mime'; import { v4 as uuidv4 } from 'uuid'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { AttachmentResourceRef } from '@/attachment/types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import EventWrapper from '@/channel/lib/EventWrapper'; import { config } from '@/config'; import { HelperService } from '@/helper/helper.service'; @@ -282,8 +286,8 @@ export class ChatService { size, type, resourceRef: AttachmentResourceRef.SubscriberAvatar, - access: 'private', - createdByRef: 'Subscriber', + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, createdBy: subscriber.id, }); diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 3e0f911b..81b4ea4e 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -15,7 +15,11 @@ import { v4 as uuidv4 } from 'uuid'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { AttachmentResourceRef } from '@/attachment/types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import { ChannelService } from '@/channel/channel.service'; import ChannelHandler from '@/channel/lib/Handler'; import { ChannelName } from '@/channel/types'; @@ -627,8 +631,8 @@ export default abstract class BaseWebChannelHandler< size: Buffer.byteLength(data.file), type: data.type, resourceRef: AttachmentResourceRef.MessageAttachment, - access: 'private', - createdByRef: 'Subscriber', + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, createdBy: req.session?.web?.profile?.id, }); } catch (err) { @@ -694,8 +698,8 @@ export default abstract class BaseWebChannelHandler< size: file.size, type: file.mimetype, resourceRef: AttachmentResourceRef.MessageAttachment, - access: 'private', - createdByRef: 'Subscriber', + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, createdBy: req.session.web.profile?.id, }); } catch (err) { @@ -1355,7 +1359,7 @@ export default abstract class BaseWebChannelHandler< */ public async hasDownloadAccess(attachment: Attachment, req: Request) { const subscriberId = req.session?.web?.profile?.id as string; - if (attachment.access === 'public') { + if (attachment.access === AttachmentAccess.Public) { return true; } else if (!subscriberId) { this.logger.warn( @@ -1363,7 +1367,7 @@ export default abstract class BaseWebChannelHandler< ); return false; } else if ( - attachment.createdByRef === 'Subscriber' && + attachment.createdByRef === AttachmentCreatedByRef.Subscriber && subscriberId === attachment.createdBy ) { // Either subscriber wants to access the attachment he sent diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index b94a68af..2f699212 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -16,6 +16,7 @@ import attachmentSchema, { Attachment, } from '@/attachment/schemas/attachment.schema'; import { + AttachmentAccess, AttachmentCreatedByRef, AttachmentResourceRef, } from '@/attachment/types'; @@ -619,8 +620,8 @@ const migrateAttachmentContents = async ( $set: { resourceRef: AttachmentResourceRef.ContentAttachment, createdBy: adminUser.id, - createdByRef: 'User', - access: 'public', + createdByRef: AttachmentCreatedByRef.User, + access: AttachmentAccess.Public, }, }, ); @@ -682,8 +683,10 @@ const migrateAndPopulateAttachmentMessages = async ({ msg.message.attachment.payload.attachment_id as string, { resourceRef: AttachmentResourceRef.MessageAttachment, - access: 'private', - createdByRef: msg.sender ? 'Subscriber' : 'User', + access: AttachmentAccess.Private, + createdByRef: msg.sender + ? AttachmentCreatedByRef.Subscriber + : AttachmentCreatedByRef.User, createdBy: msg.sender ? msg.sender : adminUser.id, }, ); @@ -715,9 +718,13 @@ const migrateAndPopulateAttachmentMessages = async ({ type: response.headers['content-type'], channel: {}, resourceRef: AttachmentResourceRef.MessageAttachment, - access: msg.sender ? 'private' : 'public', + access: msg.sender + ? AttachmentAccess.Private + : AttachmentAccess.Public, createdBy: msg.sender ? msg.sender : adminUser.id, - createdByRef: msg.sender ? 'Subscriber' : 'User', + createdByRef: msg.sender + ? AttachmentCreatedByRef.Subscriber + : AttachmentCreatedByRef.User, }); if (attachment) { diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 968855b8..a30dfba2 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -31,7 +31,11 @@ import { Session as ExpressSession } from 'express-session'; import { diskStorage, memoryStorage } from 'multer'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { AttachmentResourceRef } from '@/attachment/types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import { config } from '@/config'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; @@ -296,8 +300,8 @@ export class ReadWriteUserController extends ReadOnlyUserController { size: avatarFile.size, type: avatarFile.mimetype, resourceRef: AttachmentResourceRef.UserAvatar, - access: 'private', - createdByRef: 'User', + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.User, createdBy: req.user.id, }) : undefined; diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index 73fda51c..77ec0f93 100644 --- a/api/src/utils/test/fixtures/attachment.ts +++ b/api/src/utils/test/fixtures/attachment.ts @@ -10,7 +10,11 @@ import mongoose from 'mongoose'; import { AttachmentCreateDto } from '@/attachment/dto/attachment.dto'; import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; -import { AttachmentResourceRef } from '@/attachment/types'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; export const attachmentFixtures: AttachmentCreateDto[] = [ { @@ -24,8 +28,8 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, }, resourceRef: AttachmentResourceRef.ContentAttachment, - access: 'public', - createdByRef: 'User', + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, createdBy: '9'.repeat(24), }, { @@ -39,8 +43,8 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ }, }, resourceRef: AttachmentResourceRef.ContentAttachment, - access: 'public', - createdByRef: 'User', + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, createdBy: '9'.repeat(24), }, ]; diff --git a/frontend/src/app-components/attachment/AttachmentInput.tsx b/frontend/src/app-components/attachment/AttachmentInput.tsx index 7ed77c6d..f3fb71dc 100644 --- a/frontend/src/app-components/attachment/AttachmentInput.tsx +++ b/frontend/src/app-components/attachment/AttachmentInput.tsx @@ -13,7 +13,7 @@ import { forwardRef } from "react"; import { useGet } from "@/hooks/crud/useGet"; import { useHasPermission } from "@/hooks/useHasPermission"; import { EntityType } from "@/services/types"; -import { IAttachment, TAttachmentResourceRef } from "@/types/attachment.types"; +import { AttachmentResourceRef, IAttachment } from "@/types/attachment.types"; import { PermissionAction } from "@/types/permission.types"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -29,7 +29,7 @@ type AttachmentThumbnailProps = { onChange?: (id: string | null, mimeType: string | null) => void; error?: boolean; helperText?: string; - resourceRef: TAttachmentResourceRef; + resourceRef: AttachmentResourceRef; }; const AttachmentInput = forwardRef( diff --git a/frontend/src/app-components/attachment/AttachmentUploader.tsx b/frontend/src/app-components/attachment/AttachmentUploader.tsx index 112f111c..858eee22 100644 --- a/frontend/src/app-components/attachment/AttachmentUploader.tsx +++ b/frontend/src/app-components/attachment/AttachmentUploader.tsx @@ -17,7 +17,7 @@ import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType } from "@/services/types"; -import { IAttachment, TAttachmentResourceRef } from "@/types/attachment.types"; +import { AttachmentResourceRef, IAttachment } from "@/types/attachment.types"; import { AttachmentDialog } from "./AttachmentDialog"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -68,7 +68,7 @@ export type FileUploadProps = { enableMediaLibrary?: boolean; onChange?: (data?: IAttachment | null) => void; onUploadComplete?: () => void; - resourceRef: TAttachmentResourceRef; + resourceRef: AttachmentResourceRef; }; const AttachmentUploader: FC = ({ diff --git a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx index 11a3e069..fb4accc7 100644 --- a/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx +++ b/frontend/src/app-components/attachment/MultipleAttachmentInput.tsx @@ -12,7 +12,7 @@ import { forwardRef, useState } from "react"; import { useHasPermission } from "@/hooks/useHasPermission"; import { EntityType } from "@/services/types"; -import { IAttachment, TAttachmentResourceRef } from "@/types/attachment.types"; +import { AttachmentResourceRef, IAttachment } from "@/types/attachment.types"; import { PermissionAction } from "@/types/permission.types"; import AttachmentThumbnail from "./AttachmentThumbnail"; @@ -28,7 +28,7 @@ type MultipleAttachmentInputProps = { onChange?: (ids: string[]) => void; error?: boolean; helperText?: string; - resourceRef: TAttachmentResourceRef; + resourceRef: AttachmentResourceRef; }; const MultipleAttachmentInput = forwardRef< diff --git a/frontend/src/hooks/crud/useUpload.tsx b/frontend/src/hooks/crud/useUpload.tsx index 5f5a5e44..d7d8dcec 100644 --- a/frontend/src/hooks/crud/useUpload.tsx +++ b/frontend/src/hooks/crud/useUpload.tsx @@ -9,7 +9,7 @@ import { useMutation, useQueryClient } from "react-query"; import { QueryType, TMutationOptions } from "@/services/types"; -import { TAttachmentResourceRef } from "@/types/attachment.types"; +import { AttachmentResourceRef } from "@/types/attachment.types"; import { IBaseSchema, IDynamicProps, TType } from "@/types/base.types"; import { useEntityApiClient } from "../useApiClient"; @@ -27,7 +27,7 @@ export const useUpload = < TMutationOptions< TBasic, Error, - { file: File; resourceRef: TAttachmentResourceRef }, + { file: File; resourceRef: AttachmentResourceRef }, TBasic >, "mutationFn" | "mutationKey" diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index ddf14d0c..360abf80 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -9,7 +9,7 @@ import { AxiosInstance, AxiosResponse } from "axios"; -import { TAttachmentResourceRef } from "@/types/attachment.types"; +import { AttachmentResourceRef } from "@/types/attachment.types"; import { ILoginAttributes } from "@/types/auth/login.types"; import { IUserPermissions } from "@/types/auth/permission.types"; import { StatsType } from "@/types/bot-stat.types"; @@ -302,7 +302,7 @@ export class EntityApiClient extends ApiClient { return data; } - async upload(file: File, resourceRef?: TAttachmentResourceRef) { + async upload(file: File, resourceRef?: AttachmentResourceRef) { const { _csrf } = await this.getCsrf(); const formData = new FormData(); diff --git a/frontend/src/types/attachment.types.ts b/frontend/src/types/attachment.types.ts index d70ba7b8..22a6c7ff 100644 --- a/frontend/src/types/attachment.types.ts +++ b/frontend/src/types/attachment.types.ts @@ -22,8 +22,6 @@ export enum AttachmentCreatedByRef { Subscriber = "Subscriber", } -export type TAttachmentCreatedByRef = `${AttachmentCreatedByRef}`; - /** * Defines the various resource references in which an attachment can exist. * These references influence how the attachment is uploaded, stored, and accessed: @@ -37,7 +35,10 @@ export enum AttachmentResourceRef { MessageAttachment = "Message", // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.; } -export type TAttachmentResourceRef = `${AttachmentResourceRef}`; +export enum AttachmentAccess { + Public = "public", + Private = "private", +} export interface IAttachmentAttributes { name: string; @@ -46,8 +47,9 @@ export interface IAttachmentAttributes { location: string; url: string; channel?: Record; - resourceRef: TAttachmentResourceRef; - createdByRef: TAttachmentCreatedByRef; + resourceRef: AttachmentResourceRef; + access: AttachmentAccess; + createdByRef: AttachmentCreatedByRef; createdBy: string | null; } From a355ef01557939d0d82b29dc717c0e28627134ef Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 16 Jan 2025 18:51:43 +0100 Subject: [PATCH 17/23] fix: console channel session.web --- api/src/channel/channel.service.ts | 64 +++++++++++++++++------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/api/src/channel/channel.service.ts b/api/src/channel/channel.service.ts index 0b7d497e..00c987c0 100644 --- a/api/src/channel/channel.service.ts +++ b/api/src/channel/channel.service.ts @@ -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. @@ -13,6 +13,7 @@ import { SubscriberService } from '@/chat/services/subscriber.service'; import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings'; import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings'; import { LoggerService } from '@/logger/logger.service'; +import { getSessionStore } from '@/utils/constants/session-store'; import { SocketGet, SocketPost, @@ -155,34 +156,43 @@ export class ChannelService { ); } - // Create test subscriber for the current user - const testSubscriber = await this.subscriberService.findOneOrCreate( - { - foreign_id: req.session.passport.user.id, - }, - { - foreign_id: req.session.passport.user.id, - first_name: req.session.passport.user.first_name || 'Anonymous', - last_name: req.session.passport.user.last_name || 'Anonymous', - locale: '', - language: '', - gender: '', - country: '', - labels: [], - channel: { - name: CONSOLE_CHANNEL_NAME, - isSocket: true, + if (!req.session.web?.profile?.id) { + // Create test subscriber for the current user + const testSubscriber = await this.subscriberService.findOneOrCreate( + { + foreign_id: req.session.passport.user.id, }, - }, - ); + { + foreign_id: req.session.passport.user.id, + first_name: req.session.passport.user.first_name || 'Anonymous', + last_name: req.session.passport.user.last_name || 'Anonymous', + locale: '', + language: '', + gender: '', + country: '', + labels: [], + channel: { + name: CONSOLE_CHANNEL_NAME, + isSocket: true, + }, + }, + ); - // Update session (end user is both a user + subscriber) - req.session.web = { - profile: testSubscriber, - isSocket: true, - messageQueue: [], - polling: false, - }; + // Update session (end user is both a user + subscriber) + req.session.web = { + profile: testSubscriber, + isSocket: true, + messageQueue: [], + polling: false, + }; + + // @TODO: temporary fix until it's fixed properly: https://github.com/Hexastack/Hexabot/issues/578 + getSessionStore().set(req.sessionID, req.session, (err) => { + if (err) { + this.logger.warn('Unable to store WS Console session', err); + } + }); + } const handler = this.getChannelHandler(CONSOLE_CHANNEL_NAME); return handler.handle(req, res); From 36b0544fd1ea73cd08d1a4343edf6a8a168111fa Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 17 Jan 2025 07:17:25 +0100 Subject: [PATCH 18/23] fix: media library --- frontend/src/components/media-library/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/media-library/index.tsx b/frontend/src/components/media-library/index.tsx index 51a2213d..4409cd44 100644 --- a/frontend/src/components/media-library/index.tsx +++ b/frontend/src/components/media-library/index.tsx @@ -1,11 +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 DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload"; import { Box, Grid, Paper } from "@mui/material"; import { GridColDef, GridEventListener } from "@mui/x-data-grid"; @@ -32,7 +33,10 @@ import { PermissionAction } from "@/types/permission.types"; import { TFilterStringFields } from "@/types/search.types"; import { getDateTimeFormatter } from "@/utils/date"; -import { IAttachment } from "../../types/attachment.types"; +import { + AttachmentResourceRef, + IAttachment, +} from "../../types/attachment.types"; type MediaLibraryProps = { showTitle?: boolean; @@ -53,6 +57,10 @@ export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { { params: { where: { + resourceRef: [ + AttachmentResourceRef.BlockAttachment, + AttachmentResourceRef.ContentAttachment, + ], ...searchPayload.where, or: { ...searchPayload.where.or, From f7363563ade86ad18dd21ce3b577af60f5bbd4bb Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 17 Jan 2025 07:17:54 +0100 Subject: [PATCH 19/23] fix: carousel display + refactor attachment url --- .../src/components/contents/ContentDialog.tsx | 1 + .../inbox/components/AttachmentViewer.tsx | 14 ++++++------- .../components/inbox/components/Carousel.tsx | 10 +++++++--- frontend/src/hooks/useGetAttachmentUrl.ts | 20 +++++++++++++++++++ frontend/src/utils/attachment.ts | 16 +++++++++++++-- 5 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 frontend/src/hooks/useGetAttachmentUrl.ts diff --git a/frontend/src/components/contents/ContentDialog.tsx b/frontend/src/components/contents/ContentDialog.tsx index 642f9d91..164e69aa 100644 --- a/frontend/src/components/contents/ContentDialog.tsx +++ b/frontend/src/components/contents/ContentDialog.tsx @@ -117,6 +117,7 @@ const ContentFieldInput: React.FC = ({ value={field.value?.payload?.id} accept={MIME_TYPES["images"].join(",")} format="full" + size={256} resourceRef={AttachmentResourceRef.ContentAttachment} /> ); diff --git a/frontend/src/components/inbox/components/AttachmentViewer.tsx b/frontend/src/components/inbox/components/AttachmentViewer.tsx index e8b98306..4784cf87 100644 --- a/frontend/src/components/inbox/components/AttachmentViewer.tsx +++ b/frontend/src/components/inbox/components/AttachmentViewer.tsx @@ -11,8 +11,8 @@ import { Button, Dialog, DialogContent } from "@mui/material"; import { FC } from "react"; import { DialogTitle } from "@/app-components/dialogs"; -import { useConfig } from "@/hooks/useConfig"; import { useDialog } from "@/hooks/useDialog"; +import { useGetAttachmentUrl } from "@/hooks/useGetAttachmentUrl"; import { useTranslate } from "@/hooks/useTranslate"; import { FileType, @@ -95,19 +95,19 @@ export const AttachmentViewer = (props: { message: StdIncomingAttachmentMessage | StdOutgoingAttachmentMessage; }) => { const message = props.message; - const { apiUrl } = useConfig(); + const getUrl = useGetAttachmentUrl(); // if the attachment is an array show a 4x4 grid with a +{number of remaining attachment} and open a modal to show the list of attachments // Remark: Messenger doesn't send multiple attachments when user sends multiple at once, it only relays the first one to Hexabot // TODO: Implenent this - if (Array.isArray(message.attachment)) { + if (!message.attachment) { + return <>No attachment to display; + } else if (Array.isArray(message.attachment)) { return <>Not yet Implemented; } + const AttachmentViewerForType = componentMap[message.attachment.type]; - const url = - "id" in message.attachment?.payload && message.attachment?.payload.id - ? `${apiUrl}attachment/download/${message.attachment?.payload.id}` - : message.attachment?.payload?.url; + const url = getUrl(message.attachment?.payload); return ; }; diff --git a/frontend/src/components/inbox/components/Carousel.tsx b/frontend/src/components/inbox/components/Carousel.tsx index ab883bf7..eb0c732f 100644 --- a/frontend/src/components/inbox/components/Carousel.tsx +++ b/frontend/src/components/inbox/components/Carousel.tsx @@ -1,11 +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 ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; import { @@ -18,8 +19,9 @@ import { styled, Typography, } from "@mui/material"; -import { forwardRef, useEffect, useRef, useState, useCallback } from "react"; +import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { useGetAttachmentUrl } from "@/hooks/useGetAttachmentUrl"; import { AnyButton as ButtonType, OutgoingPopulatedListMessage, @@ -188,6 +190,8 @@ const ListCard = forwardRef< buttons: ButtonType[]; } >(function ListCardRef(props, ref) { + const getUrl = useGetAttachmentUrl(); + return ( {props.content.image_url ? ( diff --git a/frontend/src/hooks/useGetAttachmentUrl.ts b/frontend/src/hooks/useGetAttachmentUrl.ts new file mode 100644 index 00000000..c3bfcfac --- /dev/null +++ b/frontend/src/hooks/useGetAttachmentUrl.ts @@ -0,0 +1,20 @@ +/* + * 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 { AttachmentForeignKey } from "@/types/message.types"; +import { getAttachmentDownloadUrl } from "@/utils/attachment"; + +import { useConfig } from "./useConfig"; + +export const useGetAttachmentUrl = () => { + const { apiUrl } = useConfig(); + + return (attachment: AttachmentForeignKey) => { + return getAttachmentDownloadUrl(apiUrl, attachment); + }; +}; diff --git a/frontend/src/utils/attachment.ts b/frontend/src/utils/attachment.ts index db7a3f86..59c2ba8b 100644 --- a/frontend/src/utils/attachment.ts +++ b/frontend/src/utils/attachment.ts @@ -1,12 +1,15 @@ /* - * 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 { FileType } from "@/types/message.types"; + +import { AttachmentForeignKey, FileType } from "@/types/message.types"; + +import { buildURL } from "./URL"; export const MIME_TYPES = { images: ["image/jpeg", "image/png", "image/gif", "image/webp"], @@ -34,3 +37,12 @@ export function getFileType(mimeType: string): FileType { return FileType.file; } } + +export function getAttachmentDownloadUrl( + baseUrl: string, + attachment: AttachmentForeignKey, +) { + return "id" in attachment && attachment.id + ? buildURL(baseUrl, `/attachment/download/${attachment.id}`) + : attachment.url; +} From 8d1bb47b2a3021dd4a9cb6eb799297dd2f57be59 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 17 Jan 2025 08:00:45 +0100 Subject: [PATCH 20/23] feat: enhance attachment display in inbox --- .../inbox/components/AttachmentViewer.tsx | 56 ++++++++++++++----- .../components/inbox/components/Carousel.tsx | 8 +-- .../components/inbox/helpers/mapMessages.tsx | 7 ++- .../src/hooks/useGetAttachmentMetadata.ts | 52 +++++++++++++++++ frontend/src/hooks/useGetAttachmentUrl.ts | 20 ------- frontend/src/types/message.types.ts | 14 ++--- frontend/src/utils/attachment.ts | 27 ++++++++- 7 files changed, 134 insertions(+), 50 deletions(-) create mode 100644 frontend/src/hooks/useGetAttachmentMetadata.ts delete mode 100644 frontend/src/hooks/useGetAttachmentUrl.ts diff --git a/frontend/src/components/inbox/components/AttachmentViewer.tsx b/frontend/src/components/inbox/components/AttachmentViewer.tsx index 4784cf87..67fd3110 100644 --- a/frontend/src/components/inbox/components/AttachmentViewer.tsx +++ b/frontend/src/components/inbox/components/AttachmentViewer.tsx @@ -7,20 +7,22 @@ */ import DownloadIcon from "@mui/icons-material/Download"; -import { Button, Dialog, DialogContent } from "@mui/material"; +import { Box, Button, Dialog, DialogContent, Typography } from "@mui/material"; import { FC } from "react"; import { DialogTitle } from "@/app-components/dialogs"; import { useDialog } from "@/hooks/useDialog"; -import { useGetAttachmentUrl } from "@/hooks/useGetAttachmentUrl"; +import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata"; import { useTranslate } from "@/hooks/useTranslate"; import { FileType, + IAttachmentPayload, StdIncomingAttachmentMessage, StdOutgoingAttachmentMessage, } from "@/types/message.types"; interface AttachmentInterface { + name?: string; url?: string; } @@ -70,17 +72,23 @@ const componentMap: { [key in FileType]: FC } = { const { t } = useTranslate(); return ( -
- {t("label.attachment")}: + + + {props.name} + -
+ ); }, [FileType.video]: ({ url }: AttachmentInterface) => ( @@ -91,23 +99,43 @@ const componentMap: { [key in FileType]: FC } = { [FileType.unknown]: ({ url }: AttachmentInterface) => <>Unknown Type:{url}, }; -export const AttachmentViewer = (props: { +export const MessageAttachmentViewer = ({ + attachment, +}: { + attachment: IAttachmentPayload; +}) => { + const metadata = useGetAttachmentMetadata(attachment.payload); + const AttachmentViewerForType = componentMap[attachment.type]; + + if (!metadata) { + return <>No attachment to display; + } + + return ; +}; + +export const MessageAttachmentsViewer = (props: { message: StdIncomingAttachmentMessage | StdOutgoingAttachmentMessage; }) => { const message = props.message; - const getUrl = useGetAttachmentUrl(); - // if the attachment is an array show a 4x4 grid with a +{number of remaining attachment} and open a modal to show the list of attachments // Remark: Messenger doesn't send multiple attachments when user sends multiple at once, it only relays the first one to Hexabot // TODO: Implenent this + if (!message.attachment) { return <>No attachment to display; - } else if (Array.isArray(message.attachment)) { - return <>Not yet Implemented; } - const AttachmentViewerForType = componentMap[message.attachment.type]; - const url = getUrl(message.attachment?.payload); + const attachments = Array.isArray(message.attachment) + ? message.attachment + : [message.attachment]; - return ; + return attachments.map((attachment, idx) => { + return ( + + ); + }); }; diff --git a/frontend/src/components/inbox/components/Carousel.tsx b/frontend/src/components/inbox/components/Carousel.tsx index eb0c732f..d1e57c40 100644 --- a/frontend/src/components/inbox/components/Carousel.tsx +++ b/frontend/src/components/inbox/components/Carousel.tsx @@ -21,7 +21,7 @@ import { } from "@mui/material"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; -import { useGetAttachmentUrl } from "@/hooks/useGetAttachmentUrl"; +import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata"; import { AnyButton as ButtonType, OutgoingPopulatedListMessage, @@ -190,7 +190,7 @@ const ListCard = forwardRef< buttons: ButtonType[]; } >(function ListCardRef(props, ref) { - const getUrl = useGetAttachmentUrl(); + const metadata = useGetAttachmentMetadata(props.content.image_url?.payload); return ( - {props.content.image_url ? ( + {metadata ? ( diff --git a/frontend/src/components/inbox/helpers/mapMessages.tsx b/frontend/src/components/inbox/helpers/mapMessages.tsx index c6d07c16..fd027e68 100644 --- a/frontend/src/components/inbox/helpers/mapMessages.tsx +++ b/frontend/src/components/inbox/helpers/mapMessages.tsx @@ -1,11 +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 { Message, MessageModel } from "@chatscope/chat-ui-kit-react"; import MenuRoundedIcon from "@mui/icons-material/MenuRounded"; import ReplyIcon from "@mui/icons-material/Reply"; @@ -17,7 +18,7 @@ import { EntityType } from "@/services/types"; import { IMessage, IMessageFull } from "@/types/message.types"; import { buildURL } from "@/utils/URL"; -import { AttachmentViewer } from "../components/AttachmentViewer"; +import { MessageAttachmentsViewer } from "../components/AttachmentViewer"; import { Carousel } from "../components/Carousel"; function hasSameSender( @@ -110,7 +111,7 @@ export function getMessageContent( if ("attachment" in message) { content.push( - + , ); } diff --git a/frontend/src/hooks/useGetAttachmentMetadata.ts b/frontend/src/hooks/useGetAttachmentMetadata.ts new file mode 100644 index 00000000..8f669d0e --- /dev/null +++ b/frontend/src/hooks/useGetAttachmentMetadata.ts @@ -0,0 +1,52 @@ +/* + * 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 { EntityType } from "@/services/types"; +import { TAttachmentForeignKey } from "@/types/message.types"; +import { + extractFilenameFromUrl, + getAttachmentDownloadUrl, +} from "@/utils/attachment"; + +import { useGet } from "./crud/useGet"; +import { useConfig } from "./useConfig"; + +export const useGetAttachmentMetadata = ( + attachmentPayload?: TAttachmentForeignKey, +) => { + const { apiUrl } = useConfig(); + const { data: attachment } = useGet( + attachmentPayload?.id || "", + { + entity: EntityType.ATTACHMENT, + }, + { + enabled: !!attachmentPayload?.id, + }, + ); + + if (!attachmentPayload) { + return null; + } + + if (attachment) { + return { + name: attachmentPayload.id + ? attachment.name + : extractFilenameFromUrl(attachment.url), + url: getAttachmentDownloadUrl(apiUrl, attachment), + }; + } + + const url = getAttachmentDownloadUrl(apiUrl, attachmentPayload); + + return { + name: extractFilenameFromUrl(url || "/#"), + url, + }; +}; diff --git a/frontend/src/hooks/useGetAttachmentUrl.ts b/frontend/src/hooks/useGetAttachmentUrl.ts deleted file mode 100644 index c3bfcfac..00000000 --- a/frontend/src/hooks/useGetAttachmentUrl.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 { AttachmentForeignKey } from "@/types/message.types"; -import { getAttachmentDownloadUrl } from "@/utils/attachment"; - -import { useConfig } from "./useConfig"; - -export const useGetAttachmentUrl = () => { - const { apiUrl } = useConfig(); - - return (attachment: AttachmentForeignKey) => { - return getAttachmentDownloadUrl(apiUrl, attachment); - }; -}; diff --git a/frontend/src/types/message.types.ts b/frontend/src/types/message.types.ts index 5e0d4cf0..f485db36 100644 --- a/frontend/src/types/message.types.ts +++ b/frontend/src/types/message.types.ts @@ -42,7 +42,7 @@ export enum FileType { } // Attachments -export interface AttachmentAttrs { +export interface IAttachmentAttrs { name: string; type: string; size: number; @@ -51,15 +51,15 @@ export interface AttachmentAttrs { url?: string; } -export type AttachmentForeignKey = { +export type TAttachmentForeignKey = { id: string | null; /** @deprecated use id instead */ url?: string; }; -export interface AttachmentPayload { +export interface IAttachmentPayload { type: FileType; - payload: AttachmentForeignKey; + payload: TAttachmentForeignKey; } // Content @@ -95,7 +95,7 @@ export type Payload = } | { type: PayloadType.attachments; - attachments: AttachmentPayload; + attachments: IAttachmentPayload; }; export enum QuickReplyType { @@ -164,7 +164,7 @@ export type StdOutgoingListMessage = { }; export type StdOutgoingAttachmentMessage = { // Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying - attachment: AttachmentPayload; + attachment: IAttachmentPayload; quickReplies?: StdQuickReply[]; }; @@ -187,7 +187,7 @@ export type StdIncomingLocationMessage = { export type StdIncomingAttachmentMessage = { type: PayloadType.attachments; serialized_text: string; - attachment: AttachmentPayload | AttachmentPayload[]; + attachment: IAttachmentPayload | IAttachmentPayload[]; }; export type StdPluginMessage = { diff --git a/frontend/src/utils/attachment.ts b/frontend/src/utils/attachment.ts index 59c2ba8b..93254491 100644 --- a/frontend/src/utils/attachment.ts +++ b/frontend/src/utils/attachment.ts @@ -7,7 +7,8 @@ */ -import { AttachmentForeignKey, FileType } from "@/types/message.types"; +import { IAttachment } from "@/types/attachment.types"; +import { FileType, TAttachmentForeignKey } from "@/types/message.types"; import { buildURL } from "./URL"; @@ -40,9 +41,31 @@ export function getFileType(mimeType: string): FileType { export function getAttachmentDownloadUrl( baseUrl: string, - attachment: AttachmentForeignKey, + attachment: TAttachmentForeignKey | IAttachment, ) { return "id" in attachment && attachment.id ? buildURL(baseUrl, `/attachment/download/${attachment.id}`) : attachment.url; } + +export function extractFilenameFromUrl(url: string) { + try { + // Parse the URL to ensure it is valid + const parsedUrl = new URL(url); + // Extract the pathname (part after the domain) + const pathname = parsedUrl.pathname; + // Extract the last segment of the pathname + const filename = pathname.substring(pathname.lastIndexOf("/") + 1); + + // Check if a valid filename exists + if (filename && filename.includes(".")) { + return filename; + } + + // If no valid filename, return the full URL + return url; + } catch (error) { + // If the URL is invalid, return the input as-is + return url; + } +} \ No newline at end of file From 356b16aa9dcc4836521cfbe1da07f32c001003fe Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 17 Jan 2025 08:27:43 +0100 Subject: [PATCH 21/23] fix: rename payload to ensure consistency --- api/src/chat/schemas/types/quick-reply.ts | 2 +- api/src/chat/services/block.service.spec.ts | 2 +- api/src/extensions/channels/web/__test__/events.mock.ts | 2 +- api/src/extensions/channels/web/wrapper.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/chat/schemas/types/quick-reply.ts b/api/src/chat/schemas/types/quick-reply.ts index 18ae2b37..29ef17cd 100644 --- a/api/src/chat/schemas/types/quick-reply.ts +++ b/api/src/chat/schemas/types/quick-reply.ts @@ -19,7 +19,7 @@ export type Payload = } | { type: PayloadType.attachments; - attachments: AttachmentPayload; + attachment: AttachmentPayload; }; export enum QuickReplyType { diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index cf436248..fc489a26 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -389,7 +389,7 @@ describe('BlockService', () => { const result = blockService.matchPayload( { type: PayloadType.attachments, - attachments: { + attachment: { type: FileType.file, payload: { id: '9'.repeat(24), diff --git a/api/src/extensions/channels/web/__test__/events.mock.ts b/api/src/extensions/channels/web/__test__/events.mock.ts index 1c85ac63..4ffc80fb 100644 --- a/api/src/extensions/channels/web/__test__/events.mock.ts +++ b/api/src/extensions/channels/web/__test__/events.mock.ts @@ -146,7 +146,7 @@ export const webEvents: [string, Web.IncomingMessage, any][] = [ messageType: IncomingMessageType.attachments, payload: { type: IncomingMessageType.attachments, - attachments: { + attachment: { type: FileType.image, payload: { id: '9'.repeat(24), diff --git a/api/src/extensions/channels/web/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts index e576c7d6..decb9301 100644 --- a/api/src/extensions/channels/web/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -225,7 +225,7 @@ export default class WebEventWrapper< return { type: PayloadType.attachments, - attachments: { + attachment: { type: Attachment.getTypeByMime(this._adapter.raw.data.type), payload: { id: this._adapter.attachment.id, From b74c4a4e3a9a9ff558462a7d67d80447b299debc Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 17 Jan 2025 17:50:35 +0100 Subject: [PATCH 22/23] fix: migration --- api/src/migration/migration.service.ts | 2 +- .../1735836154221-v-2-2-0.migration.ts | 192 +++++++++--------- api/src/migration/types.ts | 2 +- 3 files changed, 103 insertions(+), 93 deletions(-) diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index 0459e395..5f8eb985 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -259,7 +259,7 @@ module.exports = { attachmentService: this.attachmentService, }); - if (result && migrationDocument) { + if (result) { await this.successCallback({ version, action, diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 2f699212..20160b41 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -208,70 +208,75 @@ const populateSubscriberAvatars = async ({ logger }: MigrationServices) => { const cursor = SubscriberModel.find().cursor(); for await (const subscriber of cursor) { - const foreignId = subscriber.foreign_id; - if (!foreignId) { - logger.debug(`No foreign id found for subscriber ${subscriber._id}`); - continue; - } + try { + const foreignId = subscriber.foreign_id; + if (!foreignId) { + logger.debug(`No foreign id found for subscriber ${subscriber._id}`); + continue; + } - const attachment = await AttachmentModel.findOne({ - name: RegExp(`^${foreignId}.jpe?g$`), - }); + const attachment = await AttachmentModel.findOne({ + name: RegExp(`^${foreignId}.jpe?g$`), + }); - if (attachment) { - await SubscriberModel.updateOne( - { _id: subscriber._id }, - { - $set: { - avatar: attachment._id, + if (attachment) { + await SubscriberModel.updateOne( + { _id: subscriber._id }, + { + $set: { + avatar: attachment._id, + }, }, - }, - ); - logger.log( - `Subscriber ${subscriber._id} avatar attribute successfully updated`, - ); + ); + logger.log( + `Subscriber ${subscriber._id} avatar attribute successfully updated`, + ); - await AttachmentModel.updateOne( - { _id: attachment._id }, - { - $set: { - resourceRef: AttachmentResourceRef.SubscriberAvatar, - access: 'private', - createdByRef: AttachmentCreatedByRef.Subscriber, - createdBy: subscriber._id, + await AttachmentModel.updateOne( + { _id: attachment._id }, + { + $set: { + resourceRef: AttachmentResourceRef.SubscriberAvatar, + access: 'private', + createdByRef: AttachmentCreatedByRef.Subscriber, + createdBy: subscriber._id, + }, }, - }, - ); + ); - logger.log( - `Subscriber ${subscriber._id} avatar attachment attributes successfully populated`, - ); + logger.log( + `Subscriber ${subscriber._id} avatar attachment attributes successfully populated`, + ); - const src = resolve( - join(config.parameters.uploadDir, attachment.location), - ); - if (existsSync(src)) { - try { - const dst = resolve( - join(config.parameters.avatarDir, attachment.location), + const src = resolve( + join(config.parameters.uploadDir, attachment.location), + ); + if (existsSync(src)) { + try { + const dst = resolve( + join(config.parameters.avatarDir, attachment.location), + ); + await moveFile(src, dst); + logger.log( + `Subscriber ${subscriber._id} avatar file successfully moved to the new "avatars" folder`, + ); + } catch (err) { + logger.error(err); + logger.warn(`Unable to move subscriber ${subscriber._id} avatar!`); + } + } else { + logger.warn( + `Subscriber ${subscriber._id} avatar attachment file was not found!`, ); - await moveFile(src, dst); - logger.log( - `Subscriber ${subscriber._id} avatar file successfully moved to the new "avatars" folder`, - ); - } catch (err) { - logger.error(err); - logger.warn(`Unable to move subscriber ${subscriber._id} avatar!`); } } else { logger.warn( - `Subscriber ${subscriber._id} avatar attachment file was not found!`, + `No avatar attachment found for subscriber ${subscriber._id}`, ); } - } else { - logger.warn( - `No avatar attachment found for subscriber ${subscriber._id}`, - ); + } catch (err) { + logger.error(err); + logger.error(`Unable to populate subscriber avatar ${subscriber._id}`); } } }; @@ -295,50 +300,55 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { const cursor = SubscriberModel.find({ avatar: { $exists: true } }).cursor(); for await (const subscriber of cursor) { - if (subscriber.avatar) { - const attachment = await AttachmentModel.findOne({ - _id: subscriber.avatar, - }); + try { + if (subscriber.avatar) { + const attachment = await AttachmentModel.findOne({ + _id: subscriber.avatar, + }); - if (attachment) { - // Move file to the old folder - const src = resolve( - join(config.parameters.avatarDir, attachment.location), - ); - if (existsSync(src)) { - try { - const dst = resolve( - join(config.parameters.uploadDir, attachment.location), - ); - await moveFile(src, dst); - logger.log( - `Avatar attachment successfully moved back to the old "avatars" folder`, - ); - } catch (err) { - logger.error(err); - logger.warn( - `Unable to move back subscriber ${subscriber._id} avatar to the old folder!`, - ); + if (attachment) { + // Move file to the old folder + const src = resolve( + join(config.parameters.avatarDir, attachment.location), + ); + if (existsSync(src)) { + try { + const dst = resolve( + join(config.parameters.uploadDir, attachment.location), + ); + await moveFile(src, dst); + logger.log( + `Avatar attachment successfully moved back to the old "avatars" folder`, + ); + } catch (err) { + logger.error(err); + logger.warn( + `Unable to move back subscriber ${subscriber._id} avatar to the old folder!`, + ); + } + } else { + logger.warn('Avatar attachment file was not found!'); } - } else { - logger.warn('Avatar attachment file was not found!'); - } - // Reset avatar to null - await SubscriberModel.updateOne( - { _id: subscriber._id }, - { - $set: { avatar: null }, - }, - ); - logger.log( - `Subscriber ${subscriber._id} avatar attribute successfully reverted to null`, - ); - } else { - logger.warn( - `No avatar attachment found for subscriber ${subscriber._id}`, - ); + // Reset avatar to null + await SubscriberModel.updateOne( + { _id: subscriber._id }, + { + $set: { avatar: null }, + }, + ); + logger.log( + `Subscriber ${subscriber._id} avatar attribute successfully reverted to null`, + ); + } else { + logger.warn( + `No avatar attachment found for subscriber ${subscriber._id}`, + ); + } } + } catch (err) { + logger.error(err); + logger.error(`Unable to unpopulate subscriber ${subscriber._id} avatar`); } } }; diff --git a/api/src/migration/types.ts b/api/src/migration/types.ts index a685ab87..26c44fe1 100644 --- a/api/src/migration/types.ts +++ b/api/src/migration/types.ts @@ -33,7 +33,7 @@ export interface MigrationRunOneParams extends MigrationRunParams { } export interface MigrationSuccessCallback extends MigrationRunParams { - migrationDocument: MigrationDocument; + migrationDocument: MigrationDocument | null; } export type MigrationServices = { From 3ed8bbcbe34facf51e7501ea35561abafd8a322e Mon Sep 17 00:00:00 2001 From: hexastack Date: Fri, 17 Jan 2025 19:10:39 +0100 Subject: [PATCH 23/23] feat: make storage mode configurable env var --- api/src/config/index.ts | 2 +- docker/.env.example | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 91d48b7f..4c8a66d5 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -111,7 +111,7 @@ export const config: Config = { process.env.UPLOAD_DIR || '/uploads', '/avatars', ), - storageMode: 'disk', + storageMode: (process.env.STORAGE_MODE as 'disk' | 'memory') || 'disk', maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES ? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES) : 50 * 1024 * 1024, // 50 MB in bytes diff --git a/docker/.env.example b/docker/.env.example index 66f0296a..d2941d2d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -17,7 +17,9 @@ HTTPS_ENABLED=false SESSION_SECRET=f661ff500fff6b0c8f91310b6fff6b0c SESSION_NAME=s.id # Relative attachments upload directory path to the app folder -UPLOAD_DIR=/uploads +UPLOAD_DIR=/uploads +# STORAGE MODE +STORAGE_MODE=disk # Max attachments upload size in bytes UPLOAD_MAX_SIZE_IN_BYTES=20971520 INVITATION_JWT_SECRET=dev_only