From e2c81a9618fd98223e5765d6cbee34d0b892a5b6 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 3 Jan 2025 18:33:26 +0100 Subject: [PATCH 01/11] feat: update DB schema to use subscriber.avatar --- .../attachment/services/attachment.service.ts | 32 ++++--- api/src/attachment/utilities/index.ts | 16 ++-- .../chat/controllers/subscriber.controller.ts | 65 ++++++++++---- api/src/chat/services/subscriber.service.ts | 20 +---- api/src/migration/migration.service.ts | 2 +- .../1735836154221-v-2-2-0.migration.ts | 86 +++++++++++++++++++ api/src/plugins/base-storage-plugin.ts | 19 ++-- api/src/user/controllers/user.controller.ts | 29 ++++--- api/src/user/services/user.service.ts | 37 +------- 9 files changed, 193 insertions(+), 113 deletions(-) create mode 100644 api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 62305537..01cb5f30 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -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. @@ -73,6 +73,7 @@ export class AttachmentService extends BaseService { /** * Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration. * + * @deprecated Use AttachmentService.download() instead * @param foreign_id The unique identifier of the user, used to locate the profile picture. * @returns A `StreamableFile` containing the user's profile picture. */ @@ -100,6 +101,7 @@ export class AttachmentService extends BaseService { /** * Uploads a profile picture to either 3rd party storage system or locally based on the configuration. * + * @deprecated use store() method instead * @param res - The response object from which the profile picture will be buffered or piped. * @param filename - The filename */ @@ -157,6 +159,7 @@ export class AttachmentService extends BaseService { * Uploads files to the server. If a storage plugin is configured it uploads files accordingly. * Otherwise, uploads files to the local directory. * + * @deprecated use store() instead * @param files - An array of files to upload. * @returns A promise that resolves to an array of uploaded attachments. */ @@ -241,19 +244,22 @@ export class AttachmentService extends BaseService { /** * Downloads an attachment identified by the provided parameters. * - * @param attachment - The attachment to download. + * @param attachment - The attachment to download. + * @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) { + async download( + attachment: Attachment, + rootDir = config.parameters.uploadDir, + ) { if (this.getStoragePlugin()) { return await this.getStoragePlugin().download(attachment); } else { - if (!fileExists(attachment.location)) { + const path = join(rootDir, attachment.location); + if (!fileExists(path)) { throw new NotFoundException('No file was found'); } - const path = join(config.parameters.uploadDir, attachment.location); - const disposition = `attachment; filename="${encodeURIComponent( attachment.name, )}"`; @@ -272,18 +278,22 @@ export class AttachmentService extends BaseService { /** * Downloads an attachment identified by the provided parameters as a Buffer. * - * @param attachment - The attachment to download. + * @param attachment - The attachment to download. + * @param rootDir - Root folder path where the attachment should be located. * @returns A promise that resolves to a Buffer representing the downloaded attachment. */ - async readAsBuffer(attachment: Attachment): Promise { + async readAsBuffer( + attachment: Attachment, + rootDir = config.parameters.uploadDir, + ): Promise { if (this.getStoragePlugin()) { return await this.getStoragePlugin().readAsBuffer(attachment); } else { - if (!fileExists(attachment.location)) { + const path = join(rootDir, attachment.location); + if (!fileExists(path)) { throw new NotFoundException('No file was found'); } - const filePath = join(config.parameters.uploadDir, attachment.location); - return await fs.promises.readFile(filePath); // Reads the file content as a Buffer + return await fs.promises.readFile(path); // Reads the file content as a Buffer } } } diff --git a/api/src/attachment/utilities/index.ts b/api/src/attachment/utilities/index.ts index 874ff0a0..ed3905cc 100644 --- a/api/src/attachment/utilities/index.ts +++ b/api/src/attachment/utilities/index.ts @@ -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,7 +7,7 @@ */ import { createReadStream, existsSync } from 'fs'; -import { extname, join } from 'path'; +import { extname } from 'path'; import { Logger, StreamableFile } from '@nestjs/common'; import { StreamableFileOptions } from '@nestjs/common/file-stream/interfaces/streamable-options.interface'; @@ -29,20 +29,18 @@ export const isMime = (type: string): boolean => { /** * Checks if a file exists in the specified upload directory. - * @param location The relative location of the file. - * @returns Whether the file exists. + * @param filePath The relative location of the file. + * @returns True if the file exists. */ -export const fileExists = (location: string): boolean => { +export const fileExists = (filePath: string): boolean => { // bypass test env if (config.env === 'test') { return true; } try { - const dirPath = config.parameters.uploadDir; - const fileLocation = join(dirPath, location); - return existsSync(fileLocation); + return existsSync(filePath); } catch (e) { - new Logger(`Attachment Model : Unable to locate file: ${location}`); + new Logger(`Attachment Model : Unable to locate file: ${filePath}`); return false; } }; diff --git a/api/src/chat/controllers/subscriber.controller.ts b/api/src/chat/controllers/subscriber.controller.ts index 4ee7c3d2..9d330bb1 100644 --- a/api/src/chat/controllers/subscriber.controller.ts +++ b/api/src/chat/controllers/subscriber.controller.ts @@ -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. @@ -19,6 +19,8 @@ import { } from '@nestjs/common'; 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 { Roles } from '@/utils/decorators/roles.decorator'; @@ -49,11 +51,21 @@ export class SubscriberController extends BaseController< > { constructor( private readonly subscriberService: SubscriberService, + private readonly attachmentService: AttachmentService, private readonly logger: LoggerService, ) { super(subscriberService); } + /** + * Retrieves a paginated list of subscribers based on provided query parameters. + * Supports filtering, pagination, and population of related fields. + * + * @param pageQuery - The pagination and sorting options. + * @param populate - List of fields to populate in the response. + * @param filters - Search filters to apply on the Subscriber model. + * @returns A promise containing the paginated and optionally populated list of subscribers. + */ @Get() async findPage( @Query(PageQueryPipe) pageQuery: PageQueryDto, @@ -79,8 +91,10 @@ export class SubscriberController extends BaseController< } /** - * Counts the filtered number of subscribers. - * @returns A promise that resolves to an object representing the filtered number of subscribers. + * Retrieves the count of subscribers that match the provided search filters. + * + * @param filters - Optional search filters to apply on the Subscriber model. + * @returns A promise containing the count of subscribers matching the filters. */ @Get('count') async filterCount( @@ -100,6 +114,14 @@ export class SubscriberController extends BaseController< return await this.count(filters); } + /** + * Retrieves a single subscriber by their unique ID. + * Supports optional population of related fields. + * + * @param id - The unique identifier of the subscriber to retrieve. + * @param populate - An optional list of related fields to populate in the response. + * @returns The subscriber document, populated if requested. + */ @Get(':id') async findOne( @Param('id') id: string, @@ -116,24 +138,29 @@ export class SubscriberController extends BaseController< return doc; } + /** + * Retrieves the profile picture (avatar) of a subscriber by their unique ID. + * If no avatar is set, generates an initials-based avatar. + * + * @param id - The unique identifier of the subscriber whose profile picture is to be retrieved. + * @returns A streamable file containing the avatar image. + */ @Roles('public') - @Get(':foreign_id/profile_pic') - async findProfilePic( - @Param('foreign_id') foreign_id: string, - ): Promise { - try { - const pic = await this.subscriberService.findProfilePic(foreign_id); - return pic; - } catch (e) { - const [subscriber] = await this.subscriberService.find({ foreign_id }); - if (subscriber) { - return generateInitialsAvatar(subscriber); - } else { - throw new NotFoundException( - `Subscriber with ID ${foreign_id} not found`, - ); - } + @Get(':id/profile_pic') + async getAvatar(@Param('id') id: string): Promise { + const subscriber = await this.subscriberService.findOneAndPopulate(id); + + if (!subscriber) { + throw new NotFoundException(`Subscriber with ID ${id} not found`); } + + if (subscriber.avatar) { + return this.attachmentService.download( + subscriber.avatar, + config.parameters.avatarDir, + ); + } + return generateInitialsAvatar(subscriber); } @CsrfCheck(true) diff --git a/api/src/chat/services/subscriber.service.ts b/api/src/chat/services/subscriber.service.ts index 54f94228..d5b688a9 100644 --- a/api/src/chat/services/subscriber.service.ts +++ b/api/src/chat/services/subscriber.service.ts @@ -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,9 +9,7 @@ import { Injectable, InternalServerErrorException, - NotFoundException, Optional, - StreamableFile, } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; @@ -139,22 +137,6 @@ export class SubscriberService extends BaseService< return await this.repository.handOverByForeignIdQuery(foreignId, userId); } - /** - * Retrieves the profile picture of a subscriber based on the foreign ID. - * - * @param foreign_id - The foreign ID of the subscriber. - * - * @returns A streamable file representing the profile picture. - */ - async findProfilePic(foreign_id: string): Promise { - try { - return await this.attachmentService.downloadProfilePic(foreign_id); - } catch (err) { - this.logger.error('Error downloading profile picture', err); - throw new NotFoundException('Profile picture not found'); - } - } - /** * Apply updates on end-user such as : * - Assign labels to specific end-user diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index 88db7aca..e74a9b0e 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -53,7 +53,7 @@ export class MigrationService implements OnApplicationBootstrap { if (mongoose.connection.readyState !== 1) { await this.connect(); } - this.logger.log('Mongoose connection established'); + this.logger.log('Mongoose connection established!'); if (!this.isCLI && config.mongo.autoMigrate) { this.logger.log('Executing migrations ...'); diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts new file mode 100644 index 00000000..d8288afb --- /dev/null +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -0,0 +1,86 @@ +/* + * 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 mongoose from 'mongoose'; + +import attachmentSchema, { + Attachment, +} from '@/attachment/schemas/attachment.schema'; +import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; + +import { MigrationServices } from '../types'; + +const populateSubscriberAvatar = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + const SubscriberModel = mongoose.model( + Subscriber.name, + subscriberSchema, + ); + + 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; + } + + const attachment = await AttachmentModel.findOne({ + name: RegExp(`^${foreignId}.jpe?g$`), + }); + + if (attachment) { + await SubscriberModel.updateOne( + { _id: subscriber._id }, + { $set: { avatar: attachment._id } }, + ); + logger.log( + `Avatar attachment successfully updated for subscriber ${subscriber._id}`, + ); + } else { + logger.debug( + `No avatar attachment found for subscriber ${subscriber._id}`, + ); + } + } +}; + +const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { + const SubscriberModel = mongoose.model( + Subscriber.name, + subscriberSchema, + ); + + // Rollback logic: unset the "avatar" field in all subscriber documents + const cursor = SubscriberModel.find({ avatar: { $exists: true } }).cursor(); + + for await (const subscriber of cursor) { + await SubscriberModel.updateOne( + { _id: subscriber._id }, + { $set: { avatar: null } }, + ); + logger.log( + `Avatar attachment successfully updated for subscriber ${subscriber._id}`, + ); + } +}; + +module.exports = { + async up(services: MigrationServices) { + await populateSubscriberAvatar(services); + return true; + }, + async down(services: MigrationServices) { + await unpopulateSubscriberAvatar(services); + return true; + }, +}; diff --git a/api/src/plugins/base-storage-plugin.ts b/api/src/plugins/base-storage-plugin.ts index f575478c..2a05c8d1 100644 --- a/api/src/plugins/base-storage-plugin.ts +++ b/api/src/plugins/base-storage-plugin.ts @@ -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. @@ -28,15 +28,22 @@ export abstract class BaseStoragePlugin extends BasePlugin { super(name, pluginService); } - abstract fileExists(attachment: Attachment): Promise; + /** @deprecated use download() instead */ + fileExists?(attachment: Attachment): Promise; - abstract upload(file: Express.Multer.File): Promise; + /** @deprecated use store() instead */ + upload?(file: Express.Multer.File): Promise; - abstract uploadAvatar(file: Express.Multer.File): Promise; + /** @deprecated use store() instead */ + uploadAvatar?(file: Express.Multer.File): Promise; - abstract download(attachment: Attachment): Promise; + abstract download( + attachment: Attachment, + rootLocation?: string, + ): Promise; - abstract downloadProfilePic(name: string): Promise; + /** @deprecated use download() instead */ + downloadProfilePic?(name: string): Promise; readAsBuffer?(attachment: Attachment): Promise; diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index a07b257a..a1534afb 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -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. @@ -28,6 +28,7 @@ import { Request } from 'express'; import { Session as ExpressSession } from 'express-session'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { config } from '@/config'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; import { Roles } from '@/utils/decorators/roles.decorator'; @@ -83,7 +84,7 @@ export class ReadOnlyUserController extends BaseController< */ @Roles('public') @Get('bot/profile_pic') - async botProfilePic(@Query('color') color: string) { + async getBotAvatar(@Query('color') color: string) { return await getBotAvatar(color); } @@ -96,18 +97,20 @@ export class ReadOnlyUserController extends BaseController< */ @Roles('public') @Get(':id/profile_pic') - async UserProfilePic(@Param('id') id: string) { - try { - const res = await this.userService.userProfilePic(id); - return res; - } catch (e) { - const user = await this.userService.findOne(id); - if (user) { - return await generateInitialsAvatar(user); - } else { - throw new NotFoundException(`user with ID ${id} not found`); - } + async getAvatar(@Param('id') id: string) { + const user = await this.userService.findOneAndPopulate(id); + if (!user) { + throw new NotFoundException(`user with ID ${id} not found`); } + + if (user.avatar) { + return await this.attachmentService.download( + user.avatar, + config.parameters.avatarDir, + ); + } + + return generateInitialsAvatar(user); } /** diff --git a/api/src/user/services/user.service.ts b/api/src/user/services/user.service.ts index b78bd71a..39c2e57a 100644 --- a/api/src/user/services/user.service.ts +++ b/api/src/user/services/user.service.ts @@ -1,17 +1,13 @@ /* - * 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 { join } from 'path'; +import { Injectable } from '@nestjs/common'; -import { Injectable, NotFoundException, StreamableFile } from '@nestjs/common'; - -import { getStreamableFile } from '@/attachment/utilities'; -import { config } from '@/config'; import { BaseService } from '@/utils/generics/base-service'; import { UserRepository } from '../repositories/user.repository'; @@ -22,33 +18,4 @@ export class UserService extends BaseService { constructor(readonly repository: UserRepository) { super(repository); } - - /** - * Retrieves the user's profile picture as a streamable file. - * - * @param id - The ID of the user whose profile picture is requested. - * - * @returns A promise that resolves with the streamable file of the user's profile picture. - */ - async userProfilePic(id: string): Promise { - const user = await this.findOneAndPopulate(id); - if (user) { - const attachment = user.avatar; - const path = join(config.parameters.uploadDir, attachment.location); - const disposition = `attachment; filename="${encodeURIComponent( - attachment.name, - )}"`; - - return getStreamableFile({ - path, - options: { - type: attachment.type, - length: attachment.size, - disposition, - }, - }); - } else { - throw new NotFoundException('Profile Not found'); - } - } } From c35be05416a7e4c11374a00e94de000adb1c0d51 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Fri, 3 Jan 2025 18:36:30 +0100 Subject: [PATCH 02/11] feat: update frontend to use id instead of foreign_id --- .../tables/columns/renderPicture.tsx | 11 +++------ .../src/components/inbox/components/Chat.tsx | 14 ++++------- .../inbox/components/ConversationsList.tsx | 23 ++++++++----------- 3 files changed, 18 insertions(+), 30 deletions(-) diff --git a/frontend/src/app-components/tables/columns/renderPicture.tsx b/frontend/src/app-components/tables/columns/renderPicture.tsx index 3730ded2..d723a31e 100644 --- a/frontend/src/app-components/tables/columns/renderPicture.tsx +++ b/frontend/src/app-components/tables/columns/renderPicture.tsx @@ -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 { Grid } from "@mui/material"; import { GridRenderCellParams } from "@mui/x-data-grid"; @@ -29,13 +30,7 @@ export const buildRenderPicture = ( }} > diff --git a/frontend/src/components/inbox/components/Chat.tsx b/frontend/src/components/inbox/components/Chat.tsx index 5e5ff1d6..bcb47a51 100644 --- a/frontend/src/components/inbox/components/Chat.tsx +++ b/frontend/src/components/inbox/components/Chat.tsx @@ -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 { Avatar, ChatContainer, @@ -70,11 +71,7 @@ export function Chat() { @@ -127,9 +124,8 @@ export function Chat() { message.sender ? EntityType.SUBSCRIBER : EntityType.USER, - (message.sender - ? subscriber.foreign_id - : message.sentBy) || "", + (message.sender ? subscriber.id : message.sentBy) || + "", )} />, ] diff --git a/frontend/src/components/inbox/components/ConversationsList.tsx b/frontend/src/components/inbox/components/ConversationsList.tsx index b0c25d0a..88ef4c5f 100644 --- a/frontend/src/components/inbox/components/ConversationsList.tsx +++ b/frontend/src/components/inbox/components/ConversationsList.tsx @@ -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 { Avatar, Conversation, @@ -50,30 +51,26 @@ export const SubscribersList = (props: { loadingMore={isFetching} onYReachEnd={handleLoadMore} > - {subscribers.map((conversation) => ( + {subscribers.map((subscriber) => ( chat.setSubscriberId(conversation.id)} + onClick={() => chat.setSubscriberId(subscriber.id)} className="changeColor" - key={conversation.id} - active={chat.subscriber?.id === conversation.id} + key={subscriber.id} + active={chat.subscriber?.id === subscriber.id} >
- {conversation.first_name} {conversation.last_name} + {subscriber.first_name} {subscriber.last_name}
- {conversation.lastvisit?.toLocaleString(i18n.language)} + {subscriber.lastvisit?.toLocaleString(i18n.language)}
- +
))} From e16660a0a0e444e488f43ee06a3a1ab6aec8821f Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Sun, 5 Jan 2025 08:52:36 +0100 Subject: [PATCH 03/11] fix: avatar dir --- api/.gitignore | 2 +- api/src/attachment/attachment.module.ts | 20 ++++++++-- .../attachment/services/attachment.service.ts | 40 ++++++++++--------- .../chat/controllers/subscriber.controller.ts | 2 - api/src/config/index.ts | 12 +++--- .../1735836154221-v-2-2-0.migration.ts | 28 +++++++++++++ api/src/plugins/base-storage-plugin.ts | 1 + api/src/user/controllers/user.controller.ts | 1 - docker/.env.example | 6 ++- docker/docker-compose.yml | 2 - 10 files changed, 79 insertions(+), 35 deletions(-) diff --git a/api/.gitignore b/api/.gitignore index e1fa3152..b2c99e52 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -4,4 +4,4 @@ dist/ coverage/ uploads/ documentation/ -avatars \ No newline at end of file +avatars diff --git a/api/src/attachment/attachment.module.ts b/api/src/attachment/attachment.module.ts index 4e5008d8..0c66e2f4 100644 --- a/api/src/attachment/attachment.module.ts +++ b/api/src/attachment/attachment.module.ts @@ -1,15 +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 { Module } from '@nestjs/common'; +import { existsSync, mkdirSync } from 'fs'; + +import { Module, OnApplicationBootstrap } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PassportModule } from '@nestjs/passport'; +import { config } from '@/config'; + import { AttachmentController } from './controllers/attachment.controller'; import { AttachmentRepository } from './repositories/attachment.repository'; import { AttachmentModel } from './schemas/attachment.schema'; @@ -26,4 +30,14 @@ import { AttachmentService } from './services/attachment.service'; controllers: [AttachmentController], exports: [AttachmentService], }) -export class AttachmentModule {} +export class AttachmentModule implements OnApplicationBootstrap { + onApplicationBootstrap() { + // Ensure the directories exists + if (!existsSync(config.parameters.uploadDir)) { + mkdirSync(config.parameters.uploadDir, { recursive: true }); + } + if (!existsSync(config.parameters.avatarDir)) { + mkdirSync(config.parameters.avatarDir, { recursive: true }); + } + } +} diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 01cb5f30..658b7d3e 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -7,7 +7,7 @@ */ import fs, { createReadStream, promises as fsPromises } from 'fs'; -import path, { join } from 'path'; +import { join, resolve } from 'path'; import { Readable } from 'stream'; import { @@ -88,7 +88,9 @@ export class AttachmentService extends BaseService { throw new NotFoundException('Profile picture not found'); } } else { - const path = join(config.parameters.avatarDir, `${foreign_id}.jpeg`); + const path = resolve( + join(config.parameters.avatarDir, `${foreign_id}.jpeg`), + ); if (fs.existsSync(path)) { const picturetream = createReadStream(path); return new StreamableFile(picturetream); @@ -129,14 +131,9 @@ export class AttachmentService extends BaseService { } } else { // Save profile picture locally - const dirPath = path.join(config.parameters.avatarDir, filename); + const dirPath = resolve(join(config.parameters.avatarDir, filename)); try { - // Ensure the directory exists - await fs.promises.mkdir(config.parameters.avatarDir, { - recursive: true, - }); - if (Buffer.isBuffer(data)) { await fs.promises.writeFile(dirPath, data); } else { @@ -195,23 +192,25 @@ export class AttachmentService extends BaseService { * Otherwise, uploads files to the local directory. * * @param file - The file + * @param metadata - The attachment metadata informations. + * @param rootDir - The root directory where attachment shoud be located. * @returns A promise that resolves to an array of uploaded attachments. */ async store( file: Buffer | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, + rootDir = config.parameters.uploadDir, ): Promise { if (this.getStoragePlugin()) { - const storedDto = await this.getStoragePlugin().store(file, metadata); + const storedDto = await this.getStoragePlugin().store( + file, + metadata, + rootDir, + ); return await this.create(storedDto); } else { - const dirPath = path.join(config.parameters.uploadDir); const uniqueFilename = generateUniqueFilename(metadata.name); - const filePath = path.resolve(dirPath, sanitizeFilename(uniqueFilename)); - - if (!filePath.startsWith(dirPath)) { - throw new Error('Invalid file path'); - } + const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename))); if (Buffer.isBuffer(file)) { await fsPromises.writeFile(filePath, file); @@ -225,7 +224,7 @@ export class AttachmentService extends BaseService { } else { if (file.path) { // For example, if the file is an instance of `Express.Multer.File` (diskStorage case) - const srcFilePath = path.resolve(file.path); + const srcFilePath = resolve(file.path); await fsPromises.copyFile(srcFilePath, filePath); await fsPromises.unlink(srcFilePath); } else { @@ -233,7 +232,7 @@ export class AttachmentService extends BaseService { } } - const location = filePath.replace(dirPath, ''); + const location = filePath.replace(rootDir, ''); return await this.create({ ...metadata, location, @@ -255,7 +254,8 @@ export class AttachmentService extends BaseService { if (this.getStoragePlugin()) { return await this.getStoragePlugin().download(attachment); } else { - const path = join(rootDir, attachment.location); + const path = resolve(join(rootDir, attachment.location)); + if (!fileExists(path)) { throw new NotFoundException('No file was found'); } @@ -289,10 +289,12 @@ export class AttachmentService extends BaseService { if (this.getStoragePlugin()) { return await this.getStoragePlugin().readAsBuffer(attachment); } else { - const path = join(rootDir, attachment.location); + const path = resolve(join(rootDir, attachment.location)); + if (!fileExists(path)) { throw new NotFoundException('No file was found'); } + return await fs.promises.readFile(path); // Reads the file content as a Buffer } } diff --git a/api/src/chat/controllers/subscriber.controller.ts b/api/src/chat/controllers/subscriber.controller.ts index 9d330bb1..b6aae62d 100644 --- a/api/src/chat/controllers/subscriber.controller.ts +++ b/api/src/chat/controllers/subscriber.controller.ts @@ -23,7 +23,6 @@ import { AttachmentService } from '@/attachment/services/attachment.service'; import { config } from '@/config'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { LoggerService } from '@/logger/logger.service'; -import { Roles } from '@/utils/decorators/roles.decorator'; import { BaseController } from '@/utils/generics/base-controller'; import { generateInitialsAvatar } from '@/utils/helpers/avatar'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; @@ -145,7 +144,6 @@ export class SubscriberController extends BaseController< * @param id - The unique identifier of the subscriber whose profile picture is to be retrieved. * @returns A streamable file containing the avatar image. */ - @Roles('public') @Get(':id/profile_pic') async getAvatar(@Param('id') id: string): Promise { const subscriber = await this.subscriberService.findOneAndPopulate(id); diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 74033d61..91d48b7f 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -105,12 +105,12 @@ export const config: Config = { from: process.env.EMAIL_SMTP_FROM || 'noreply@example.com', }, parameters: { - uploadDir: process.env.UPLOAD_DIR - ? join(process.cwd(), process.env.UPLOAD_DIR) - : join(process.cwd(), 'uploads'), - avatarDir: process.env.AVATAR_DIR - ? join(process.cwd(), process.env.AVATAR_DIR) - : join(process.cwd(), 'avatars'), + uploadDir: join(process.cwd(), process.env.UPLOAD_DIR || '/uploads'), + avatarDir: join( + process.cwd(), + process.env.UPLOAD_DIR || '/uploads', + '/avatars', + ), storageMode: 'disk', maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES ? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES) diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index d8288afb..ca4886ae 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -6,12 +6,16 @@ * 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 { existsSync, promises as fsPromises } from 'fs'; +import { join } from 'path'; + import mongoose from 'mongoose'; import attachmentSchema, { Attachment, } from '@/attachment/schemas/attachment.schema'; import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; +import { config } from '@/config'; import { MigrationServices } from '../types'; @@ -74,13 +78,37 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { } }; +const updateOldAvatarsPath = async ({ logger }: MigrationServices) => { + const oldPath = join(process.cwd(), process.env.AVATAR_DIR || '/avatars'); + if (existsSync(oldPath)) { + await fsPromises.copyFile(oldPath, config.parameters.avatarDir); + await fsPromises.unlink(oldPath); + logger.log('Avatars folder successfully moved to its new location ...'); + } else { + logger.log('No old avatars folder found ...'); + } +}; + +const restoreOldAvatarsPath = async ({ logger }: MigrationServices) => { + const oldPath = join(process.cwd(), process.env.AVATAR_DIR || '/avatars'); + if (existsSync(config.parameters.avatarDir)) { + await fsPromises.copyFile(config.parameters.avatarDir, oldPath); + await fsPromises.unlink(config.parameters.avatarDir); + logger.log('Avatars folder successfully moved to its old location ...'); + } else { + logger.log('No avatars folder found ...'); + } +}; + module.exports = { async up(services: MigrationServices) { await populateSubscriberAvatar(services); + await updateOldAvatarsPath(services); return true; }, async down(services: MigrationServices) { await unpopulateSubscriberAvatar(services); + await restoreOldAvatarsPath(services); return true; }, }; diff --git a/api/src/plugins/base-storage-plugin.ts b/api/src/plugins/base-storage-plugin.ts index 2a05c8d1..8d01ce46 100644 --- a/api/src/plugins/base-storage-plugin.ts +++ b/api/src/plugins/base-storage-plugin.ts @@ -50,5 +50,6 @@ export abstract class BaseStoragePlugin extends BasePlugin { store?( file: Buffer | 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 a1534afb..67d23cd6 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -95,7 +95,6 @@ export class ReadOnlyUserController extends BaseController< * * @returns A promise that resolves to the user's avatar or an avatar generated from initials if not found. */ - @Roles('public') @Get(':id/profile_pic') async getAvatar(@Param('id') id: string) { const user = await this.userService.findOneAndPopulate(id); diff --git a/docker/.env.example b/docker/.env.example index d32156f3..66f0296a 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -6,6 +6,7 @@ API_PORT=4000 APP_FRONTEND_PORT=8080 APP_SCRIPT_COMPODOC_PORT=9003 API_ORIGIN=http://${APP_DOMAIN}:${API_PORT} +# Specifies if the current instance has primary write (used in DB Migrations) API_IS_PRIMARY_NODE=true FRONTEND_BASE_URL=http://${APP_DOMAIN}:${APP_FRONTEND_PORT} FRONTEND_ORIGIN=${FRONTEND_BASE_URL},http://${APP_DOMAIN}:8081,http://${APP_DOMAIN}:5173,http://${APP_DOMAIN},https://${APP_DOMAIN} @@ -15,8 +16,9 @@ SALT_LENGTH=12 HTTPS_ENABLED=false SESSION_SECRET=f661ff500fff6b0c8f91310b6fff6b0c SESSION_NAME=s.id +# Relative attachments upload directory path to the app folder UPLOAD_DIR=/uploads -AVATAR_DIR=/avatars +# Max attachments upload size in bytes UPLOAD_MAX_SIZE_IN_BYTES=20971520 INVITATION_JWT_SECRET=dev_only INVITATION_EXPIRES_IN=24h @@ -24,7 +26,9 @@ PASSWORD_RESET_JWT_SECRET=dev_only PASSWORD_RESET_EXPIRES_IN=1h CONFIRM_ACCOUNT_SECRET=dev_only CONFIRM_ACCOUNT_EXPIRES_IN=1h +# Public attachments download URLs JWT Sign secret SIGNED_URL_SECRET=dev_only +# Public attachments download URLs download expiration SIGNED_URL_EXPIRES_IN=1h I18N_TRANSLATION_FILENAME=messages diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index bd0f2d39..36ebce9e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,7 +12,6 @@ services: - app-network volumes: - api-data:/app/uploads - - api-avatars-data:/app/avatars depends_on: mongo: condition: service_healthy @@ -56,7 +55,6 @@ services: volumes: mongo-data: api-data: - api-avatars-data: networks: db-network: From d1e9214128f4a907d057b625c11799eaea5d752e Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 6 Jan 2025 12:38:49 +0100 Subject: [PATCH 04/11] fix: move users avatars under the new folder --- .../1735836154221-v-2-2-0.migration.ts | 81 +++++++++++++++++-- api/src/user/controllers/user.controller.ts | 14 +++- api/src/utils/helpers/fs.ts | 69 ++++++++++++++++ 3 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 api/src/utils/helpers/fs.ts diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index ca4886ae..667e0936 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -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 { existsSync, promises as fsPromises } from 'fs'; -import { join } from 'path'; +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; import mongoose from 'mongoose'; @@ -16,6 +16,8 @@ import attachmentSchema, { } from '@/attachment/schemas/attachment.schema'; import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; import { config } from '@/config'; +import userSchema, { User } from '@/user/schemas/user.schema'; +import { moveFile, moveFiles } from '@/utils/helpers/fs'; import { MigrationServices } from '../types'; @@ -79,22 +81,85 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { }; const updateOldAvatarsPath = async ({ logger }: MigrationServices) => { + // Make sure the old folder is moved const oldPath = join(process.cwd(), process.env.AVATAR_DIR || '/avatars'); if (existsSync(oldPath)) { - await fsPromises.copyFile(oldPath, config.parameters.avatarDir); - await fsPromises.unlink(oldPath); + logger.verbose( + `Moving subscriber avatar files from ${oldPath} to ${config.parameters.avatarDir} ...`, + ); + await moveFiles(oldPath, config.parameters.avatarDir); logger.log('Avatars folder successfully moved to its new location ...'); } else { - logger.log('No old avatars folder found ...'); + logger.log(`No old avatars folder found: ${oldPath}`); + } + + // Move users avatars to the "uploads/avatars" folder + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + const UserModel = mongoose.model(User.name, userSchema); + + const cursor = UserModel.find().cursor(); + + for await (const user of cursor) { + try { + if (user.avatar) { + const avatar = await AttachmentModel.findOne({ _id: user.avatar }); + if (avatar) { + const src = resolve( + join(config.parameters.uploadDir, avatar.location), + ); + const dst = resolve( + join(config.parameters.avatarDir, avatar.location), + ); + logger.verbose(`Moving user avatar file from ${src} to ${dst} ...`); + await moveFile(src, dst); + } + } + } catch (err) { + logger.error(err); + logger.error('Unable to move user avatar to the new folder'); + } } }; const restoreOldAvatarsPath = async ({ logger }: MigrationServices) => { + // Move users avatars to the "/app/avatars" folder + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); + const UserModel = mongoose.model(User.name, userSchema); + + const cursor = UserModel.find().cursor(); + + for await (const user of cursor) { + try { + if (user.avatar) { + const avatar = await AttachmentModel.findOne({ _id: user.avatar }); + if (avatar) { + const src = resolve( + join(config.parameters.avatarDir, avatar.location), + ); + const dst = resolve( + join(config.parameters.uploadDir, avatar.location), + ); + logger.verbose(`Moving user avatar file from ${src} to ${dst} ...`); + await moveFile(src, dst); + } + } + } catch (err) { + logger.error(err); + logger.error('Unable to move user avatar to the new folder'); + } + } + + // const oldPath = join(process.cwd(), process.env.AVATAR_DIR || '/avatars'); if (existsSync(config.parameters.avatarDir)) { - await fsPromises.copyFile(config.parameters.avatarDir, oldPath); - await fsPromises.unlink(config.parameters.avatarDir); - logger.log('Avatars folder successfully moved to its old location ...'); + await moveFiles(config.parameters.avatarDir, oldPath); + logger.log('Avatars folder successfully moved to the old location ...'); } else { logger.log('No avatars folder found ...'); } diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 67d23cd6..cc50f821 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -102,14 +102,22 @@ export class ReadOnlyUserController extends BaseController< throw new NotFoundException(`user with ID ${id} not found`); } - if (user.avatar) { + try { + if (!user.avatar) { + throw new Error('User has no avatar'); + } + return await this.attachmentService.download( user.avatar, config.parameters.avatarDir, ); + } catch (err) { + this.logger.verbose( + 'User has no avatar, generating initials avatar ...', + err, + ); + return await generateInitialsAvatar(user); } - - return generateInitialsAvatar(user); } /** diff --git a/api/src/utils/helpers/fs.ts b/api/src/utils/helpers/fs.ts new file mode 100644 index 00000000..c13fe51a --- /dev/null +++ b/api/src/utils/helpers/fs.ts @@ -0,0 +1,69 @@ +/* + * 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 { basename, join, resolve } from 'path'; + +export async function moveFile( + sourcePath: string, + destinationPath: string, + overwrite: boolean = true, +): Promise { + // Check if the file exists at the destination + try { + if (overwrite) { + await fs.promises.unlink(destinationPath); // Remove existing file if overwrite is true + } else { + await fs.promises.access(destinationPath); + throw new Error(`File already exists at destination: ${destinationPath}`); + } + } catch { + // Ignore if file does not exist + } + + // Move the file + await fs.promises.copyFile(sourcePath, destinationPath); + await fs.promises.unlink(sourcePath); + return destinationPath; +} + +/** + * Moves all files from a source folder to a destination folder. + * @param sourceFolder - The folder containing the files to move. + * @param destinationFolder - The folder where the files should be moved. + * @param overwrite - Whether to overwrite files if they already exist at the destination (default: false). + * @returns A promise that resolves when all files have been moved. + */ +export async function moveFiles( + sourceFolder: string, + destinationFolder: string, + overwrite: boolean = true, +): Promise { + // Read the contents of the source folder + const files = await fs.promises.readdir(sourceFolder); + + // Filter only files (skip directories) + const filePaths = await Promise.all( + files.map(async (file) => { + const filePath = join(sourceFolder, file); + const stat = await fs.promises.stat(filePath); + return stat.isFile() ? filePath : null; + }), + ); + + // Move each file to the destination folder + const movePromises = filePaths + .filter((filePath): filePath is string => filePath !== null) + .map((filePath) => { + const fileName = basename(filePath); + const destination = resolve(join(destinationFolder, fileName)); + return moveFile(filePath, destination, overwrite); + }); + + await Promise.all(movePromises); +} From 586337496faf9828e571c7c163f0d8e2f1c14ea1 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 6 Jan 2025 13:27:37 +0100 Subject: [PATCH 05/11] fix: access to own user avatar --- api/src/user/guards/ability.guard.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/api/src/user/guards/ability.guard.ts b/api/src/user/guards/ability.guard.ts index 60e5dc13..095ed904 100644 --- a/api/src/user/guards/ability.guard.ts +++ b/api/src/user/guards/ability.guard.ts @@ -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. @@ -53,9 +53,16 @@ export class Ability implements CanActivate { if (user?.roles?.length) { if ( - ['/auth/logout', '/logout', '/auth/me', '/channel', '/i18n'].includes( - _parsedUrl.pathname, - ) + [ + // Allow access to all routes available for authenticated users + '/auth/logout', + '/logout', + '/auth/me', + '/channel', + '/i18n', + // Allow access to own avatar + `/user/${user.id}/profile_pic`, + ].includes(_parsedUrl.pathname) ) { return true; } From 57fcd9b3a310b78d19951d6de937fe3f755ba351 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 6 Jan 2025 18:52:55 +0100 Subject: [PATCH 06/11] fix: allow users to update own profile and access own avatar --- api/src/user/guards/ability.guard.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/user/guards/ability.guard.ts b/api/src/user/guards/ability.guard.ts index 095ed904..b60a8ac2 100644 --- a/api/src/user/guards/ability.guard.ts +++ b/api/src/user/guards/ability.guard.ts @@ -60,6 +60,8 @@ export class Ability implements CanActivate { '/auth/me', '/channel', '/i18n', + // Allow to update own profile + `/user/edit/${user.id}`, // Allow access to own avatar `/user/${user.id}/profile_pic`, ].includes(_parsedUrl.pathname) From 3721f4365e63493691aa42dcdf7c51fad85edf49 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 6 Jan 2025 18:54:02 +0100 Subject: [PATCH 07/11] feat: allow user/edit/:id endpoint to upload avatar --- api/src/user/controllers/user.controller.ts | 44 ++++++++++++++++++++- api/src/user/dto/user.dto.ts | 9 +++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index cc50f821..adcc0b8a 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -21,11 +21,14 @@ import { Req, Session, UnauthorizedException, + UploadedFile, UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { Request } from 'express'; import { Session as ExpressSession } from 'express-session'; +import { diskStorage, memoryStorage } from 'multer'; import { AttachmentService } from '@/attachment/services/attachment.service'; import { config } from '@/config'; @@ -261,17 +264,54 @@ export class ReadWriteUserController extends ReadOnlyUserController { * @returns A promise that resolves to the updated user. */ @CsrfCheck(true) + @UseInterceptors( + FileInterceptor('avatar', { + limits: { + fileSize: config.parameters.maxUploadSize, + }, + storage: (() => { + if (config.parameters.storageMode === 'memory') { + return memoryStorage(); + } else { + return diskStorage({}); + } + })(), + }), + ) @Patch('edit/:id') async updateOne( @Req() req: Request, @Param('id') id: string, @Body() userUpdate: UserEditProfileDto, + @UploadedFile() avatarFile?: Express.Multer.File, ) { if (!('id' in req.user && req.user.id) || req.user.id !== id) { - throw new UnauthorizedException(); + throw new ForbiddenException(); } - const result = await this.userService.updateOne(req.user.id, userUpdate); + // Upload Avatar if provided + const avatar = avatarFile + ? await this.attachmentService.store( + avatarFile, + { + name: avatarFile.originalname, + size: avatarFile.size, + type: avatarFile.mimetype, + }, + config.parameters.avatarDir, + ) + : undefined; + + const result = await this.userService.updateOne( + req.user.id, + avatar + ? { + ...userUpdate, + avatar: avatar.id, + } + : userUpdate, + ); + if (!result) { this.logger.warn(`Unable to update User by id ${id}`); throw new NotFoundException(`User with ID ${id} not found`); diff --git a/api/src/user/dto/user.dto.ts b/api/src/user/dto/user.dto.ts index e7d7ca4e..56d298ac 100644 --- a/api/src/user/dto/user.dto.ts +++ b/api/src/user/dto/user.dto.ts @@ -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. @@ -14,12 +14,12 @@ import { PartialType, } from '@nestjs/swagger'; import { - IsEmail, - IsNotEmpty, - IsString, IsArray, IsBoolean, + IsEmail, + IsNotEmpty, IsOptional, + IsString, } from 'class-validator'; import { IsObjectId } from '@/utils/validation-rules/is-object-id'; @@ -66,6 +66,7 @@ export class UserCreateDto { export class UserEditProfileDto extends OmitType(PartialType(UserCreateDto), [ 'username', 'roles', + 'avatar', ]) { @ApiPropertyOptional({ description: 'User language', type: String }) @IsOptional() From 355c6ebe26127716174aa5cacf3bf18d4e739026 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 6 Jan 2025 19:06:11 +0100 Subject: [PATCH 08/11] feat: add AvatarInput to update own avatar --- frontend/public/locales/en/translation.json | 1 + frontend/public/locales/fr/translation.json | 1 + .../src/app-components/inputs/AvatarInput.tsx | 89 +++++++++++++++++++ frontend/src/components/profile/profile.tsx | 39 ++------ frontend/src/hooks/entities/auth-hooks.ts | 12 ++- frontend/src/services/api.class.ts | 32 +++++-- frontend/src/types/user.types.ts | 8 +- 7 files changed, 140 insertions(+), 42 deletions(-) create mode 100644 frontend/src/app-components/inputs/AvatarInput.tsx diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 2ee95216..838d68d1 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -532,6 +532,7 @@ "invite": "Invite", "send": "Send", "fields": "Fields", + "upload": "Upload", "import": "Import", "export": "Export", "manage": "Manage", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 51ee928a..2181529e 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -533,6 +533,7 @@ "invite": "Inviter", "send": "Envoyer", "fields": "Champs", + "upload": "Télécharger", "import": "Import", "export": "Export", "manage": "Gérer", diff --git a/frontend/src/app-components/inputs/AvatarInput.tsx b/frontend/src/app-components/inputs/AvatarInput.tsx new file mode 100644 index 00000000..6141c62f --- /dev/null +++ b/frontend/src/app-components/inputs/AvatarInput.tsx @@ -0,0 +1,89 @@ +/* + * 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 { Avatar, Box, FormHelperText, FormLabel } from "@mui/material"; +import { forwardRef, useState } from "react"; + +import { getAvatarSrc } from "@/components/inbox/helpers/mapMessages"; +import { useAuth } from "@/hooks/useAuth"; +import { useConfig } from "@/hooks/useConfig"; +import { useTranslate } from "@/hooks/useTranslate"; +import { theme } from "@/layout/themes/theme"; +import { EntityType } from "@/services/types"; + +import FileUploadButton from "./FileInput"; + +type AvatarInputProps = { + label: string; + value: File | undefined | null; + accept: string; + size: number; + onChange: (file: File) => void; + error?: boolean; + helperText?: string; +}; + +const AvatarInput = forwardRef( + ({ label, accept, size, onChange, error, helperText }, ref) => { + const { apiUrl } = useConfig(); + const { user } = useAuth(); + const [avatarSrc, setAvatarSrc] = useState( + getAvatarSrc(apiUrl, EntityType.USER, user?.id), + ); + const { t } = useTranslate(); + const handleChange = (file: File) => { + onChange(file); + setAvatarSrc(URL.createObjectURL(file)); + }; + + return ( + + + {label} + + + + + + {helperText ? ( + {helperText} + ) : null} + + ); + }, +); + +AvatarInput.displayName = "AttachmentInput"; + +export default AvatarInput; diff --git a/frontend/src/components/profile/profile.tsx b/frontend/src/components/profile/profile.tsx index 16d17c84..2723d102 100644 --- a/frontend/src/components/profile/profile.tsx +++ b/frontend/src/components/profile/profile.tsx @@ -1,13 +1,13 @@ /* - * 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 CheckIcon from "@mui/icons-material/Check"; -import DeleteIcon from "@mui/icons-material/Delete"; import EmailIcon from "@mui/icons-material/Email"; import KeyIcon from "@mui/icons-material/Key"; import LanguageIcon from "@mui/icons-material/Language"; @@ -16,10 +16,10 @@ import { FC } from "react"; import { Controller, useForm } from "react-hook-form"; import { useQueryClient } from "react-query"; -import AttachmentInput from "@/app-components/attachment/AttachmentInput"; import { ContentItem } from "@/app-components/dialogs"; import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer"; import { Adornment } from "@/app-components/inputs/Adornment"; +import AvatarInput from "@/app-components/inputs/AvatarInput"; import { Input } from "@/app-components/inputs/Input"; import { PasswordInput } from "@/app-components/inputs/PasswordInput"; import { useUpdateProfile } from "@/hooks/entities/auth-hooks"; @@ -27,11 +27,9 @@ import { CURRENT_USER_KEY } from "@/hooks/useAuth"; import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { useValidationRules } from "@/hooks/useValidationRules"; -import { IUser, IUserAttributes } from "@/types/user.types"; +import { IProfileAttributes, IUser } from "@/types/user.types"; import { MIME_TYPES } from "@/utils/attachment"; -type TUserProfileExtendedPayload = IUserAttributes & { password2: string }; - type ProfileFormProps = { user: IUser }; export const ProfileForm: FC = ({ user }) => { @@ -55,14 +53,12 @@ export const ProfileForm: FC = ({ user }) => { formState: { errors }, register, setValue, - getValues, - } = useForm({ + } = useForm({ defaultValues: { first_name: user.first_name, last_name: user.last_name, email: user.email, language: user.language, - avatar: user.avatar, }, }); const rules = useValidationRules(); @@ -89,7 +85,7 @@ export const ProfileForm: FC = ({ user }) => { password, password2: _password2, ...rest - }: TUserProfileExtendedPayload) => { + }: IProfileAttributes) => { await updateProfile({ ...rest, password: password || undefined, @@ -106,32 +102,13 @@ export const ProfileForm: FC = ({ user }) => { render={({ field }) => ( <> - setValue("avatar", attachment)} + onChange={(file) => setValue("avatar", file)} /> - {getValues("avatar") ? ( - - ) : null} { export const useUpdateProfile = ( options?: Omit< - TMutationOptions>, + TMutationOptions>, "mutationFn" >, ) => { diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index b6370c4b..091c691d 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -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 { AxiosInstance, AxiosResponse } from "axios"; import { ILoginAttributes } from "@/types/auth/login.types"; @@ -15,7 +16,12 @@ import { ICsrf } from "@/types/csrf.types"; import { IInvitation, IInvitationAttributes } from "@/types/invitation.types"; import { INlpDatasetSampleAttributes } from "@/types/nlp-sample.types"; import { IResetPayload, IResetRequest } from "@/types/reset.types"; -import { IUser, IUserAttributes, IUserStub } from "@/types/user.types"; +import { + IProfileAttributes, + IUser, + IUserAttributes, + IUserStub, +} from "@/types/user.types"; import { EntityType, Format, TCount, TypeByFormat } from "./types"; @@ -100,15 +106,27 @@ export class ApiClient { return data; } - async updateProfile(id: string, payload: Partial) { + async updateProfile(id: string, payload: Partial) { const { _csrf } = await this.getCsrf(); + const formData = new FormData(); + + for (const [key, value] of Object.entries(payload)) { + if (value !== undefined) { + formData.append(key, value as string | Blob); + } + } + + // Append the CSRF token + formData.append("_csrf", _csrf); + const { data } = await this.request.patch< IUserStub, AxiosResponse, - Partial & ICsrf - >(`${ROUTES.PROFILE}/${id}`, { - ...payload, - _csrf, + Partial + >(`${ROUTES.PROFILE}/${id}?_csrf=${_csrf}`, payload, { + headers: { + "Content-Type": "multipart/form-data", + }, }); return data; diff --git a/frontend/src/types/user.types.ts b/frontend/src/types/user.types.ts index e9c85f8c..d9aa763e 100644 --- a/frontend/src/types/user.types.ts +++ b/frontend/src/types/user.types.ts @@ -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 { EntityType, Format } from "@/services/types"; import { IAttachment } from "./attachment.types"; @@ -27,6 +28,11 @@ export interface IUserStub extends IBaseSchema, OmitPopulate {} +export interface IProfileAttributes extends Partial { + password2?: string; + avatar?: File | null; +} + export interface IUser extends IUserStub, IFormat { roles: string[]; //populated by default avatar: string | null; From 1ea0ce2a7fcdc82473ce1d135390e15c869697a8 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Tue, 7 Jan 2025 16:36:16 +0100 Subject: [PATCH 09/11] fix: subscriber avatar + update migration --- .../chat/controllers/subscriber.controller.ts | 15 ++- .../1735836154221-v-2-2-0.migration.ts | 123 ++++++++++++++++-- 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/api/src/chat/controllers/subscriber.controller.ts b/api/src/chat/controllers/subscriber.controller.ts index b6aae62d..f7e53806 100644 --- a/api/src/chat/controllers/subscriber.controller.ts +++ b/api/src/chat/controllers/subscriber.controller.ts @@ -152,13 +152,22 @@ export class SubscriberController extends BaseController< throw new NotFoundException(`Subscriber with ID ${id} not found`); } - if (subscriber.avatar) { - return this.attachmentService.download( + try { + if (!subscriber.avatar) { + throw new Error('User has no avatar'); + } + + return await this.attachmentService.download( subscriber.avatar, config.parameters.avatarDir, ); + } catch (err) { + this.logger.verbose( + 'Subscriber has no avatar, generating initials avatar ...', + err, + ); + return await generateInitialsAvatar(subscriber); } - return generateInitialsAvatar(subscriber); } @CsrfCheck(true) diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 667e0936..6ec88545 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -21,6 +21,12 @@ import { moveFile, moveFiles } from '@/utils/helpers/fs'; import { MigrationServices } from '../types'; +/** + * Updates subscriber documents with their corresponding avatar attachments + * and moves avatar files to a new directory. + * + * @returns Resolves when the migration process is complete. + */ const populateSubscriberAvatar = async ({ logger }: MigrationServices) => { const AttachmentModel = mongoose.model( Attachment.name, @@ -50,17 +56,48 @@ const populateSubscriberAvatar = async ({ logger }: MigrationServices) => { { $set: { avatar: attachment._id } }, ); logger.log( - `Avatar attachment successfully updated for subscriber ${subscriber._id}`, + `Subscriber ${subscriber._id} avatar attachment successfully updated for `, ); + + 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!`, + ); + } } else { - logger.debug( + logger.warn( `No avatar attachment found for subscriber ${subscriber._id}`, ); } } }; +/** + * Reverts what the previous function does + * + * @returns Resolves when the migration process is complete. + */ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { + const AttachmentModel = mongoose.model( + Attachment.name, + attachmentSchema, + ); const SubscriberModel = mongoose.model( Subscriber.name, subscriberSchema, @@ -70,16 +107,57 @@ const unpopulateSubscriberAvatar = async ({ logger }: MigrationServices) => { const cursor = SubscriberModel.find({ avatar: { $exists: true } }).cursor(); for await (const subscriber of cursor) { - await SubscriberModel.updateOne( - { _id: subscriber._id }, - { $set: { avatar: null } }, - ); - logger.log( - `Avatar attachment successfully updated for subscriber ${subscriber._id}`, - ); + 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!`, + ); + } + } 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}`, + ); + } + } } }; +/** + * Migrates and updates the paths of old folder "avatars" files for subscribers and users. + * + * @returns Resolves when the migration process is complete. + */ const updateOldAvatarsPath = async ({ logger }: MigrationServices) => { // Make sure the old folder is moved const oldPath = join(process.cwd(), process.env.AVATAR_DIR || '/avatars'); @@ -87,8 +165,13 @@ const updateOldAvatarsPath = async ({ logger }: MigrationServices) => { logger.verbose( `Moving subscriber avatar files from ${oldPath} to ${config.parameters.avatarDir} ...`, ); - await moveFiles(oldPath, config.parameters.avatarDir); - logger.log('Avatars folder successfully moved to its new location ...'); + try { + await moveFiles(oldPath, config.parameters.avatarDir); + logger.log('Avatars folder successfully moved to its new location ...'); + } catch (err) { + logger.error(err); + logger.error('Unable to move files from the old "avatars" folder'); + } } else { logger.log(`No old avatars folder found: ${oldPath}`); } @@ -124,6 +207,11 @@ const updateOldAvatarsPath = async ({ logger }: MigrationServices) => { } }; +/** + * Reverts what the previous function does + * + * @returns Resolves when the migration process is complete. + */ const restoreOldAvatarsPath = async ({ logger }: MigrationServices) => { // Move users avatars to the "/app/avatars" folder const AttachmentModel = mongoose.model( @@ -156,10 +244,17 @@ const restoreOldAvatarsPath = async ({ logger }: MigrationServices) => { } // - const oldPath = join(process.cwd(), process.env.AVATAR_DIR || '/avatars'); + const oldPath = resolve( + join(process.cwd(), process.env.AVATAR_DIR || '/avatars'), + ); if (existsSync(config.parameters.avatarDir)) { - await moveFiles(config.parameters.avatarDir, oldPath); - logger.log('Avatars folder successfully moved to the old location ...'); + try { + await moveFiles(config.parameters.avatarDir, oldPath); + logger.log('Avatars folder successfully moved to the old location ...'); + } catch (err) { + logger.error(err); + logger.log('Unable to move avatar files to the old folder ...'); + } } else { logger.log('No avatars folder found ...'); } From 282b69f4a365c4e91a06fbd2af85e0c31e648c72 Mon Sep 17 00:00:00 2001 From: Med Marrouchi Date: Wed, 8 Jan 2025 17:06:52 +0100 Subject: [PATCH 10/11] Update frontend/public/locales/fr/translation.json --- frontend/public/locales/fr/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 2181529e..521b911a 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -533,7 +533,7 @@ "invite": "Inviter", "send": "Envoyer", "fields": "Champs", - "upload": "Télécharger", + "upload": "Téléverser", "import": "Import", "export": "Export", "manage": "Gérer", From 30202bac79b732cba4fc3f1c4d4d63ce90375740 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 8 Jan 2025 17:32:58 +0100 Subject: [PATCH 11/11] fix: use for loop in fs operations --- api/src/utils/helpers/fs.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/api/src/utils/helpers/fs.ts b/api/src/utils/helpers/fs.ts index c13fe51a..d9c3ae39 100644 --- a/api/src/utils/helpers/fs.ts +++ b/api/src/utils/helpers/fs.ts @@ -48,22 +48,19 @@ export async function moveFiles( const files = await fs.promises.readdir(sourceFolder); // Filter only files (skip directories) - const filePaths = await Promise.all( - files.map(async (file) => { - const filePath = join(sourceFolder, file); - const stat = await fs.promises.stat(filePath); - return stat.isFile() ? filePath : null; - }), - ); + const filePaths = []; + for (const file of files) { + const filePath = join(sourceFolder, file); + const stat = await fs.promises.stat(filePath); + if (stat.isFile()) { + filePaths.push(filePath); + } + } // Move each file to the destination folder - const movePromises = filePaths - .filter((filePath): filePath is string => filePath !== null) - .map((filePath) => { - const fileName = basename(filePath); - const destination = resolve(join(destinationFolder, fileName)); - return moveFile(filePath, destination, overwrite); - }); - - await Promise.all(movePromises); + for (const filePath of filePaths) { + const fileName = basename(filePath); + const destination = resolve(join(destinationFolder, fileName)); + await moveFile(filePath, destination, overwrite); + } }