From 39213e8b0b30c63503430d6f1a05d47b8e62622c Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 15 Jan 2025 15:46:50 +0100 Subject: [PATCH] refactor: channels design to allow attachment ownership --- .../attachment/schemas/attachment.schema.ts | 4 +- .../attachment/services/attachment.service.ts | 27 +++++---- api/src/attachment/types/index.ts | 24 ++++++++ api/src/channel/lib/EventWrapper.ts | 24 +++++--- api/src/channel/lib/Handler.ts | 60 +++++++++++++++++-- .../chat/controllers/subscriber.controller.ts | 6 +- api/src/chat/services/chat.service.ts | 46 +++++++++++++- .../channels/web/base-web-channel.ts | 6 +- api/src/plugins/base-storage-plugin.ts | 6 +- api/src/user/controllers/user.controller.ts | 25 +++----- api/src/utils/types/misc.ts | 9 +++ 11 files changed, 184 insertions(+), 53 deletions(-) create mode 100644 api/src/utils/types/misc.ts diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index f9355c4c..8a7a9b4e 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -149,13 +149,13 @@ export class Attachment extends AttachmentStub { @Schema({ timestamps: true }) export class UserAttachmentFull extends AttachmentStub { @Type(() => User) - owner: User | null; + owner: User | undefined; } @Schema({ timestamps: true }) export class SubscriberAttachmentFull extends AttachmentStub { @Type(() => Subscriber) - owner: Subscriber | null; + owner: Subscriber | undefined; } export type AttachmentDocument = THydratedDocument; diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 242d19c4..1d4ac708 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -30,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 { TAttachmentContext } from '../types'; import { fileExists, generateUniqueFilename, @@ -156,6 +157,18 @@ export class AttachmentService extends BaseService { } } + /** + * Get the attachment root directory given the context + * + * @param context The attachment context + * @returns The root directory path + */ + getRootDirByContext(context: TAttachmentContext) { + return context === 'subscriber_avatar' || context === 'user_avatar' + ? config.parameters.avatarDir + : config.parameters.uploadDir; + } + /** * Uploads files to the server. If a storage plugin is configured it uploads files accordingly. * Otherwise, uploads files to the local directory. @@ -168,16 +181,12 @@ export class AttachmentService extends BaseService { async store( file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, - rootDir = config.parameters.uploadDir, ): Promise { if (this.getStoragePlugin()) { - const storedDto = await this.getStoragePlugin()?.store?.( - file, - metadata, - rootDir, - ); + const storedDto = await this.getStoragePlugin()?.store?.(file, metadata); return storedDto ? await this.create(storedDto) : undefined; } else { + const rootDir = this.getRootDirByContext(metadata.context); const uniqueFilename = generateUniqueFilename(metadata.name); const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename))); @@ -225,10 +234,7 @@ export class AttachmentService extends BaseService { * @param rootDir - The root directory where attachment shoud be located. * @returns A promise that resolves to a StreamableFile representing the downloaded attachment. */ - async download( - attachment: Attachment, - rootDir = config.parameters.uploadDir, - ): Promise { + async download(attachment: Attachment): Promise { if (this.getStoragePlugin()) { const streamableFile = await this.getStoragePlugin()?.download(attachment); @@ -238,6 +244,7 @@ export class AttachmentService extends BaseService { return streamableFile; } else { + const rootDir = this.getRootDirByContext(attachment.context); const path = resolve(join(rootDir, attachment.location)); if (!fileExists(path)) { diff --git a/api/src/attachment/types/index.ts b/api/src/attachment/types/index.ts index e7cdc6ee..9accd75c 100644 --- a/api/src/attachment/types/index.ts +++ b/api/src/attachment/types/index.ts @@ -6,6 +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 { Readable, Stream } from 'stream'; + /** * Defines the types of owners for an attachment, * indicating whether the file belongs to a User or a Subscriber. @@ -31,3 +33,25 @@ export enum AttachmentContext { } export type TAttachmentContext = `${AttachmentContext}`; + +export class AttachmentFile { + /** + * File original file name + */ + file: Buffer | Stream | Readable | Express.Multer.File; + + /** + * File original file name + */ + name?: string; + + /** + * File size in bytes + */ + size: number; + + /** + * File MIME type + */ + type: string; +} diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index c8a9e47a..03c37a8f 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -6,6 +6,7 @@ * 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'; @@ -29,19 +30,24 @@ export default abstract class EventWrapper< eventType: StdEventType; messageType?: IncomingMessageType; raw: E; + attachments?: Attachment[]; }, E, N extends ChannelName = ChannelName, C extends ChannelHandler = ChannelHandler, S = SubscriberChannelDict[N], > { - _adapter: A = { raw: {}, eventType: StdEventType.unknown } as A; + _adapter: A = { + raw: {}, + eventType: StdEventType.unknown, + attachments: undefined, + } as A; _handler: C; channelAttrs: S; - _profile!: Subscriber; + subscriber!: Subscriber; _nlp!: NLU.ParseEntities; @@ -177,7 +183,7 @@ export default abstract class EventWrapper< * @returns event sender data */ getSender(): Subscriber { - return this._profile; + return this.subscriber; } /** @@ -186,7 +192,7 @@ export default abstract class EventWrapper< * @param profile - Sender data */ setSender(profile: Subscriber) { - this._profile = profile; + this.subscriber = profile; } /** @@ -194,9 +200,13 @@ 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); + } } /** diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index d488cc72..ee0e7ccb 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -17,12 +17,17 @@ 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 { AttachmentFile } 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 +55,7 @@ export default abstract class ChannelHandler< private readonly settings: ChannelSetting[]; @Inject(AttachmentService) - protected readonly attachmentService: AttachmentService; + public readonly attachmentService: AttachmentService; @Inject(JwtService) protected readonly jwtService: JwtService; @@ -206,15 +211,62 @@ export default abstract class ChannelHandler< ): Promise<{ mid: string }>; /** - * Fetch the end user profile data + * Calls the channel handler to fetch attachments and stores them + * + * @param event + * @returns An attachment array + */ + getMessageAttachments?( + event: EventWrapper, + ): Promise; + + /** + * Fetch the subscriber profile data * @param event - The message event received * @returns {Promise} - The channel's response, otherwise an error - */ - abstract getUserData( + getSubscriberAvatar?( + event: EventWrapper, + ): Promise; + + /** + * Fetch the subscriber profile data + * + * @param event - The message event received + * @returns {Promise} - The channel's response, otherwise an error + */ + abstract getSubscriberData( event: EventWrapper, ): Promise; + /** + * Persist Message attachments + * + * @returns Resolves the promise once attachments are fetched and stored + */ + async persistMessageAttachments(event: EventWrapper) { + if ( + event._adapter.eventType === StdEventType.message && + event._adapter.messageType === IncomingMessageType.attachments && + this.getMessageAttachments + ) { + const metadatas = await this.getMessageAttachments(event); + const subscriber = event.getSender(); + event._adapter.attachments = await Promise.all( + metadatas.map(({ file, name, type, size }) => { + return this.attachmentService.store(file, { + name: `${name ? `${name}-` : ''}${uuidv4()}.${mime.extension(type)}`, + type, + size, + context: 'message_attachment', + ownerType: 'Subscriber', + owner: subscriber.id, + }); + }), + ); + } + } + /** * Custom channel middleware * @param req diff --git a/api/src/chat/controllers/subscriber.controller.ts b/api/src/chat/controllers/subscriber.controller.ts index d0688df0..b88df3cb 100644 --- a/api/src/chat/controllers/subscriber.controller.ts +++ b/api/src/chat/controllers/subscriber.controller.ts @@ -20,7 +20,6 @@ import { import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { config } from '@/config'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; import { BaseController } from '@/utils/generics/base-controller'; @@ -159,10 +158,7 @@ export class SubscriberController extends BaseController< throw new Error('User has no avatar'); } - return await this.attachmentService.download( - subscriber.avatar, - config.parameters.avatarDir, - ); + return await this.attachmentService.download(subscriber.avatar); } catch (err) { this.logger.verbose( 'Subscriber has no avatar, generating initials avatar ...', diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index 5fa9fcc2..8b8dc5ea 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -8,7 +8,10 @@ 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 EventWrapper from '@/channel/lib/EventWrapper'; import { config } from '@/config'; import { HelperService } from '@/helper/helper.service'; @@ -36,6 +39,7 @@ export class ChatService { private readonly botService: BotService, private readonly websocketGateway: WebsocketGateway, private readonly helperService: HelperService, + private readonly attachmentService: AttachmentService, ) {} /** @@ -248,7 +252,7 @@ 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); @@ -260,9 +264,47 @@ export class ChatService { this.websocketGateway.broadcastSubscriberUpdate(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); + + const avatar = await this.attachmentService.store(file, { + name: `avatar-${uuidv4()}.${extension}`, + size, + type, + context: 'subscriber_avatar', + ownerType: 'Subscriber', + owner: subscriber.id, + }); + + if (avatar) { + subscriber = await this.subscriberService.updateOne( + subscriber.id, + { + avatar: avatar.id, + }, + ); + } + } + } catch (err) { + this.logger.error( + `Unable to retrieve avatar for subscriber ${subscriber.id}`, + err, + ); + } + } + + // Set the subscriber object event.setSender(subscriber); - await event.preprocess(); + // Preprocess the event (persist attachments, ...) + if (event.preprocess) { + await event.preprocess(); + } // Trigger message received event this.eventEmitter.emit('hook:chatbot:received', event); diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 08e8fb32..44421ebd 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -70,7 +70,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, @@ -1321,7 +1321,9 @@ export default abstract class BaseWebChannelHandler< * * @returns The web's response, otherwise an error */ - async getUserData(event: WebEventWrapper): Promise { + async getSubscriberData( + event: WebEventWrapper, + ): Promise { const sender = event.getSender(); const { id: _id, diff --git a/api/src/plugins/base-storage-plugin.ts b/api/src/plugins/base-storage-plugin.ts index 8a0ac262..ebf3ee5f 100644 --- a/api/src/plugins/base-storage-plugin.ts +++ b/api/src/plugins/base-storage-plugin.ts @@ -37,10 +37,7 @@ export abstract class BaseStoragePlugin extends BasePlugin { /** @deprecated use store() instead */ uploadAvatar?(file: Express.Multer.File): Promise; - abstract download( - attachment: Attachment, - rootLocation?: string, - ): Promise; + abstract download(attachment: Attachment): Promise; /** @deprecated use download() instead */ downloadProfilePic?(name: string): Promise; @@ -52,6 +49,5 @@ export abstract class BaseStoragePlugin extends BasePlugin { store?( file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, - rootDir?: string, ): Promise; } diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index b9bc75a3..4cd16405 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -110,10 +110,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,18 +290,14 @@ 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, - context: 'user_avatar', - ownerType: 'User', - owner: req.user.id, - }, - config.parameters.avatarDir, - ) + ? await this.attachmentService.store(avatarFile, { + name: avatarFile.originalname, + size: avatarFile.size, + type: avatarFile.mimetype, + context: 'user_avatar', + ownerType: 'User', + owner: req.user.id, + }) : undefined; const result = await this.userService.updateOne( diff --git a/api/src/utils/types/misc.ts b/api/src/utils/types/misc.ts new file mode 100644 index 00000000..197ca81f --- /dev/null +++ b/api/src/utils/types/misc.ts @@ -0,0 +1,9 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +export type PartialExcept = Partial & Pick;