mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
Merge pull request #535 from Hexastack/feat/attachments-extra-attrs
Feat/attachments extra attrs
This commit is contained in:
@@ -13,6 +13,7 @@ import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
import { config } from '@/config';
|
||||
import { UserModule } from '@/user/user.module';
|
||||
|
||||
import { AttachmentController } from './controllers/attachment.controller';
|
||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||
@@ -25,6 +26,7 @@ import { AttachmentService } from './services/attachment.service';
|
||||
PassportModule.register({
|
||||
session: true,
|
||||
}),
|
||||
UserModule,
|
||||
],
|
||||
providers: [AttachmentRepository, AttachmentService],
|
||||
controllers: [AttachmentController],
|
||||
|
||||
@@ -1,19 +1,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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Attachment> {
|
||||
constructor(
|
||||
private readonly attachmentService: AttachmentService,
|
||||
@@ -61,7 +68,7 @@ export class AttachmentController extends BaseController<Attachment> {
|
||||
async filterCount(
|
||||
@Query(
|
||||
new SearchFilterPipe<Attachment>({
|
||||
allowedFields: ['name', 'type'],
|
||||
allowedFields: ['name', 'type', 'resourceRef'],
|
||||
}),
|
||||
)
|
||||
filters?: TFilterQuery<Attachment>,
|
||||
@@ -90,7 +97,9 @@ export class AttachmentController extends BaseController<Attachment> {
|
||||
async findPage(
|
||||
@Query(PageQueryPipe) pageQuery: PageQueryDto<Attachment>,
|
||||
@Query(
|
||||
new SearchFilterPipe<Attachment>({ allowedFields: ['name', 'type'] }),
|
||||
new SearchFilterPipe<Attachment>({
|
||||
allowedFields: ['name', 'type', 'resourceRef'],
|
||||
}),
|
||||
)
|
||||
filters: TFilterQuery<Attachment>,
|
||||
) {
|
||||
@@ -114,26 +123,49 @@ export class AttachmentController extends BaseController<Attachment> {
|
||||
if (config.parameters.storageMode === 'memory') {
|
||||
return memoryStorage();
|
||||
} else {
|
||||
return diskStorage({
|
||||
destination: config.parameters.uploadDir,
|
||||
filename: (req, file, cb) => {
|
||||
const name = file.originalname.split('.')[0];
|
||||
const extension = extname(file.originalname);
|
||||
cb(null, `${name}-${uuidv4()}${extension}`);
|
||||
},
|
||||
});
|
||||
return diskStorage({});
|
||||
}
|
||||
})(),
|
||||
}),
|
||||
)
|
||||
async uploadFile(
|
||||
@UploadedFiles() files: { file: Express.Multer.File[] },
|
||||
@Req() req: Request,
|
||||
@Query()
|
||||
{
|
||||
resourceRef,
|
||||
access = AttachmentAccess.Public,
|
||||
}: AttachmentContextParamDto,
|
||||
): Promise<Attachment[]> {
|
||||
if (!files || !Array.isArray(files?.file) || files.file.length === 0) {
|
||||
throw new BadRequestException('No file was selected');
|
||||
}
|
||||
|
||||
return await this.attachmentService.uploadFiles(files);
|
||||
const userId = req.session?.passport?.user?.id;
|
||||
if (!userId) {
|
||||
throw new ForbiddenException(
|
||||
'Unexpected Error: Only authenticated users are allowed to upload',
|
||||
);
|
||||
}
|
||||
|
||||
const attachments: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Record<ChannelName, any>>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
283
api/src/attachment/guards/attachment-ability.guard.spec.ts
Normal file
283
api/src/attachment/guards/attachment-ability.guard.spec.ts
Normal file
@@ -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>(AttachmentGuard);
|
||||
permissionService = module.get<PermissionService>(PermissionService);
|
||||
modelService = module.get<ModelService>(ModelService);
|
||||
attachmentService = module.get<AttachmentService>(AttachmentService);
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
it('should allow GET requests with valid 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
272
api/src/attachment/guards/attachment-ability.guard.ts
Normal file
272
api/src/attachment/guards/attachment-ability.guard.ts
Normal file
@@ -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<AttachmentResourceRef, [TModel, Action][]>
|
||||
> = {
|
||||
// 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<boolean> {
|
||||
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<boolean> {
|
||||
const { query, _parsedUrl, method, user, params } = ctx
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user: User; _parsedUrl: Url }>();
|
||||
|
||||
switch (method) {
|
||||
// count(), find() and findOne() endpoints
|
||||
case 'GET': {
|
||||
if (params && 'id' in params && Types.ObjectId.isValid(params.id)) {
|
||||
const attachment = await this.attachmentService.findOne(params.id);
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found!');
|
||||
}
|
||||
|
||||
return await this.isAuthorized(
|
||||
Action.READ,
|
||||
user,
|
||||
attachment.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
@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<Record<string, any>>;
|
||||
channel?: Partial<Record<ChannelName, any>>;
|
||||
|
||||
/**
|
||||
* 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<Attachment>;
|
||||
|
||||
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'];
|
||||
|
||||
@@ -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<Attachment> {
|
||||
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<Attachment> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Attachment> {
|
||||
async store(
|
||||
file: Buffer | Stream | Readable | Express.Multer.File,
|
||||
metadata: AttachmentMetadataDto,
|
||||
rootDir = config.parameters.uploadDir,
|
||||
): Promise<Attachment | undefined> {
|
||||
): Promise<Attachment | null> {
|
||||
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<Attachment> {
|
||||
} 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<Attachment> {
|
||||
* @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<StreamableFile> {
|
||||
async download(attachment: Attachment): Promise<StreamableFile> {
|
||||
if (this.getStoragePlugin()) {
|
||||
const streamableFile =
|
||||
await this.getStoragePlugin()?.download(attachment);
|
||||
@@ -266,6 +245,7 @@ export class AttachmentService extends BaseService<Attachment> {
|
||||
|
||||
return streamableFile;
|
||||
} else {
|
||||
const rootDir = this.getRootDirByResourceRef(attachment.resourceRef);
|
||||
const path = resolve(join(rootDir, attachment.location));
|
||||
|
||||
if (!fileExists(path)) {
|
||||
|
||||
58
api/src/attachment/types/index.ts
Normal file
58
api/src/attachment/types/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<N>,
|
||||
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<ChannelName>,
|
||||
Record<string, any>
|
||||
>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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<N>[];
|
||||
|
||||
@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<any, any, N>,
|
||||
): Promise<AttachmentFile[]>;
|
||||
|
||||
/**
|
||||
* Fetch the subscriber profile data
|
||||
* @param event - The message event received
|
||||
* @returns {Promise<Subscriber>} - The channel's response, otherwise an error
|
||||
|
||||
*/
|
||||
abstract getUserData(
|
||||
getSubscriberAvatar?(
|
||||
event: EventWrapper<any, any, N>,
|
||||
): Promise<AttachmentFile | undefined>;
|
||||
|
||||
/**
|
||||
* Fetch the subscriber profile data
|
||||
*
|
||||
* @param event - The message event received
|
||||
* @returns {Promise<Subscriber>} - The channel's response, otherwise an error
|
||||
*/
|
||||
abstract getSubscriberData(
|
||||
event: EventWrapper<any, any, N>,
|
||||
): Promise<SubscriberCreateDto>;
|
||||
|
||||
/**
|
||||
* Persist Message attachments
|
||||
*
|
||||
* @returns Resolves the promise once attachments are fetched and stored
|
||||
*/
|
||||
async persistMessageAttachments(event: EventWrapper<any, any, N>) {
|
||||
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);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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 ...',
|
||||
|
||||
@@ -19,7 +19,7 @@ export type Payload =
|
||||
}
|
||||
| {
|
||||
type: PayloadType.attachments;
|
||||
attachments: AttachmentPayload;
|
||||
attachment: AttachmentPayload;
|
||||
};
|
||||
|
||||
export enum QuickReplyType {
|
||||
|
||||
@@ -389,7 +389,7 @@ describe('BlockService', () => {
|
||||
const result = blockService.matchPayload(
|
||||
{
|
||||
type: PayloadType.attachments,
|
||||
attachments: {
|
||||
attachment: {
|
||||
type: FileType.file,
|
||||
payload: {
|
||||
id: '9'.repeat(24),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<N>): Promise<SubscriberCreateDto> {
|
||||
async getSubscriberData(
|
||||
event: WebEventWrapper<N>,
|
||||
): Promise<SubscriberCreateDto> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -259,7 +259,7 @@ module.exports = {
|
||||
attachmentService: this.attachmentService,
|
||||
});
|
||||
|
||||
if (result && migrationDocument) {
|
||||
if (result) {
|
||||
await this.successCallback({
|
||||
version,
|
||||
action,
|
||||
|
||||
@@ -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>(Role.name, roleSchema);
|
||||
const UserModel = mongoose.model<User>(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>(
|
||||
Attachment.name,
|
||||
attachmentSchema,
|
||||
);
|
||||
const BlockModel = mongoose.model<Block>(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>(
|
||||
Attachment.name,
|
||||
attachmentSchema,
|
||||
);
|
||||
const SettingModel = mongoose.model<Setting>(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>(
|
||||
Attachment.name,
|
||||
attachmentSchema,
|
||||
);
|
||||
const UserModel = mongoose.model<User>(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>(
|
||||
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>(
|
||||
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>(Content.name, contentSchema);
|
||||
// Find blocks where "message.attachment" exists
|
||||
const AttachmentModel = mongoose.model<Attachment>(
|
||||
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);
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface MigrationRunOneParams extends MigrationRunParams {
|
||||
}
|
||||
|
||||
export interface MigrationSuccessCallback extends MigrationRunParams {
|
||||
migrationDocument: MigrationDocument;
|
||||
migrationDocument: MigrationDocument | null;
|
||||
}
|
||||
|
||||
export type MigrationServices = {
|
||||
|
||||
@@ -37,10 +37,7 @@ export abstract class BaseStoragePlugin extends BasePlugin {
|
||||
/** @deprecated use store() instead */
|
||||
uploadAvatar?(file: Express.Multer.File): Promise<any>;
|
||||
|
||||
abstract download(
|
||||
attachment: Attachment,
|
||||
rootLocation?: string,
|
||||
): Promise<StreamableFile>;
|
||||
abstract download(attachment: Attachment): Promise<StreamableFile>;
|
||||
|
||||
/** @deprecated use download() instead */
|
||||
downloadProfilePic?(name: string): Promise<StreamableFile>;
|
||||
@@ -52,6 +49,5 @@ export abstract class BaseStoragePlugin extends BasePlugin {
|
||||
store?(
|
||||
file: Buffer | Stream | Readable | Express.Multer.File,
|
||||
metadata: AttachmentMetadataDto,
|
||||
rootDir?: string,
|
||||
): Promise<Attachment>;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
16
api/src/utils/test/fixtures/attachment.ts
vendored
16
api/src/utils/test/fixtures/attachment.ts
vendored
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 Hexastack. All rights reserved.
|
||||
*
|
||||
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
||||
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
9
api/src/utils/types/misc.ts
Normal file
9
api/src/utils/types/misc.ts
Normal file
@@ -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<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
@@ -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
|
||||
|
||||
@@ -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<HTMLDivElement, AttachmentThumbnailProps>(
|
||||
@@ -42,6 +44,7 @@ const AttachmentInput = forwardRef<HTMLDivElement, AttachmentThumbnailProps>(
|
||||
onChange,
|
||||
error,
|
||||
helperText,
|
||||
resourceRef,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -81,6 +84,7 @@ const AttachmentInput = forwardRef<HTMLDivElement, AttachmentThumbnailProps>(
|
||||
accept={accept}
|
||||
enableMediaLibrary={enableMediaLibrary}
|
||||
onChange={handleChange}
|
||||
resourceRef={resourceRef}
|
||||
/>
|
||||
) : null}
|
||||
{helperText ? (
|
||||
|
||||
@@ -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<FileUploadProps> = ({
|
||||
@@ -74,6 +76,7 @@ const AttachmentUploader: FC<FileUploadProps> = ({
|
||||
enableMediaLibrary,
|
||||
onChange,
|
||||
onUploadComplete,
|
||||
resourceRef,
|
||||
}) => {
|
||||
const [attachment, setAttachment] = useState<IAttachment | undefined>(
|
||||
undefined,
|
||||
@@ -97,34 +100,40 @@ const AttachmentUploader: FC<FileUploadProps> = ({
|
||||
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<HTMLInputElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||
const file = event.dataTransfer.files.item(0);
|
||||
|
||||
if (file) {
|
||||
uploadAttachment(file);
|
||||
}
|
||||
handleUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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<ContentFieldInput> = ({
|
||||
value={field.value?.payload?.id}
|
||||
accept={MIME_TYPES["images"].join(",")}
|
||||
format="full"
|
||||
size={256}
|
||||
resourceRef={AttachmentResourceRef.ContentAttachment}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -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<ContentImportDialogProps> = ({
|
||||
}}
|
||||
label=""
|
||||
value={attachmentId}
|
||||
resourceRef={AttachmentResourceRef.ContentAttachment}
|
||||
/>
|
||||
</ContentItem>
|
||||
</ContentContainer>
|
||||
|
||||
@@ -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<AttachmentInterface> } = {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span style={{ fontWeight: "bold" }}>{t("label.attachment")}: </span>
|
||||
<Box>
|
||||
<Typography
|
||||
component="span"
|
||||
className="cs-message__text-content"
|
||||
mr={2}
|
||||
>
|
||||
{props.name}
|
||||
</Typography>
|
||||
<Button
|
||||
href={props.url}
|
||||
endIcon={<DownloadIcon />}
|
||||
color="inherit"
|
||||
variant="text"
|
||||
variant="contained"
|
||||
>
|
||||
{t("button.download")}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[FileType.video]: ({ url }: AttachmentInterface) => (
|
||||
@@ -91,23 +99,43 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
|
||||
[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 <AttachmentViewerForType url={metadata.url} name={metadata.name} />;
|
||||
};
|
||||
|
||||
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 <AttachmentViewerForType url={url} />;
|
||||
if (!message.attachment) {
|
||||
return <>No attachment to display</>;
|
||||
}
|
||||
|
||||
const attachments = Array.isArray(message.attachment)
|
||||
? message.attachment
|
||||
: [message.attachment];
|
||||
|
||||
return attachments.map((attachment, idx) => {
|
||||
return (
|
||||
<MessageAttachmentViewer
|
||||
key={`${attachment.payload.id}-${idx}`}
|
||||
attachment={attachment}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Card
|
||||
style={{
|
||||
@@ -201,9 +205,9 @@ const ListCard = forwardRef<
|
||||
ref={ref}
|
||||
id={"A" + props.id}
|
||||
>
|
||||
{props.content.image_url ? (
|
||||
{metadata ? (
|
||||
<CardMedia
|
||||
image={props.content.image_url?.payload?.url as string}
|
||||
image={metadata.url}
|
||||
sx={{ height: "185px" }}
|
||||
title={props.content.title}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
<Message.CustomContent>
|
||||
<AttachmentViewer message={message} />
|
||||
<MessageAttachmentsViewer message={message} />
|
||||
</Message.CustomContent>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RenderSettingInputProps> = ({
|
||||
accept={MIME_TYPES["images"].join(",")}
|
||||
format="full"
|
||||
size={128}
|
||||
resourceRef={AttachmentResourceRef.SettingAttachment}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -197,6 +200,7 @@ const SettingInput: React.FC<RenderSettingInputProps> = ({
|
||||
accept={MIME_TYPES["images"].join(",")}
|
||||
format="full"
|
||||
size={128}
|
||||
resourceRef={AttachmentResourceRef.SettingAttachment}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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<TBasic, Error, File, TBasic>,
|
||||
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
|
||||
|
||||
52
frontend/src/hooks/useGetAttachmentMetadata.ts
Normal file
52
frontend/src/hooks/useGetAttachmentMetadata.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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<TAttr, TBasic, TFull> 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<TAttr, TBasic, TFull> extends ApiClient {
|
||||
TBasic[],
|
||||
AxiosResponse<TBasic[]>,
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -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<string, any>;
|
||||
resourceRef: AttachmentResourceRef;
|
||||
access: AttachmentAccess;
|
||||
createdByRef: AttachmentCreatedByRef;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
export interface IAttachmentStub extends IBaseSchema, IAttachmentAttributes {}
|
||||
export interface IAttachmentStub
|
||||
extends IBaseSchema,
|
||||
OmitPopulate<IAttachmentAttributes, EntityType.ATTACHMENT> {}
|
||||
|
||||
export interface IAttachment extends IAttachmentStub, IFormat<Format.BASIC> {}
|
||||
export interface IAttachment extends IAttachmentStub, IFormat<Format.BASIC> {
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
export interface ISubscriberAttachmentFull
|
||||
extends IAttachmentStub,
|
||||
IFormat<Format.FULL> {
|
||||
createdBy: (ISubscriber | IUser)[];
|
||||
}
|
||||
|
||||
@@ -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]: [],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user