Merge pull request #535 from Hexastack/feat/attachments-extra-attrs
Some checks failed
Build and Push Docker API Image / build-and-push (push) Has been cancelled
Build and Push Docker Base Image / build-and-push (push) Has been cancelled
Build and Push Docker UI Image / build-and-push (push) Has been cancelled

Feat/attachments extra attrs
This commit is contained in:
Med Marrouchi
2025-01-17 20:44:23 +01:00
committed by GitHub
53 changed files with 1988 additions and 407 deletions

View File

@@ -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],

View File

@@ -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'],
);
});
});

View File

@@ -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;
}
/**

View File

@@ -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;
}

View 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,
);
});
});
});

View 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');
}
}
}

View File

@@ -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,
},
];

View File

@@ -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'];

View File

@@ -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)) {

View 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;
}

View File

@@ -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)
);
};

View File

@@ -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);

View File

@@ -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
*

View File

@@ -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);

View File

@@ -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(),
};

View File

@@ -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 ...',

View File

@@ -19,7 +19,7 @@ export type Payload =
}
| {
type: PayloadType.attachments;
attachments: AttachmentPayload;
attachment: AttachmentPayload;
};
export enum QuickReplyType {

View File

@@ -389,7 +389,7 @@ describe('BlockService', () => {
const result = blockService.matchPayload(
{
type: PayloadType.attachments,
attachments: {
attachment: {
type: FileType.file,
payload: {
id: '9'.repeat(24),

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

@@ -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;
}
}
}

View File

@@ -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
*

View File

@@ -259,7 +259,7 @@ module.exports = {
attachmentService: this.attachmentService,
});
if (result && migrationDocument) {
if (result) {
await this.successCallback({
version,
action,

View File

@@ -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);

View File

@@ -33,7 +33,7 @@ export interface MigrationRunOneParams extends MigrationRunParams {
}
export interface MigrationSuccessCallback extends MigrationRunParams {
migrationDocument: MigrationDocument;
migrationDocument: MigrationDocument | null;
}
export type MigrationServices = {

View File

@@ -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>;
}

View File

@@ -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(

View File

@@ -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';

View File

@@ -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 {}

View File

@@ -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),
},
];

View 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>;