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

View File

@@ -17,7 +17,9 @@ HTTPS_ENABLED=false
SESSION_SECRET=f661ff500fff6b0c8f91310b6fff6b0c
SESSION_NAME=s.id
# Relative attachments upload directory path to the app folder
UPLOAD_DIR=/uploads
UPLOAD_DIR=/uploads
# STORAGE MODE
STORAGE_MODE=disk
# Max attachments upload size in bytes
UPLOAD_MAX_SIZE_IN_BYTES=20971520
INVITATION_JWT_SECRET=dev_only

View File

@@ -1,18 +1,19 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Box, FormHelperText, FormLabel } from "@mui/material";
import { forwardRef } from "react";
import { useGet } from "@/hooks/crud/useGet";
import { useHasPermission } from "@/hooks/useHasPermission";
import { EntityType } from "@/services/types";
import { IAttachment } from "@/types/attachment.types";
import { AttachmentResourceRef, IAttachment } from "@/types/attachment.types";
import { PermissionAction } from "@/types/permission.types";
import AttachmentThumbnail from "./AttachmentThumbnail";
@@ -28,6 +29,7 @@ type AttachmentThumbnailProps = {
onChange?: (id: string | null, mimeType: string | null) => void;
error?: boolean;
helperText?: string;
resourceRef: AttachmentResourceRef;
};
const AttachmentInput = forwardRef<HTMLDivElement, AttachmentThumbnailProps>(
@@ -42,6 +44,7 @@ const AttachmentInput = forwardRef<HTMLDivElement, AttachmentThumbnailProps>(
onChange,
error,
helperText,
resourceRef,
},
ref,
) => {
@@ -81,6 +84,7 @@ const AttachmentInput = forwardRef<HTMLDivElement, AttachmentThumbnailProps>(
accept={accept}
enableMediaLibrary={enableMediaLibrary}
onChange={handleChange}
resourceRef={resourceRef}
/>
) : null}
{helperText ? (

View File

@@ -1,11 +1,12 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import FolderCopyIcon from "@mui/icons-material/FolderCopy";
import { Box, Button, Divider, Grid, styled, Typography } from "@mui/material";
@@ -16,7 +17,7 @@ import { getDisplayDialogs, useDialog } from "@/hooks/useDialog";
import { useToast } from "@/hooks/useToast";
import { useTranslate } from "@/hooks/useTranslate";
import { EntityType } from "@/services/types";
import { IAttachment } from "@/types/attachment.types";
import { AttachmentResourceRef, IAttachment } from "@/types/attachment.types";
import { AttachmentDialog } from "./AttachmentDialog";
import AttachmentThumbnail from "./AttachmentThumbnail";
@@ -67,6 +68,7 @@ export type FileUploadProps = {
enableMediaLibrary?: boolean;
onChange?: (data?: IAttachment | null) => void;
onUploadComplete?: () => void;
resourceRef: AttachmentResourceRef;
};
const AttachmentUploader: FC<FileUploadProps> = ({
@@ -74,6 +76,7 @@ const AttachmentUploader: FC<FileUploadProps> = ({
enableMediaLibrary,
onChange,
onUploadComplete,
resourceRef,
}) => {
const [attachment, setAttachment] = useState<IAttachment | undefined>(
undefined,
@@ -97,34 +100,40 @@ const AttachmentUploader: FC<FileUploadProps> = ({
e.stopPropagation();
e.preventDefault();
};
const handleUpload = (file: File | null) => {
if (file) {
const acceptedTypes = accept.split(",");
const isValidType = acceptedTypes.some((mimeType) => {
const [type, subtype] = mimeType.split("/");
if (!type || !subtype) return false; // Ensure valid MIME type
return (
file.type === mimeType ||
(subtype === "*" && file.type.startsWith(`${type}/`))
);
});
if (!isValidType) {
toast.error(t("message.invalid_file_type"));
return;
}
uploadAttachment({ file, resourceRef });
}
};
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files.item(0);
if (file) {
const acceptedTypes = accept.split(",");
const isValidType = acceptedTypes.some(
(type) =>
file.type === type || file.name.endsWith(type.replace(".*", "")),
);
if (!isValidType) {
toast.error(t("message.invalid_file_type"));
return;
}
uploadAttachment(file);
}
handleUpload(file);
}
};
const onDrop = (event: DragEvent<HTMLElement>) => {
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files.item(0);
if (file) {
uploadAttachment(file);
}
handleUpload(file);
}
};

View File

@@ -1,17 +1,18 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Box, Button, FormHelperText, FormLabel } from "@mui/material";
import { forwardRef, useState } from "react";
import { useHasPermission } from "@/hooks/useHasPermission";
import { EntityType } from "@/services/types";
import { IAttachment } from "@/types/attachment.types";
import { AttachmentResourceRef, IAttachment } from "@/types/attachment.types";
import { PermissionAction } from "@/types/permission.types";
import AttachmentThumbnail from "./AttachmentThumbnail";
@@ -27,6 +28,7 @@ type MultipleAttachmentInputProps = {
onChange?: (ids: string[]) => void;
error?: boolean;
helperText?: string;
resourceRef: AttachmentResourceRef;
};
const MultipleAttachmentInput = forwardRef<
@@ -44,6 +46,7 @@ const MultipleAttachmentInput = forwardRef<
onChange,
error,
helperText,
resourceRef,
},
ref,
) => {
@@ -106,6 +109,7 @@ const MultipleAttachmentInput = forwardRef<
accept={accept}
enableMediaLibrary={enableMediaLibrary}
onChange={(attachment) => handleChange(attachment)}
resourceRef={resourceRef}
/>
)}
{helperText && (

View File

@@ -38,6 +38,7 @@ import { DialogControlProps } from "@/hooks/useDialog";
import { useToast } from "@/hooks/useToast";
import { useTranslate } from "@/hooks/useTranslate";
import { EntityType } from "@/services/types";
import { AttachmentResourceRef } from "@/types/attachment.types";
import {
ContentField,
ContentFieldType,
@@ -116,6 +117,8 @@ const ContentFieldInput: React.FC<ContentFieldInput> = ({
value={field.value?.payload?.id}
accept={MIME_TYPES["images"].join(",")}
format="full"
size={256}
resourceRef={AttachmentResourceRef.ContentAttachment}
/>
);
default:

View File

@@ -1,11 +1,12 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import CloseIcon from "@mui/icons-material/Close";
import { Button, Dialog, DialogActions, DialogContent } from "@mui/material";
import { FC, useState } from "react";
@@ -19,6 +20,7 @@ import { useApiClient } from "@/hooks/useApiClient";
import { DialogControlProps } from "@/hooks/useDialog";
import { useToast } from "@/hooks/useToast";
import { useTranslate } from "@/hooks/useTranslate";
import { AttachmentResourceRef } from "@/types/attachment.types";
import { IContentType } from "@/types/content-type.types";
export type ContentImportDialogProps = DialogControlProps<{
@@ -80,6 +82,7 @@ export const ContentImportDialog: FC<ContentImportDialogProps> = ({
}}
label=""
value={attachmentId}
resourceRef={AttachmentResourceRef.ContentAttachment}
/>
</ContentItem>
</ContentContainer>

View File

@@ -7,20 +7,22 @@
*/
import DownloadIcon from "@mui/icons-material/Download";
import { Button, Dialog, DialogContent } from "@mui/material";
import { Box, Button, Dialog, DialogContent, Typography } from "@mui/material";
import { FC } from "react";
import { DialogTitle } from "@/app-components/dialogs";
import { useConfig } from "@/hooks/useConfig";
import { useDialog } from "@/hooks/useDialog";
import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata";
import { useTranslate } from "@/hooks/useTranslate";
import {
FileType,
IAttachmentPayload,
StdIncomingAttachmentMessage,
StdOutgoingAttachmentMessage,
} from "@/types/message.types";
interface AttachmentInterface {
name?: string;
url?: string;
}
@@ -70,17 +72,23 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
const { t } = useTranslate();
return (
<div>
<span style={{ fontWeight: "bold" }}>{t("label.attachment")}: </span>
<Box>
<Typography
component="span"
className="cs-message__text-content"
mr={2}
>
{props.name}
</Typography>
<Button
href={props.url}
endIcon={<DownloadIcon />}
color="inherit"
variant="text"
variant="contained"
>
{t("button.download")}
</Button>
</div>
</Box>
);
},
[FileType.video]: ({ url }: AttachmentInterface) => (
@@ -91,23 +99,43 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
[FileType.unknown]: ({ url }: AttachmentInterface) => <>Unknown Type:{url}</>,
};
export const AttachmentViewer = (props: {
export const MessageAttachmentViewer = ({
attachment,
}: {
attachment: IAttachmentPayload;
}) => {
const metadata = useGetAttachmentMetadata(attachment.payload);
const AttachmentViewerForType = componentMap[attachment.type];
if (!metadata) {
return <>No attachment to display</>;
}
return <AttachmentViewerForType url={metadata.url} name={metadata.name} />;
};
export const MessageAttachmentsViewer = (props: {
message: StdIncomingAttachmentMessage | StdOutgoingAttachmentMessage;
}) => {
const message = props.message;
const { apiUrl } = useConfig();
// if the attachment is an array show a 4x4 grid with a +{number of remaining attachment} and open a modal to show the list of attachments
// Remark: Messenger doesn't send multiple attachments when user sends multiple at once, it only relays the first one to Hexabot
// TODO: Implenent this
if (Array.isArray(message.attachment)) {
return <>Not yet Implemented</>;
}
const AttachmentViewerForType = componentMap[message.attachment.type];
const url =
"id" in message.attachment?.payload && message.attachment?.payload.id
? `${apiUrl}attachment/download/${message.attachment?.payload.id}`
: message.attachment?.payload?.url;
return <AttachmentViewerForType url={url} />;
if (!message.attachment) {
return <>No attachment to display</>;
}
const attachments = Array.isArray(message.attachment)
? message.attachment
: [message.attachment];
return attachments.map((attachment, idx) => {
return (
<MessageAttachmentViewer
key={`${attachment.payload.id}-${idx}`}
attachment={attachment}
/>
);
});
};

View File

@@ -1,11 +1,12 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import {
@@ -18,8 +19,9 @@ import {
styled,
Typography,
} from "@mui/material";
import { forwardRef, useEffect, useRef, useState, useCallback } from "react";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata";
import {
AnyButton as ButtonType,
OutgoingPopulatedListMessage,
@@ -188,6 +190,8 @@ const ListCard = forwardRef<
buttons: ButtonType[];
}
>(function ListCardRef(props, ref) {
const metadata = useGetAttachmentMetadata(props.content.image_url?.payload);
return (
<Card
style={{
@@ -201,9 +205,9 @@ const ListCard = forwardRef<
ref={ref}
id={"A" + props.id}
>
{props.content.image_url ? (
{metadata ? (
<CardMedia
image={props.content.image_url?.payload?.url as string}
image={metadata.url}
sx={{ height: "185px" }}
title={props.content.title}
/>

View File

@@ -1,11 +1,12 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Message, MessageModel } from "@chatscope/chat-ui-kit-react";
import MenuRoundedIcon from "@mui/icons-material/MenuRounded";
import ReplyIcon from "@mui/icons-material/Reply";
@@ -17,7 +18,7 @@ import { EntityType } from "@/services/types";
import { IMessage, IMessageFull } from "@/types/message.types";
import { buildURL } from "@/utils/URL";
import { AttachmentViewer } from "../components/AttachmentViewer";
import { MessageAttachmentsViewer } from "../components/AttachmentViewer";
import { Carousel } from "../components/Carousel";
function hasSameSender(
@@ -110,7 +111,7 @@ export function getMessageContent(
if ("attachment" in message) {
content.push(
<Message.CustomContent>
<AttachmentViewer message={message} />
<MessageAttachmentsViewer message={message} />
</Message.CustomContent>,
);
}

View File

@@ -1,11 +1,12 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
import { Box, Grid, Paper } from "@mui/material";
import { GridColDef, GridEventListener } from "@mui/x-data-grid";
@@ -32,7 +33,10 @@ import { PermissionAction } from "@/types/permission.types";
import { TFilterStringFields } from "@/types/search.types";
import { getDateTimeFormatter } from "@/utils/date";
import { IAttachment } from "../../types/attachment.types";
import {
AttachmentResourceRef,
IAttachment,
} from "../../types/attachment.types";
type MediaLibraryProps = {
showTitle?: boolean;
@@ -53,6 +57,10 @@ export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => {
{
params: {
where: {
resourceRef: [
AttachmentResourceRef.BlockAttachment,
AttachmentResourceRef.ContentAttachment,
],
...searchPayload.where,
or: {
...searchPayload.where.or,

View File

@@ -1,11 +1,12 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import KeyIcon from "@mui/icons-material/Key";
import { FormControlLabel, MenuItem, Switch } from "@mui/material";
import { ControllerRenderProps } from "react-hook-form";
@@ -19,6 +20,7 @@ import MultipleInput from "@/app-components/inputs/MultipleInput";
import { PasswordInput } from "@/app-components/inputs/PasswordInput";
import { useTranslate } from "@/hooks/useTranslate";
import { EntityType, Format } from "@/services/types";
import { AttachmentResourceRef } from "@/types/attachment.types";
import { IBlock } from "@/types/block.types";
import { IHelper } from "@/types/helper.types";
import { ISetting, SettingType } from "@/types/setting.types";
@@ -185,6 +187,7 @@ const SettingInput: React.FC<RenderSettingInputProps> = ({
accept={MIME_TYPES["images"].join(",")}
format="full"
size={128}
resourceRef={AttachmentResourceRef.SettingAttachment}
/>
);
@@ -197,6 +200,7 @@ const SettingInput: React.FC<RenderSettingInputProps> = ({
accept={MIME_TYPES["images"].join(",")}
format="full"
size={128}
resourceRef={AttachmentResourceRef.SettingAttachment}
/>
);
default:

View File

@@ -13,6 +13,7 @@ import AttachmentInput from "@/app-components/attachment/AttachmentInput";
import { ContentItem } from "@/app-components/dialogs";
import AttachmentIcon from "@/app-components/svg/toolbar/AttachmentIcon";
import { useTranslate } from "@/hooks/useTranslate";
import { AttachmentResourceRef } from "@/types/attachment.types";
import { IBlockAttributes } from "@/types/block.types";
import { FileType } from "@/types/message.types";
import { MIME_TYPES, getFileType } from "@/utils/attachment";
@@ -69,6 +70,7 @@ const AttachmentMessageForm = () => {
},
});
}}
resourceRef={AttachmentResourceRef.BlockAttachment}
/>
);
}}

View File

@@ -9,6 +9,7 @@
import { useMutation, useQueryClient } from "react-query";
import { QueryType, TMutationOptions } from "@/services/types";
import { AttachmentResourceRef } from "@/types/attachment.types";
import { IBaseSchema, IDynamicProps, TType } from "@/types/base.types";
import { useEntityApiClient } from "../useApiClient";
@@ -23,7 +24,12 @@ export const useUpload = <
>(
entity: TEntity,
options?: Omit<
TMutationOptions<TBasic, Error, File, TBasic>,
TMutationOptions<
TBasic,
Error,
{ file: File; resourceRef: AttachmentResourceRef },
TBasic
>,
"mutationFn" | "mutationKey"
>,
) => {
@@ -33,8 +39,8 @@ export const useUpload = <
const { invalidate = true, ...otherOptions } = options || {};
return useMutation({
mutationFn: async (variables: File) => {
const data = await api.upload(variables);
mutationFn: async ({ file, resourceRef }) => {
const data = await api.upload(file, resourceRef);
const { entities, result } = normalizeAndCache(data);
// Invalidate all counts & collections

View File

@@ -0,0 +1,52 @@
/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { EntityType } from "@/services/types";
import { TAttachmentForeignKey } from "@/types/message.types";
import {
extractFilenameFromUrl,
getAttachmentDownloadUrl,
} from "@/utils/attachment";
import { useGet } from "./crud/useGet";
import { useConfig } from "./useConfig";
export const useGetAttachmentMetadata = (
attachmentPayload?: TAttachmentForeignKey,
) => {
const { apiUrl } = useConfig();
const { data: attachment } = useGet(
attachmentPayload?.id || "",
{
entity: EntityType.ATTACHMENT,
},
{
enabled: !!attachmentPayload?.id,
},
);
if (!attachmentPayload) {
return null;
}
if (attachment) {
return {
name: attachmentPayload.id
? attachment.name
: extractFilenameFromUrl(attachment.url),
url: getAttachmentDownloadUrl(apiUrl, attachment),
};
}
const url = getAttachmentDownloadUrl(apiUrl, attachmentPayload);
return {
name: extractFilenameFromUrl(url || "/#"),
url,
};
};

View File

@@ -9,6 +9,7 @@
import { AxiosInstance, AxiosResponse } from "axios";
import { AttachmentResourceRef } from "@/types/attachment.types";
import { ILoginAttributes } from "@/types/auth/login.types";
import { IUserPermissions } from "@/types/auth/permission.types";
import { StatsType } from "@/types/bot-stat.types";
@@ -301,7 +302,7 @@ export class EntityApiClient<TAttr, TBasic, TFull> extends ApiClient {
return data;
}
async upload(file: File) {
async upload(file: File, resourceRef?: AttachmentResourceRef) {
const { _csrf } = await this.getCsrf();
const formData = new FormData();
@@ -311,11 +312,17 @@ export class EntityApiClient<TAttr, TBasic, TFull> extends ApiClient {
TBasic[],
AxiosResponse<TBasic[]>,
FormData
>(`${ROUTES[this.type]}/upload?_csrf=${_csrf}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
>(
`${ROUTES[this.type]}/upload?_csrf=${_csrf}${
resourceRef ? `&resourceRef=${resourceRef}` : ""
}`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
});
);
return data[0];
}

View File

@@ -1,14 +1,44 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Format } from "@/services/types";
import { IBaseSchema, IFormat } from "./base.types";
import { EntityType, Format } from "@/services/types";
import { IBaseSchema, IFormat, OmitPopulate } from "./base.types";
import { ISubscriber } from "./subscriber.types";
import { IUser } from "./user.types";
/**
* Defines the types of owners for an attachment,
* indicating whether the file belongs to a User or a Subscriber.
*/
export enum AttachmentCreatedByRef {
User = "User",
Subscriber = "Subscriber",
}
/**
* Defines the various resource references in which an attachment can exist.
* These references influence how the attachment is uploaded, stored, and accessed:
*/
export enum AttachmentResourceRef {
SettingAttachment = "Setting", // Attachments related to app settings, restricted to users with specific permissions.
UserAvatar = "User", // Avatar files for users, only the current user can upload, accessible to those with appropriate permissions.
SubscriberAvatar = "Subscriber", // Avatar files for subscribers, uploaded programmatically, accessible to authorized users.
BlockAttachment = "Block", // Files sent by the bot, public or private based on the channel and user authentication.
ContentAttachment = "Content", // Files in the knowledge base, usually public but could vary based on specific needs.
MessageAttachment = "Message", // Files sent or received via messages, uploaded programmatically, accessible to users with inbox permissions.;
}
export enum AttachmentAccess {
Public = "public",
Private = "private",
}
export interface IAttachmentAttributes {
name: string;
@@ -17,8 +47,22 @@ export interface IAttachmentAttributes {
location: string;
url: string;
channel?: Record<string, any>;
resourceRef: AttachmentResourceRef;
access: AttachmentAccess;
createdByRef: AttachmentCreatedByRef;
createdBy: string | null;
}
export interface IAttachmentStub extends IBaseSchema, IAttachmentAttributes {}
export interface IAttachmentStub
extends IBaseSchema,
OmitPopulate<IAttachmentAttributes, EntityType.ATTACHMENT> {}
export interface IAttachment extends IAttachmentStub, IFormat<Format.BASIC> {}
export interface IAttachment extends IAttachmentStub, IFormat<Format.BASIC> {
createdBy: string | null;
}
export interface ISubscriberAttachmentFull
extends IAttachmentStub,
IFormat<Format.FULL> {
createdBy: (ISubscriber | IUser)[];
}

View File

@@ -1,11 +1,12 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { GridPaginationModel, GridSortModel } from "@mui/x-data-grid";
import { EntityType, Format } from "@/services/types";
@@ -109,7 +110,7 @@ export const POPULATE_BY_TYPE = {
[EntityType.MENUTREE]: [],
[EntityType.LANGUAGE]: [],
[EntityType.TRANSLATION]: [],
[EntityType.ATTACHMENT]: [],
[EntityType.ATTACHMENT]: ["createdBy"],
[EntityType.CUSTOM_BLOCK]: [],
[EntityType.CUSTOM_BLOCK_SETTINGS]: [],
[EntityType.CHANNEL]: [],

View File

@@ -42,7 +42,7 @@ export enum FileType {
}
// Attachments
export interface AttachmentAttrs {
export interface IAttachmentAttrs {
name: string;
type: string;
size: number;
@@ -51,15 +51,15 @@ export interface AttachmentAttrs {
url?: string;
}
export type AttachmentForeignKey = {
export type TAttachmentForeignKey = {
id: string | null;
/** @deprecated use id instead */
url?: string;
};
export interface AttachmentPayload {
export interface IAttachmentPayload {
type: FileType;
payload: AttachmentForeignKey;
payload: TAttachmentForeignKey;
}
// Content
@@ -95,7 +95,7 @@ export type Payload =
}
| {
type: PayloadType.attachments;
attachments: AttachmentPayload;
attachments: IAttachmentPayload;
};
export enum QuickReplyType {
@@ -164,7 +164,7 @@ export type StdOutgoingListMessage = {
};
export type StdOutgoingAttachmentMessage = {
// Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying
attachment: AttachmentPayload;
attachment: IAttachmentPayload;
quickReplies?: StdQuickReply[];
};
@@ -187,7 +187,7 @@ export type StdIncomingLocationMessage = {
export type StdIncomingAttachmentMessage = {
type: PayloadType.attachments;
serialized_text: string;
attachment: AttachmentPayload | AttachmentPayload[];
attachment: IAttachmentPayload | IAttachmentPayload[];
};
export type StdPluginMessage = {

View File

@@ -1,17 +1,18 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { EntityType, Format } from "@/services/types";
import { IBaseSchema, IFormat, OmitPopulate } from "./base.types";
import { IPermission } from "./permission.types";
export type TRelation = "role" | "owner";
export type TRelation = "role" | "createdBy";
export interface IModelAttributes {
name: string;

View File

@@ -1,12 +1,16 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { FileType } from "@/types/message.types";
import { IAttachment } from "@/types/attachment.types";
import { FileType, TAttachmentForeignKey } from "@/types/message.types";
import { buildURL } from "./URL";
export const MIME_TYPES = {
images: ["image/jpeg", "image/png", "image/gif", "image/webp"],
@@ -34,3 +38,34 @@ export function getFileType(mimeType: string): FileType {
return FileType.file;
}
}
export function getAttachmentDownloadUrl(
baseUrl: string,
attachment: TAttachmentForeignKey | IAttachment,
) {
return "id" in attachment && attachment.id
? buildURL(baseUrl, `/attachment/download/${attachment.id}`)
: attachment.url;
}
export function extractFilenameFromUrl(url: string) {
try {
// Parse the URL to ensure it is valid
const parsedUrl = new URL(url);
// Extract the pathname (part after the domain)
const pathname = parsedUrl.pathname;
// Extract the last segment of the pathname
const filename = pathname.substring(pathname.lastIndexOf("/") + 1);
// Check if a valid filename exists
if (filename && filename.includes(".")) {
return filename;
}
// If no valid filename, return the full URL
return url;
} catch (error) {
// If the URL is invalid, return the input as-is
return url;
}
}