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..e8c96d88 100644 --- a/api/src/attachment/controllers/attachment.controller.spec.ts +++ b/api/src/attachment/controllers/attachment.controller.spec.ts @@ -1,19 +1,29 @@ /* - * 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 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'; 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 { @@ -29,6 +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 { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '../types'; import { AttachmentController } from './attachment.controller'; @@ -42,14 +57,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 = @@ -62,7 +93,10 @@ describe('AttachmentController', () => { afterAll(closeInMongodConnection); - afterEach(jest.clearAllMocks); + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); describe('count', () => { it('should count attachments', async () => { @@ -76,29 +110,52 @@ 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, + { resourceRef: AttachmentResourceRef.BlockAttachment }, + ); await expect(promiseResult).rejects.toThrow( new BadRequestException('No file was selected'), ); }); it('should upload attachment', async () => { + jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(); + 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, + { resourceRef: AttachmentResourceRef.BlockAttachment }, + ); + 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}`)), + resourceRef: AttachmentResourceRef.BlockAttachment, + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, + createdBy: '9'.repeat(24), }); expect(result).toEqualPayload( - [attachment], - [...IGNORED_TEST_FIELDS, 'url'], + [ + { + ...attachment, + resourceRef: AttachmentResourceRef.BlockAttachment, + createdByRef: AttachmentCreatedByRef.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 3607903f..8f794e03 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,18 @@ 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'; +import { AttachmentAccess, AttachmentCreatedByRef } from '../types'; @UseInterceptors(CsrfInterceptor) @Controller('attachment') +@UseGuards(AttachmentGuard) export class AttachmentController extends BaseController { constructor( private readonly attachmentService: AttachmentService, @@ -61,7 +68,7 @@ export class AttachmentController extends BaseController { async filterCount( @Query( new SearchFilterPipe({ - allowedFields: ['name', 'type'], + allowedFields: ['name', 'type', 'resourceRef'], }), ) filters?: TFilterQuery, @@ -90,7 +97,9 @@ export class AttachmentController extends BaseController { async findPage( @Query(PageQueryPipe) pageQuery: PageQueryDto, @Query( - new SearchFilterPipe({ allowedFields: ['name', 'type'] }), + new SearchFilterPipe({ + allowedFields: ['name', 'type', 'resourceRef'], + }), ) filters: TFilterQuery, ) { @@ -114,26 +123,49 @@ 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() + { + resourceRef, + access = AttachmentAccess.Public, + }: 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: Attachment[] = []; + for (const file of files.file) { + const attachment = await this.attachmentService.store(file, { + name: file.originalname, + size: file.size, + type: file.mimetype, + resourceRef, + access, + createdBy: userId, + createdByRef: AttachmentCreatedByRef.User, + }); + + 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 ce180dc0..c10d0096 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,13 @@ import { import { ChannelName } from '@/channel/types'; import { ObjectIdDto } from '@/utils/dto/object-id.dto'; +import { IsObjectId } from '@/utils/validation-rules/is-object-id'; + +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '../types'; export class AttachmentMetadataDto { /** @@ -33,6 +43,7 @@ export class AttachmentMetadataDto { */ @ApiProperty({ description: 'Attachment size in bytes', type: Number }) @IsNotEmpty() + @IsNumber() size: number; /** @@ -41,6 +52,7 @@ export class AttachmentMetadataDto { @ApiProperty({ description: 'Attachment MIME type', type: String }) @IsNotEmpty() @IsString() + @IsMimeType() type: string; /** @@ -50,6 +62,54 @@ export class AttachmentMetadataDto { @IsNotEmpty() @IsObject() channel?: Partial>; + + /** + * Attachment resource reference + */ + @ApiProperty({ + description: 'Attachment Resource Ref', + enum: Object.values(AttachmentResourceRef), + }) + @IsString() + @IsNotEmpty() + @IsIn(Object.values(AttachmentResourceRef)) + resourceRef: AttachmentResourceRef; + + /** + * Attachment Owner Type + */ + @ApiProperty({ + description: 'Attachment Owner Type', + enum: Object.values(AttachmentCreatedByRef), + }) + @IsString() + @IsNotEmpty() + @IsIn(Object.values(AttachmentCreatedByRef)) + createdByRef: AttachmentCreatedByRef; + + /** + * Attachment Access + */ + @ApiProperty({ + description: 'Attachment Access', + enum: Object.values(AttachmentAccess), + }) + @IsString() + @IsNotEmpty() + @IsIn(Object.values(AttachmentAccess)) + access: AttachmentAccess; + + /** + * Attachment Owner : Subscriber or User ID + */ + @ApiProperty({ + description: 'Attachment Owner : Subscriber / User ID', + type: String, + }) + @IsString() + @IsNotEmpty() + @IsObjectId({ message: 'Owner must be a valid ObjectId' }) + createdBy: string; } export class AttachmentCreateDto extends AttachmentMetadataDto { @@ -75,3 +135,23 @@ export class AttachmentDownloadDto extends ObjectIdDto { @IsOptional() filename?: string; } + +export class AttachmentContextParamDto { + @ApiProperty({ + description: 'Attachment Resource Reference', + enum: Object.values(AttachmentResourceRef), + }) + @IsString() + @IsIn(Object.values(AttachmentResourceRef)) + @IsNotEmpty() + resourceRef: AttachmentResourceRef; + + @ApiPropertyOptional({ + description: 'Attachment Access', + enum: Object.values(AttachmentAccess), + }) + @IsString() + @IsIn(Object.values(AttachmentAccess)) + @IsOptional() + access?: AttachmentAccess; +} 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..395163ae --- /dev/null +++ b/api/src/attachment/guards/attachment-ability.guard.spec.ts @@ -0,0 +1,283 @@ +/* + * 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 { AttachmentResourceRef } from '../types'; + +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 ref', async () => { + const mockUser = { roles: ['admin-id'] } as any; + const mockRef = [AttachmentResourceRef.UserAvatar]; + + 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: { resourceRef: mockRef } }, + 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 ref', async () => { + const mockExecutionContext = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + query: { where: { resourceRef: 'invalid_ref' } }, + 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), + resourceRef: AttachmentResourceRef.UserAvatar, + } 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 a valid ref', 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: { resourceRef: AttachmentResourceRef.BlockAttachment }, + 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..224f0947 --- /dev/null +++ b/api/src/attachment/guards/attachment-ability.guard.ts @@ -0,0 +1,272 @@ +/* + * 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 { AttachmentResourceRef } from '../types'; +import { + isAttachmentResourceRef, + isAttachmentResourceRefArray, +} 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 ref + [Action.READ]: { + [AttachmentResourceRef.SettingAttachment]: [ + ['setting', Action.READ], + ['attachment', Action.READ], + ], + [AttachmentResourceRef.UserAvatar]: [['user', Action.READ]], + [AttachmentResourceRef.BlockAttachment]: [ + ['block', Action.READ], + ['attachment', Action.READ], + ], + [AttachmentResourceRef.ContentAttachment]: [ + ['content', Action.READ], + ['attachment', Action.READ], + ], + [AttachmentResourceRef.SubscriberAvatar]: [['subscriber', Action.READ]], + [AttachmentResourceRef.MessageAttachment]: [ + ['message', Action.READ], + ['attachment', Action.READ], + ], + }, + // Create attachments by ref + [Action.CREATE]: { + [AttachmentResourceRef.SettingAttachment]: [ + ['setting', Action.UPDATE], + ['attachment', Action.CREATE], + ], + [AttachmentResourceRef.UserAvatar]: [ + // Not authorized, done via /user/:id/edit endpoint + ], + [AttachmentResourceRef.BlockAttachment]: [ + ['block', Action.UPDATE], + ['attachment', Action.CREATE], + ], + [AttachmentResourceRef.ContentAttachment]: [ + ['content', Action.UPDATE], + ['attachment', Action.CREATE], + ], + [AttachmentResourceRef.SubscriberAvatar]: [ + // Not authorized, done programmatically by the channel + ], + [AttachmentResourceRef.MessageAttachment]: [ + // Unless we're in case of a handover, done programmatically by the channel + ['message', Action.CREATE], + ['attachment', Action.CREATE], + ], + }, + // Delete attachments by ref + [Action.DELETE]: { + [AttachmentResourceRef.SettingAttachment]: [ + ['setting', Action.UPDATE], + ['attachment', Action.DELETE], + ], + [AttachmentResourceRef.UserAvatar]: [ + // Not authorized + ], + [AttachmentResourceRef.BlockAttachment]: [ + ['block', Action.UPDATE], + ['attachment', Action.DELETE], + ], + [AttachmentResourceRef.ContentAttachment]: [ + ['content', Action.UPDATE], + ['attachment', Action.DELETE], + ], + [AttachmentResourceRef.SubscriberAvatar]: [ + // Not authorized, done programmatically by the channel + ], + [AttachmentResourceRef.MessageAttachment]: [ + // Not authorized + ], + }, + // Update attachments is not possible + [Action.UPDATE]: { + [AttachmentResourceRef.SettingAttachment]: [], + [AttachmentResourceRef.UserAvatar]: [], + [AttachmentResourceRef.BlockAttachment]: [], + [AttachmentResourceRef.ContentAttachment]: [], + [AttachmentResourceRef.SubscriberAvatar]: [], + [AttachmentResourceRef.MessageAttachment]: [], + }, + }; + + /** + * 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 the resource reference and user roles. + * + * @param action - The action on the attachment. + * @param user - The current user. + * @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( + action: Action, + user: Express.User & User, + resourceRef: AttachmentResourceRef, + ): Promise { + if (!action) { + throw new TypeError('Invalid action'); + } + + if (!resourceRef) { + throw new TypeError('Invalid resource ref'); + } + + const permissions = this.permissionMap[action][resourceRef]; + + 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.resourceRef, + ); + } else if (query.where) { + const { resourceRef = [] } = query.where as qs.ParsedQs; + + if (!isAttachmentResourceRefArray(resourceRef)) { + throw new BadRequestException('Invalid resource ref'); + } + + return ( + await Promise.all( + resourceRef.map((c) => this.isAuthorized(Action.READ, user, c)), + ) + ).every(Boolean); + } else { + throw new BadRequestException('Invalid params'); + } + } + // upload() endpoint + case 'POST': { + const { resourceRef = '' } = query; + if (!isAttachmentResourceRef(resourceRef)) { + throw new BadRequestException('Invalid resource ref'); + } + + return await this.isAuthorized(Action.CREATE, user, resourceRef); + } + // 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.resourceRef, + ); + } 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..025b929b 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. @@ -9,6 +9,11 @@ import { Stream } from 'node:stream'; import { Attachment } from '../schemas/attachment.schema'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '../types'; export const attachment: Attachment = { name: 'Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', @@ -16,9 +21,13 @@ export const attachment: Attachment = { size: 343370, location: '/Screenshot from 2022-03-11 08-41-27-2a9799a8b6109c88fd9a7a690c1101934c.png', + resourceRef: AttachmentResourceRef.BlockAttachment, + access: AttachmentAccess.Public, id: '65940d115178607da65c82b6', createdAt: new Date(), updatedAt: new Date(), + createdBy: '1', + createdByRef: AttachmentCreatedByRef.User, }; export const attachmentFile: Express.Multer.File = { @@ -28,7 +37,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 +51,14 @@ 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']: {} }, + resourceRef: AttachmentResourceRef.BlockAttachment, + access: AttachmentAccess.Public, id: '65940d115178607da65c82b7', createdAt: new Date(), updatedAt: new Date(), + createdBy: '1', + createdByRef: AttachmentCreatedByRef.User, }, { name: 'Screenshot from 2022-03-18 08-58-15-af61e7f71281f9fd3f1ad7ad10107741c.png', @@ -53,9 +66,13 @@ 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']: {} }, + resourceRef: AttachmentResourceRef.BlockAttachment, + access: AttachmentAccess.Public, id: '65940d115178607da65c82b8', createdAt: new Date(), updatedAt: new Date(), + createdBy: '1', + createdByRef: AttachmentCreatedByRef.User, }, ]; diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index fed8dd90..a4675379 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,31 @@ */ 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 { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} 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 +75,35 @@ 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 createdBy (depending on the createdBy type) + */ + @Prop({ + type: MongooseSchema.Types.ObjectId, + refPath: 'createdByRef', + default: null, + }) + createdBy: unknown; + + /** + * Type of the createdBy (depending on the createdBy type) + */ + @Prop({ type: String, enum: Object.values(AttachmentCreatedByRef) }) + createdByRef: AttachmentCreatedByRef; + + /** + * Resource reference of the attachment + */ + @Prop({ type: String, enum: Object.values(AttachmentResourceRef) }) + resourceRef: AttachmentResourceRef; + + /** + * Access level of the attachment + */ + @Prop({ type: String, enum: Object.values(AttachmentAccess) }) + access: AttachmentAccess; /** * Optional property representing the URL of the attachment. @@ -114,6 +145,24 @@ export class Attachment extends BaseSchema { } } +@Schema({ timestamps: true }) +export class Attachment extends AttachmentStub { + @Transform(({ obj }) => obj.createdBy?.toString() || null) + createdBy: string | null; +} + +@Schema({ timestamps: true }) +export class UserAttachmentFull extends AttachmentStub { + @Type(() => User) + createdBy: User | undefined; +} + +@Schema({ timestamps: true }) +export class SubscriberAttachmentFull extends AttachmentStub { + @Type(() => Subscriber) + createdBy: Subscriber | undefined; +} + export type AttachmentDocument = THydratedDocument; export const AttachmentModel: ModelDefinition = LifecycleHookManager.attach({ @@ -132,3 +181,10 @@ AttachmentModel.schema.virtual('url').get(function () { }); export default AttachmentModel.schema; + +export type AttachmentPopulate = keyof TFilterPopulateFields< + Attachment, + AttachmentStub +>; + +export const ATTACHMENT_POPULATE: AttachmentPopulate[] = ['createdBy']; diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 3cdae64a..94e55985 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 { @@ -29,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 { AttachmentResourceRef } from '../types'; import { fileExists, generateUniqueFilename, @@ -95,7 +97,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'); @@ -156,40 +158,16 @@ 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. + * Get the attachment root directory given the resource reference * - * @deprecated use store() instead - * @param files - An array of files to upload. - * @returns A promise that resolves to an array of uploaded attachments. + * @param ref The attachment resource reference + * @returns The root directory path */ - 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; + getRootDirByResourceRef(ref: AttachmentResourceRef) { + return ref === AttachmentResourceRef.SubscriberAvatar || + ref === AttachmentResourceRef.UserAvatar + ? config.parameters.avatarDir + : config.parameters.uploadDir; } /** @@ -204,21 +182,17 @@ export class AttachmentService extends BaseService { async store( file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, - rootDir = config.parameters.uploadDir, - ): Promise { + ): Promise { if (this.getStoragePlugin()) { - const storedDto = await this.getStoragePlugin()?.store?.( - file, - metadata, - rootDir, - ); - return storedDto ? await this.create(storedDto) : undefined; + const storedDto = await this.getStoragePlugin()?.store?.(file, metadata); + return storedDto ? await this.create(storedDto) : null; } else { + const rootDir = this.getRootDirByResourceRef(metadata.resourceRef); const uniqueFilename = generateUniqueFilename(metadata.name); 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); @@ -230,11 +204,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); } } @@ -253,10 +235,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); @@ -266,6 +245,7 @@ export class AttachmentService extends BaseService { return streamableFile; } else { + 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 new file mode 100644 index 00000000..66c613f7 --- /dev/null +++ b/api/src/attachment/types/index.ts @@ -0,0 +1,58 @@ +/* + * 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 { Readable, Stream } from 'stream'; + +/** + * Defines the types of createdBys for an attachment, + * indicating whether the file belongs to a User or a Subscriber. + */ +export enum AttachmentCreatedByRef { + User = 'User', + Subscriber = 'Subscriber', +} + +/** + * 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 AttachmentResourceRef { + 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 enum AttachmentAccess { + Public = 'public', + Private = 'private', +} + +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/attachment/utilities/index.ts b/api/src/attachment/utilities/index.ts index ed3905cc..13dead20 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 { AttachmentResourceRef } from '../types'; + export const MIME_REGEX = /^[a-z-]+\/[0-9a-z\-.]+$/gm; /** @@ -78,3 +80,32 @@ export const generateUniqueFilename = (originalname: string) => { const name = originalname.slice(0, -extension.length); return `${name}-${uuidv4()}${extension}`; }; + +/** + * Checks if the given ref is of type TAttachmentResourceRef. + * + * @param resourceRef - The ref to check. + * @returns True if the ref is of type TAttachmentResourceRef, otherwise false. + */ +export const isAttachmentResourceRef = ( + resourceRef: any, +): resourceRef is AttachmentResourceRef => { + return Object.values(AttachmentResourceRef).includes(resourceRef); +}; +AttachmentResourceRef; + +/** + * Checks if the given list is an array of TAttachmentResourceRef. + * + * @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 isAttachmentResourceRefArray = ( + refList: any, +): refList is AttachmentResourceRef[] => { + return ( + Array.isArray(refList) && + refList.length > 0 && + refList.every(isAttachmentResourceRef) + ); +}; 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); diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index c8a9e47a..73b4d121 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -6,8 +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 { 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, @@ -29,19 +29,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; @@ -72,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(), }, @@ -177,7 +181,7 @@ export default abstract class EventWrapper< * @returns event sender data */ getSender(): Subscriber { - return this._profile; + return this.subscriber; } /** @@ -186,7 +190,7 @@ export default abstract class EventWrapper< * @param profile - Sender data */ setSender(profile: Subscriber) { - this._profile = profile; + this.subscriber = profile; } /** @@ -194,9 +198,21 @@ 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 as EventWrapper< + any, + any, + ChannelName, + ChannelHandler, + Record + >, + ); + } } /** @@ -253,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 * @@ -383,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/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index d488cc72..a803ae5b 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, @@ -17,12 +18,22 @@ 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 { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentFile, + AttachmentResourceRef, +} 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 +61,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 +217,63 @@ 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, + resourceRef: AttachmentResourceRef.MessageAttachment, + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, + createdBy: subscriber.id, + }); + }), + ); + } + } + /** * Custom channel middleware * @param req @@ -263,6 +322,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 === AttachmentAccess.Public; + } + /** * Downloads an attachment using a signed token. * @@ -273,9 +345,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, @@ -283,6 +354,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/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index ebc85516..7146dc80 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -7,6 +7,11 @@ */ import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import { ButtonType } from '@/chat/schemas/types/button'; import { FileType, @@ -88,6 +93,10 @@ const attachment: Attachment = { id: 'any-channel-attachment-id', }, }, + resourceRef: AttachmentResourceRef.BlockAttachment, + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, + createdBy: null, createdAt: new Date(), updatedAt: new Date(), }; 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/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/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 5fa9fcc2..6f7512fd 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -8,7 +8,15 @@ 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 { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import EventWrapper from '@/channel/lib/EventWrapper'; import { config } from '@/config'; import { HelperService } from '@/helper/helper.service'; @@ -36,6 +44,7 @@ export class ChatService { private readonly botService: BotService, private readonly websocketGateway: WebsocketGateway, private readonly helperService: HelperService, + private readonly attachmentService: AttachmentService, ) {} /** @@ -248,10 +257,14 @@ 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); + + if (!subscriber) { + throw new Error('Unable to create a new subscriber'); + } } else { // Already existing user profile // Exec lastvisit hook @@ -260,14 +273,57 @@ export class ChatService { this.websocketGateway.broadcastSubscriberUpdate(subscriber); - event.setSender(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); - await event.preprocess(); + const avatar = await this.attachmentService.store(file, { + name: `avatar-${uuidv4()}.${extension}`, + size, + type, + resourceRef: AttachmentResourceRef.SubscriberAvatar, + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, + createdBy: subscriber.id, + }); + + if (avatar) { + subscriber = await this.subscriberService.updateOne( + subscriber.id, + { + 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 ${event.getSenderForeignId()}`, + err, + ); + } + } + + // Set the subscriber object + event.setSender(subscriber!); + + // Preprocess the event (persist attachments, ...) + if (event.preprocess) { + await event.preprocess(); + } // 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/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/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/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 99d2992f..81b4ea4e 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -15,6 +15,11 @@ import { v4 as uuidv4 } from 'uuid'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import { ChannelService } from '@/channel/channel.service'; import ChannelHandler from '@/channel/lib/Handler'; import { ChannelName } from '@/channel/types'; @@ -70,7 +75,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, @@ -604,6 +609,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'); @@ -616,17 +626,15 @@ 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, + resourceRef: AttachmentResourceRef.MessageAttachment, + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, + createdBy: 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', @@ -680,23 +688,20 @@ 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; } - 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, + resourceRef: AttachmentResourceRef.MessageAttachment, + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.Subscriber, + createdBy: req.session.web.profile?.id, + }); } catch (err) { this.logger.error( 'Web Channel Handler : Unable to store uploaded file', @@ -1328,7 +1333,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, @@ -1342,4 +1349,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 === AttachmentAccess.Public) { + return true; + } else if (!subscriberId) { + this.logger.warn( + `Unauthorized access attempt to attachment ${attachment.id}`, + ); + return false; + } else if ( + attachment.createdByRef === AttachmentCreatedByRef.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; + } + } } diff --git a/api/src/extensions/channels/web/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts index 094ee5ae..decb9301 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, @@ -226,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, @@ -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/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 9dd667db..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 @@ -15,24 +15,187 @@ import { v4 as uuidv4 } from 'uuid'; import attachmentSchema, { Attachment, } from '@/attachment/schemas/attachment.schema'; +import { + AttachmentAccess, + 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'; 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'; import { MigrationAction, MigrationServices } from '../types'; /** - * Updates subscriber documents with their corresponding avatar attachments - * and moves avatar files to a new directory. + * @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 attachment documents for blocks that contain "message.attachment". * * @returns Resolves when the migration process is complete. */ -const populateSubscriberAvatar = async ({ logger }: MigrationServices) => { +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: { + resourceRef: AttachmentResourceRef.BlockAttachment, + access: 'public', + createdByRef: AttachmentCreatedByRef.User, + createdBy: 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 (resourceRef, createdBy, createdByRef) + * + * @returns Resolves when the migration process is complete. + */ +const populateSettingAttachments = 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: { + resourceRef: AttachmentResourceRef.SettingAttachment, + access: 'public', + createdByRef: AttachmentCreatedByRef.User, + createdBy: 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 (resourceRef, createdBy, createdByRef) + * + * @returns Resolves when the migration process is complete. + */ +const populateUserAvatars = 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: { + resourceRef: AttachmentResourceRef.UserAvatar, + access: 'private', + createdByRef: AttachmentCreatedByRef.User, + createdBy: 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 subscriber documents with their corresponding avatar attachments, + * populate new attributes (resourceRef, createdBy, createdByRef) and moves avatar files to a new directory. + * + * @returns Resolves when the migration process is complete. + */ +const populateSubscriberAvatars = async ({ logger }: MigrationServices) => { const AttachmentModel = mongoose.model( Attachment.name, attachmentSchema, @@ -45,50 +208,75 @@ const populateSubscriberAvatar = 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 } }, - ); - logger.log( - `Subscriber ${subscriber._id} avatar attachment successfully updated for `, - ); + if (attachment) { + await SubscriberModel.updateOne( + { _id: subscriber._id }, + { + $set: { + avatar: attachment._id, + }, + }, + ); + logger.log( + `Subscriber ${subscriber._id} avatar attribute successfully updated`, + ); - const src = resolve( - join(config.parameters.uploadDir, attachment.location), - ); - if (existsSync(src)) { - try { - const dst = resolve( - join(config.parameters.avatarDir, attachment.location), + 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`, + ); + + 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}`); } } }; @@ -112,52 +300,104 @@ 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( - `Avatar attachment successfully updated for subscriber ${subscriber._id}`, - ); - } 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`); } } }; +/** + * Reverts the attachments additional attribute populate + * + * @returns Resolves when the migration process is complete. + */ +const undoPopulateAttachments = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + + try { + const result = await AttachmentModel.updateMany( + { + resourceRef: { + $in: [ + AttachmentResourceRef.BlockAttachment, + AttachmentResourceRef.SettingAttachment, + AttachmentResourceRef.UserAvatar, + AttachmentResourceRef.SubscriberAvatar, + AttachmentResourceRef.ContentAttachment, + AttachmentResourceRef.MessageAttachment, + ], + }, + }, + { + $unset: { + resourceRef: '', + access: '', + createdByRef: '', + createdBy: '', + }, + }, + ); + + logger.log( + `Successfully reverted attributes for ${result.modifiedCount} attachments with ref AttachmentResourceRef.SettingAttachment`, + ); + } catch (error) { + logger.error( + `Failed to revert attributes for attachments with ref AttachmentResourceRef.SettingAttachment: ${error.message}`, + ); + } +}; + /** * Migrates and updates the paths of old folder "avatars" files for subscribers and users. * @@ -325,7 +565,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 @@ -357,7 +597,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) { @@ -367,6 +614,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: AttachmentCreatedByRef.User, + access: AttachmentAccess.Public, + }, + }, + ); + } + } + await ContentModel.replaceOne({ _id: content._id }, content); } catch (error) { logger.error(`Failed to update content ${content._id}: ${error.message}`); @@ -383,7 +654,7 @@ const migrateAttachmentContents = async ( * * @returns Resolves when the migration process is complete. */ -const migrateAttachmentMessages = async ({ +const migrateAndPopulateAttachmentMessages = async ({ logger, http, attachmentService, @@ -407,6 +678,8 @@ const migrateAttachmentMessages = async ({ ); }; + const adminUser = await getAdminUser(); + for await (const msg of cursor) { try { if ( @@ -415,6 +688,19 @@ 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 as string, + { + resourceRef: AttachmentResourceRef.MessageAttachment, + access: AttachmentAccess.Private, + createdByRef: msg.sender + ? AttachmentCreatedByRef.Subscriber + : AttachmentCreatedByRef.User, + createdBy: msg.sender ? msg.sender : adminUser.id, + }, + ); + // Rename `attachment_id` to `id` await updateAttachmentId( msg._id, msg.message.attachment.payload.attachment_id as string, @@ -441,10 +727,20 @@ const migrateAttachmentMessages = async ({ size: fileBuffer.length, type: response.headers['content-type'], channel: {}, + resourceRef: AttachmentResourceRef.MessageAttachment, + access: msg.sender + ? AttachmentAccess.Private + : AttachmentAccess.Public, + createdBy: msg.sender ? msg.sender : adminUser.id, + createdByRef: msg.sender + ? AttachmentCreatedByRef.Subscriber + : AttachmentCreatedByRef.User, }); if (attachment) { await updateAttachmentId(msg._id, attachment.id); + } else { + logger.warn(`Unable to store attachment for message ${msg._id}`); } } } else { @@ -478,16 +774,20 @@ const migrateAttachmentMessages = async ({ module.exports = { async up(services: MigrationServices) { - 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 migrateAndPopulateAttachmentMessages(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 restoreOldAvatarsPath(services); await migrateAttachmentBlocks(MigrationAction.DOWN, services); 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 = { 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 5f8ff25d..a30dfba2 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -31,6 +31,11 @@ import { Session as ExpressSession } from 'express-session'; import { diskStorage, memoryStorage } from 'multer'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; import { config } from '@/config'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; @@ -110,10 +115,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,15 +295,15 @@ 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, - }, - config.parameters.avatarDir, - ) + ? await this.attachmentService.store(avatarFile, { + name: avatarFile.originalname, + size: avatarFile.size, + type: avatarFile.mimetype, + resourceRef: AttachmentResourceRef.UserAvatar, + access: AttachmentAccess.Private, + createdByRef: AttachmentCreatedByRef.User, + createdBy: req.user.id, + }) : undefined; const result = await this.userService.updateOne( 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/user/user.module.ts b/api/src/user/user.module.ts index 2b123ace..a44c414f 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, @@ -90,6 +90,6 @@ import { ValidateAccountService } from './services/validate-account.service'; PermissionController, ModelController, ], - exports: [UserService, PermissionService], + exports: [UserService, PermissionService, ModelService], }) export class UserModule {} diff --git a/api/src/utils/test/fixtures/attachment.ts b/api/src/utils/test/fixtures/attachment.ts index 3319401d..77ec0f93 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. @@ -10,6 +10,11 @@ import mongoose from 'mongoose'; import { AttachmentCreateDto } from '@/attachment/dto/attachment.dto'; import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; +import { + AttachmentAccess, + AttachmentCreatedByRef, + AttachmentResourceRef, +} from '@/attachment/types'; export const attachmentFixtures: AttachmentCreateDto[] = [ { @@ -22,8 +27,11 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '1', }, }, + resourceRef: AttachmentResourceRef.ContentAttachment, + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, + createdBy: '9'.repeat(24), }, - { name: 'store2.jpg', type: 'image/jpeg', @@ -34,6 +42,10 @@ export const attachmentFixtures: AttachmentCreateDto[] = [ id: '2', }, }, + resourceRef: AttachmentResourceRef.ContentAttachment, + access: AttachmentAccess.Public, + createdByRef: AttachmentCreatedByRef.User, + createdBy: '9'.repeat(24), }, ]; 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; 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 diff --git a/frontend/src/app-components/attachment/AttachmentInput.tsx b/frontend/src/app-components/attachment/AttachmentInput.tsx index 28c7a5ec..f3fb71dc 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 { AttachmentResourceRef, IAttachment } 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; + resourceRef: AttachmentResourceRef; }; const AttachmentInput = forwardRef( @@ -42,6 +44,7 @@ const AttachmentInput = forwardRef( onChange, error, helperText, + resourceRef, }, ref, ) => { @@ -81,6 +84,7 @@ const AttachmentInput = forwardRef( accept={accept} enableMediaLibrary={enableMediaLibrary} onChange={handleChange} + resourceRef={resourceRef} /> ) : null} {helperText ? ( diff --git a/frontend/src/app-components/attachment/AttachmentUploader.tsx b/frontend/src/app-components/attachment/AttachmentUploader.tsx index 73cdf176..858eee22 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 { AttachmentResourceRef, IAttachment } 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; + resourceRef: AttachmentResourceRef; }; const AttachmentUploader: FC = ({ @@ -74,6 +76,7 @@ const AttachmentUploader: FC = ({ enableMediaLibrary, onChange, onUploadComplete, + resourceRef, }) => { 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, resourceRef }); + } + }; 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..fb4accc7 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 { AttachmentResourceRef, IAttachment } 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; + resourceRef: AttachmentResourceRef; }; const MultipleAttachmentInput = forwardRef< @@ -44,6 +46,7 @@ const MultipleAttachmentInput = forwardRef< onChange, error, helperText, + resourceRef, }, ref, ) => { @@ -106,6 +109,7 @@ const MultipleAttachmentInput = forwardRef< accept={accept} enableMediaLibrary={enableMediaLibrary} onChange={(attachment) => handleChange(attachment)} + resourceRef={resourceRef} /> )} {helperText && ( diff --git a/frontend/src/components/contents/ContentDialog.tsx b/frontend/src/components/contents/ContentDialog.tsx index c04b3a75..164e69aa 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,6 +117,8 @@ const ContentFieldInput: React.FC = ({ value={field.value?.payload?.id} accept={MIME_TYPES["images"].join(",")} format="full" + size={256} + resourceRef={AttachmentResourceRef.ContentAttachment} /> ); default: diff --git a/frontend/src/components/contents/ContentImportDialog.tsx b/frontend/src/components/contents/ContentImportDialog.tsx index 4ed2a4f8..ddf613bb 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"; @@ -19,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<{ @@ -80,6 +82,7 @@ export const ContentImportDialog: FC = ({ }} label="" value={attachmentId} + resourceRef={AttachmentResourceRef.ContentAttachment} /> diff --git a/frontend/src/components/inbox/components/AttachmentViewer.tsx b/frontend/src/components/inbox/components/AttachmentViewer.tsx index e8b98306..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 { useConfig } from "@/hooks/useConfig"; import { useDialog } from "@/hooks/useDialog"; +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 { apiUrl } = useConfig(); - // 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)) { - 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; - return ; + if (!message.attachment) { + return <>No attachment to display; + } + + const attachments = Array.isArray(message.attachment) + ? message.attachment + : [message.attachment]; + + 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 ab883bf7..d1e57c40 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 { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata"; import { AnyButton as ButtonType, OutgoingPopulatedListMessage, @@ -188,6 +190,8 @@ const ListCard = forwardRef< buttons: ButtonType[]; } >(function ListCardRef(props, ref) { + 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/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, diff --git a/frontend/src/components/settings/SettingInput.tsx b/frontend/src/components/settings/SettingInput.tsx index f09ef9be..889ce979 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"; @@ -19,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"; @@ -185,6 +187,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} + resourceRef={AttachmentResourceRef.SettingAttachment} /> ); @@ -197,6 +200,7 @@ const SettingInput: React.FC = ({ accept={MIME_TYPES["images"].join(",")} format="full" size={128} + 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 35da99fb..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,6 +70,7 @@ const AttachmentMessageForm = () => { }, }); }} + resourceRef={AttachmentResourceRef.BlockAttachment} /> ); }} diff --git a/frontend/src/hooks/crud/useUpload.tsx b/frontend/src/hooks/crud/useUpload.tsx index 51202889..d7d8dcec 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 { AttachmentResourceRef } 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; resourceRef: AttachmentResourceRef }, + 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, resourceRef }) => { + const data = await api.upload(file, resourceRef); const { entities, result } = normalizeAndCache(data); // Invalidate all counts & collections 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/services/api.class.ts b/frontend/src/services/api.class.ts index 091c691d..360abf80 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 { 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"; @@ -301,7 +302,7 @@ export class EntityApiClient extends ApiClient { return data; } - async upload(file: File) { + async upload(file: File, resourceRef?: AttachmentResourceRef) { 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}${ + resourceRef ? `&resourceRef=${resourceRef}` : "" + }`, + 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..22a6c7ff 100644 --- a/frontend/src/types/attachment.types.ts +++ b/frontend/src/types/attachment.types.ts @@ -1,14 +1,44 @@ /* - * 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 AttachmentCreatedByRef { + User = "User", + Subscriber = "Subscriber", +} + +/** + * Defines the various resource references in which an attachment can exist. + * These references influence how the attachment is uploaded, stored, and accessed: + */ +export enum AttachmentResourceRef { + 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 enum AttachmentAccess { + Public = "public", + Private = "private", +} export interface IAttachmentAttributes { name: string; @@ -17,8 +47,22 @@ export interface IAttachmentAttributes { location: string; url: string; channel?: Record; + resourceRef: AttachmentResourceRef; + access: AttachmentAccess; + createdByRef: AttachmentCreatedByRef; + createdBy: 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 { + createdBy: string | null; +} + +export interface ISubscriberAttachmentFull + extends IAttachmentStub, + IFormat { + createdBy: (ISubscriber | IUser)[]; +} diff --git a/frontend/src/types/base.types.ts b/frontend/src/types/base.types.ts index d20d123a..29bb91d6 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]: ["createdBy"], [EntityType.CUSTOM_BLOCK]: [], [EntityType.CUSTOM_BLOCK_SETTINGS]: [], [EntityType.CHANNEL]: [], 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/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; diff --git a/frontend/src/utils/attachment.ts b/frontend/src/utils/attachment.ts index db7a3f86..93254491 100644 --- a/frontend/src/utils/attachment.ts +++ b/frontend/src/utils/attachment.ts @@ -1,12 +1,16 @@ /* - * 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 { IAttachment } from "@/types/attachment.types"; +import { FileType, TAttachmentForeignKey } from "@/types/message.types"; + +import { buildURL } from "./URL"; export const MIME_TYPES = { images: ["image/jpeg", "image/png", "image/gif", "image/webp"], @@ -34,3 +38,34 @@ export function getFileType(mimeType: string): FileType { return FileType.file; } } + +export function getAttachmentDownloadUrl( + baseUrl: string, + 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