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>;
|
||||
Reference in New Issue
Block a user