Merge pull request #517 from Hexastack/refactor/avatar-upload
Some checks are pending
Build and Push Docker API Image / build-and-push (push) Waiting to run
Build and Push Docker Base Image / build-and-push (push) Waiting to run
Build and Push Docker UI Image / build-and-push (push) Waiting to run

Refactor Subscriber/User Avatar Upload/Download
This commit is contained in:
Med Marrouchi 2025-01-08 17:51:01 +01:00 committed by GitHub
commit c720d3b602
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 727 additions and 224 deletions

View File

@ -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: * 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. * 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). * 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 { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { config } from '@/config';
import { AttachmentController } from './controllers/attachment.controller'; import { AttachmentController } from './controllers/attachment.controller';
import { AttachmentRepository } from './repositories/attachment.repository'; import { AttachmentRepository } from './repositories/attachment.repository';
import { AttachmentModel } from './schemas/attachment.schema'; import { AttachmentModel } from './schemas/attachment.schema';
@ -26,4 +30,14 @@ import { AttachmentService } from './services/attachment.service';
controllers: [AttachmentController], controllers: [AttachmentController],
exports: [AttachmentService], 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 });
}
}
}

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: * 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. * 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 fs, { createReadStream, promises as fsPromises } from 'fs'; import fs, { createReadStream, promises as fsPromises } from 'fs';
import path, { join } from 'path'; import { join, resolve } from 'path';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { import {
@ -73,6 +73,7 @@ export class AttachmentService extends BaseService<Attachment> {
/** /**
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration. * 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. * @param foreign_id The unique identifier of the user, used to locate the profile picture.
* @returns A `StreamableFile` containing the user's profile picture. * @returns A `StreamableFile` containing the user's profile picture.
*/ */
@ -87,7 +88,9 @@ export class AttachmentService extends BaseService<Attachment> {
throw new NotFoundException('Profile picture not found'); throw new NotFoundException('Profile picture not found');
} }
} else { } else {
const path = join(config.parameters.avatarDir, `${foreign_id}.jpeg`); const path = resolve(
join(config.parameters.avatarDir, `${foreign_id}.jpeg`),
);
if (fs.existsSync(path)) { if (fs.existsSync(path)) {
const picturetream = createReadStream(path); const picturetream = createReadStream(path);
return new StreamableFile(picturetream); return new StreamableFile(picturetream);
@ -100,6 +103,7 @@ export class AttachmentService extends BaseService<Attachment> {
/** /**
* Uploads a profile picture to either 3rd party storage system or locally based on the configuration. * 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 res - The response object from which the profile picture will be buffered or piped.
* @param filename - The filename * @param filename - The filename
*/ */
@ -127,14 +131,9 @@ export class AttachmentService extends BaseService<Attachment> {
} }
} else { } else {
// Save profile picture locally // Save profile picture locally
const dirPath = path.join(config.parameters.avatarDir, filename); const dirPath = resolve(join(config.parameters.avatarDir, filename));
try { try {
// Ensure the directory exists
await fs.promises.mkdir(config.parameters.avatarDir, {
recursive: true,
});
if (Buffer.isBuffer(data)) { if (Buffer.isBuffer(data)) {
await fs.promises.writeFile(dirPath, data); await fs.promises.writeFile(dirPath, data);
} else { } else {
@ -157,6 +156,7 @@ export class AttachmentService extends BaseService<Attachment> {
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly. * Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
* Otherwise, uploads files to the local directory. * Otherwise, uploads files to the local directory.
* *
* @deprecated use store() instead
* @param files - An array of files to upload. * @param files - An array of files to upload.
* @returns A promise that resolves to an array of uploaded attachments. * @returns A promise that resolves to an array of uploaded attachments.
*/ */
@ -192,23 +192,25 @@ export class AttachmentService extends BaseService<Attachment> {
* Otherwise, uploads files to the local directory. * Otherwise, uploads files to the local directory.
* *
* @param file - The file * @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. * @returns A promise that resolves to an array of uploaded attachments.
*/ */
async store( async store(
file: Buffer | Readable | Express.Multer.File, file: Buffer | Readable | Express.Multer.File,
metadata: AttachmentMetadataDto, metadata: AttachmentMetadataDto,
rootDir = config.parameters.uploadDir,
): Promise<Attachment> { ): Promise<Attachment> {
if (this.getStoragePlugin()) { 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); return await this.create(storedDto);
} else { } else {
const dirPath = path.join(config.parameters.uploadDir);
const uniqueFilename = generateUniqueFilename(metadata.name); const uniqueFilename = generateUniqueFilename(metadata.name);
const filePath = path.resolve(dirPath, sanitizeFilename(uniqueFilename)); const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename)));
if (!filePath.startsWith(dirPath)) {
throw new Error('Invalid file path');
}
if (Buffer.isBuffer(file)) { if (Buffer.isBuffer(file)) {
await fsPromises.writeFile(filePath, file); await fsPromises.writeFile(filePath, file);
@ -222,7 +224,7 @@ export class AttachmentService extends BaseService<Attachment> {
} else { } else {
if (file.path) { if (file.path) {
// For example, if the file is an instance of `Express.Multer.File` (diskStorage case) // 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.copyFile(srcFilePath, filePath);
await fsPromises.unlink(srcFilePath); await fsPromises.unlink(srcFilePath);
} else { } else {
@ -230,7 +232,7 @@ export class AttachmentService extends BaseService<Attachment> {
} }
} }
const location = filePath.replace(dirPath, ''); const location = filePath.replace(rootDir, '');
return await this.create({ return await this.create({
...metadata, ...metadata,
location, location,
@ -242,18 +244,22 @@ export class AttachmentService extends BaseService<Attachment> {
* Downloads an attachment identified by the provided parameters. * 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. * @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()) { if (this.getStoragePlugin()) {
return await this.getStoragePlugin().download(attachment); return await this.getStoragePlugin().download(attachment);
} else { } else {
if (!fileExists(attachment.location)) { const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) {
throw new NotFoundException('No file was found'); throw new NotFoundException('No file was found');
} }
const path = join(config.parameters.uploadDir, attachment.location);
const disposition = `attachment; filename="${encodeURIComponent( const disposition = `attachment; filename="${encodeURIComponent(
attachment.name, attachment.name,
)}"`; )}"`;
@ -273,17 +279,23 @@ export class AttachmentService extends BaseService<Attachment> {
* Downloads an attachment identified by the provided parameters as a Buffer. * 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. * @returns A promise that resolves to a Buffer representing the downloaded attachment.
*/ */
async readAsBuffer(attachment: Attachment): Promise<Buffer> { async readAsBuffer(
attachment: Attachment,
rootDir = config.parameters.uploadDir,
): Promise<Buffer> {
if (this.getStoragePlugin()) { if (this.getStoragePlugin()) {
return await this.getStoragePlugin().readAsBuffer(attachment); return await this.getStoragePlugin().readAsBuffer(attachment);
} else { } else {
if (!fileExists(attachment.location)) { const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) {
throw new NotFoundException('No file was found'); 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
} }
} }
} }

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: * 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. * 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 { createReadStream, existsSync } from 'fs';
import { extname, join } from 'path'; import { extname } from 'path';
import { Logger, StreamableFile } from '@nestjs/common'; import { Logger, StreamableFile } from '@nestjs/common';
import { StreamableFileOptions } from '@nestjs/common/file-stream/interfaces/streamable-options.interface'; 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. * Checks if a file exists in the specified upload directory.
* @param location The relative location of the file. * @param filePath The relative location of the file.
* @returns Whether the file exists. * @returns True if the file exists.
*/ */
export const fileExists = (location: string): boolean => { export const fileExists = (filePath: string): boolean => {
// bypass test env // bypass test env
if (config.env === 'test') { if (config.env === 'test') {
return true; return true;
} }
try { try {
const dirPath = config.parameters.uploadDir; return existsSync(filePath);
const fileLocation = join(dirPath, location);
return existsSync(fileLocation);
} catch (e) { } catch (e) {
new Logger(`Attachment Model : Unable to locate file: ${location}`); new Logger(`Attachment Model : Unable to locate file: ${filePath}`);
return false; return false;
} }
}; };

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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -19,9 +19,10 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { config } from '@/config';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { Roles } from '@/utils/decorators/roles.decorator';
import { BaseController } from '@/utils/generics/base-controller'; import { BaseController } from '@/utils/generics/base-controller';
import { generateInitialsAvatar } from '@/utils/helpers/avatar'; import { generateInitialsAvatar } from '@/utils/helpers/avatar';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
@ -49,11 +50,21 @@ export class SubscriberController extends BaseController<
> { > {
constructor( constructor(
private readonly subscriberService: SubscriberService, private readonly subscriberService: SubscriberService,
private readonly attachmentService: AttachmentService,
private readonly logger: LoggerService, private readonly logger: LoggerService,
) { ) {
super(subscriberService); 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() @Get()
async findPage( async findPage(
@Query(PageQueryPipe) pageQuery: PageQueryDto<Subscriber>, @Query(PageQueryPipe) pageQuery: PageQueryDto<Subscriber>,
@ -79,8 +90,10 @@ export class SubscriberController extends BaseController<
} }
/** /**
* Counts the filtered number of subscribers. * Retrieves the count of subscribers that match the provided search filters.
* @returns A promise that resolves to an object representing the filtered number of subscribers. *
* @param filters - Optional search filters to apply on the Subscriber model.
* @returns A promise containing the count of subscribers matching the filters.
*/ */
@Get('count') @Get('count')
async filterCount( async filterCount(
@ -100,6 +113,14 @@ export class SubscriberController extends BaseController<
return await this.count(filters); 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') @Get(':id')
async findOne( async findOne(
@Param('id') id: string, @Param('id') id: string,
@ -116,23 +137,36 @@ export class SubscriberController extends BaseController<
return doc; return doc;
} }
@Roles('public') /**
@Get(':foreign_id/profile_pic') * Retrieves the profile picture (avatar) of a subscriber by their unique ID.
async findProfilePic( * If no avatar is set, generates an initials-based avatar.
@Param('foreign_id') foreign_id: string, *
): Promise<StreamableFile> { * @param id - The unique identifier of the subscriber whose profile picture is to be retrieved.
try { * @returns A streamable file containing the avatar image.
const pic = await this.subscriberService.findProfilePic(foreign_id); */
return pic; @Get(':id/profile_pic')
} catch (e) { async getAvatar(@Param('id') id: string): Promise<StreamableFile> {
const [subscriber] = await this.subscriberService.find({ foreign_id }); const subscriber = await this.subscriberService.findOneAndPopulate(id);
if (subscriber) {
return generateInitialsAvatar(subscriber); if (!subscriber) {
} else { throw new NotFoundException(`Subscriber with ID ${id} not found`);
throw new NotFoundException(
`Subscriber with ID ${foreign_id} not found`,
);
} }
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);
} }
} }

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: * 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. * 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 { import {
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
NotFoundException,
Optional, Optional,
StreamableFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
@ -139,22 +137,6 @@ export class SubscriberService extends BaseService<
return await this.repository.handOverByForeignIdQuery(foreignId, userId); 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<StreamableFile> {
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 : * Apply updates on end-user such as :
* - Assign labels to specific end-user * - Assign labels to specific end-user

View File

@ -105,12 +105,12 @@ export const config: Config = {
from: process.env.EMAIL_SMTP_FROM || 'noreply@example.com', from: process.env.EMAIL_SMTP_FROM || 'noreply@example.com',
}, },
parameters: { parameters: {
uploadDir: process.env.UPLOAD_DIR uploadDir: join(process.cwd(), process.env.UPLOAD_DIR || '/uploads'),
? join(process.cwd(), process.env.UPLOAD_DIR) avatarDir: join(
: join(process.cwd(), 'uploads'), process.cwd(),
avatarDir: process.env.AVATAR_DIR process.env.UPLOAD_DIR || '/uploads',
? join(process.cwd(), process.env.AVATAR_DIR) '/avatars',
: join(process.cwd(), 'avatars'), ),
storageMode: 'disk', storageMode: 'disk',
maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES
? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES) ? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES)

View File

@ -53,7 +53,7 @@ export class MigrationService implements OnApplicationBootstrap {
if (mongoose.connection.readyState !== 1) { if (mongoose.connection.readyState !== 1) {
await this.connect(); await this.connect();
} }
this.logger.log('Mongoose connection established'); this.logger.log('Mongoose connection established!');
if (!this.isCLI && config.mongo.autoMigrate) { if (!this.isCLI && config.mongo.autoMigrate) {
this.logger.log('Executing migrations ...'); this.logger.log('Executing migrations ...');

View File

@ -0,0 +1,274 @@
/*
* 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 { existsSync } from 'fs';
import { join, resolve } 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 userSchema, { User } from '@/user/schemas/user.schema';
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>(
Attachment.name,
attachmentSchema,
);
const SubscriberModel = mongoose.model<Subscriber>(
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(
`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.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>(
Attachment.name,
attachmentSchema,
);
const SubscriberModel = mongoose.model<Subscriber>(
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) {
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');
if (existsSync(oldPath)) {
logger.verbose(
`Moving subscriber avatar files from ${oldPath} to ${config.parameters.avatarDir} ...`,
);
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}`);
}
// Move users avatars to the "uploads/avatars" folder
const AttachmentModel = mongoose.model<Attachment>(
Attachment.name,
attachmentSchema,
);
const UserModel = mongoose.model<User>(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');
}
}
};
/**
* 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<Attachment>(
Attachment.name,
attachmentSchema,
);
const UserModel = mongoose.model<User>(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 = resolve(
join(process.cwd(), process.env.AVATAR_DIR || '/avatars'),
);
if (existsSync(config.parameters.avatarDir)) {
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 ...');
}
};
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;
},
};

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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -28,20 +28,28 @@ export abstract class BaseStoragePlugin extends BasePlugin {
super(name, pluginService); super(name, pluginService);
} }
abstract fileExists(attachment: Attachment): Promise<boolean>; /** @deprecated use download() instead */
fileExists?(attachment: Attachment): Promise<boolean>;
abstract upload(file: Express.Multer.File): Promise<AttachmentCreateDto>; /** @deprecated use store() instead */
upload?(file: Express.Multer.File): Promise<AttachmentCreateDto>;
abstract uploadAvatar(file: Express.Multer.File): Promise<any>; /** @deprecated use store() instead */
uploadAvatar?(file: Express.Multer.File): Promise<any>;
abstract download(attachment: Attachment): Promise<StreamableFile>; abstract download(
attachment: Attachment,
rootLocation?: string,
): Promise<StreamableFile>;
abstract downloadProfilePic(name: string): Promise<StreamableFile>; /** @deprecated use download() instead */
downloadProfilePic?(name: string): Promise<StreamableFile>;
readAsBuffer?(attachment: Attachment): Promise<Buffer>; readAsBuffer?(attachment: Attachment): Promise<Buffer>;
store?( store?(
file: Buffer | Readable | Express.Multer.File, file: Buffer | Readable | Express.Multer.File,
metadata: AttachmentMetadataDto, metadata: AttachmentMetadataDto,
rootDir?: string,
): Promise<Attachment>; ): Promise<Attachment>;
} }

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: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -21,13 +21,17 @@ import {
Req, Req,
Session, Session,
UnauthorizedException, UnauthorizedException,
UploadedFile,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
import { Request } from 'express'; import { Request } from 'express';
import { Session as ExpressSession } from 'express-session'; import { Session as ExpressSession } from 'express-session';
import { diskStorage, memoryStorage } from 'multer';
import { AttachmentService } from '@/attachment/services/attachment.service'; import { AttachmentService } from '@/attachment/services/attachment.service';
import { config } from '@/config';
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { Roles } from '@/utils/decorators/roles.decorator'; import { Roles } from '@/utils/decorators/roles.decorator';
@ -83,7 +87,7 @@ export class ReadOnlyUserController extends BaseController<
*/ */
@Roles('public') @Roles('public')
@Get('bot/profile_pic') @Get('bot/profile_pic')
async botProfilePic(@Query('color') color: string) { async getBotAvatar(@Query('color') color: string) {
return await getBotAvatar(color); return await getBotAvatar(color);
} }
@ -94,19 +98,28 @@ export class ReadOnlyUserController extends BaseController<
* *
* @returns A promise that resolves to the user's avatar or an avatar generated from initials if not found. * @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') @Get(':id/profile_pic')
async UserProfilePic(@Param('id') id: string) { async getAvatar(@Param('id') id: string) {
try { const user = await this.userService.findOneAndPopulate(id);
const res = await this.userService.userProfilePic(id); if (!user) {
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`); throw new NotFoundException(`user with ID ${id} not found`);
} }
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);
} }
} }
@ -251,17 +264,54 @@ export class ReadWriteUserController extends ReadOnlyUserController {
* @returns A promise that resolves to the updated user. * @returns A promise that resolves to the updated user.
*/ */
@CsrfCheck(true) @CsrfCheck(true)
@UseInterceptors(
FileInterceptor('avatar', {
limits: {
fileSize: config.parameters.maxUploadSize,
},
storage: (() => {
if (config.parameters.storageMode === 'memory') {
return memoryStorage();
} else {
return diskStorage({});
}
})(),
}),
)
@Patch('edit/:id') @Patch('edit/:id')
async updateOne( async updateOne(
@Req() req: Request, @Req() req: Request,
@Param('id') id: string, @Param('id') id: string,
@Body() userUpdate: UserEditProfileDto, @Body() userUpdate: UserEditProfileDto,
@UploadedFile() avatarFile?: Express.Multer.File,
) { ) {
if (!('id' in req.user && req.user.id) || req.user.id !== id) { 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) { if (!result) {
this.logger.warn(`Unable to update User by id ${id}`); this.logger.warn(`Unable to update User by id ${id}`);
throw new NotFoundException(`User with ID ${id} not found`); throw new NotFoundException(`User with ID ${id} not found`);

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: * 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. * 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, PartialType,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { import {
IsEmail,
IsNotEmpty,
IsString,
IsArray, IsArray,
IsBoolean, IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional, IsOptional,
IsString,
} from 'class-validator'; } from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id'; import { IsObjectId } from '@/utils/validation-rules/is-object-id';
@ -66,6 +66,7 @@ export class UserCreateDto {
export class UserEditProfileDto extends OmitType(PartialType(UserCreateDto), [ export class UserEditProfileDto extends OmitType(PartialType(UserCreateDto), [
'username', 'username',
'roles', 'roles',
'avatar',
]) { ]) {
@ApiPropertyOptional({ description: 'User language', type: String }) @ApiPropertyOptional({ description: 'User language', type: String })
@IsOptional() @IsOptional()

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: * 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. * 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,18 @@ export class Ability implements CanActivate {
if (user?.roles?.length) { if (user?.roles?.length) {
if ( 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 to update own profile
`/user/edit/${user.id}`,
// Allow access to own avatar
`/user/${user.id}/profile_pic`,
].includes(_parsedUrl.pathname)
) { ) {
return true; return true;
} }

View File

@ -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: * 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. * 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). * 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 { BaseService } from '@/utils/generics/base-service';
import { UserRepository } from '../repositories/user.repository'; import { UserRepository } from '../repositories/user.repository';
@ -22,33 +18,4 @@ export class UserService extends BaseService<User, UserPopulate, UserFull> {
constructor(readonly repository: UserRepository) { constructor(readonly repository: UserRepository) {
super(repository); 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<StreamableFile> {
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');
}
}
} }

View File

@ -0,0 +1,66 @@
/*
* 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<string> {
// 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<void> {
// Read the contents of the source folder
const files = await fs.promises.readdir(sourceFolder);
// Filter only files (skip directories)
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
for (const filePath of filePaths) {
const fileName = basename(filePath);
const destination = resolve(join(destinationFolder, fileName));
await moveFile(filePath, destination, overwrite);
}
}

View File

@ -6,6 +6,7 @@ API_PORT=4000
APP_FRONTEND_PORT=8080 APP_FRONTEND_PORT=8080
APP_SCRIPT_COMPODOC_PORT=9003 APP_SCRIPT_COMPODOC_PORT=9003
API_ORIGIN=http://${APP_DOMAIN}:${API_PORT} API_ORIGIN=http://${APP_DOMAIN}:${API_PORT}
# Specifies if the current instance has primary write (used in DB Migrations)
API_IS_PRIMARY_NODE=true API_IS_PRIMARY_NODE=true
FRONTEND_BASE_URL=http://${APP_DOMAIN}:${APP_FRONTEND_PORT} 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} 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 HTTPS_ENABLED=false
SESSION_SECRET=f661ff500fff6b0c8f91310b6fff6b0c SESSION_SECRET=f661ff500fff6b0c8f91310b6fff6b0c
SESSION_NAME=s.id SESSION_NAME=s.id
# Relative attachments upload directory path to the app folder
UPLOAD_DIR=/uploads UPLOAD_DIR=/uploads
AVATAR_DIR=/avatars # Max attachments upload size in bytes
UPLOAD_MAX_SIZE_IN_BYTES=20971520 UPLOAD_MAX_SIZE_IN_BYTES=20971520
INVITATION_JWT_SECRET=dev_only INVITATION_JWT_SECRET=dev_only
INVITATION_EXPIRES_IN=24h INVITATION_EXPIRES_IN=24h
@ -24,7 +26,9 @@ PASSWORD_RESET_JWT_SECRET=dev_only
PASSWORD_RESET_EXPIRES_IN=1h PASSWORD_RESET_EXPIRES_IN=1h
CONFIRM_ACCOUNT_SECRET=dev_only CONFIRM_ACCOUNT_SECRET=dev_only
CONFIRM_ACCOUNT_EXPIRES_IN=1h CONFIRM_ACCOUNT_EXPIRES_IN=1h
# Public attachments download URLs JWT Sign secret
SIGNED_URL_SECRET=dev_only SIGNED_URL_SECRET=dev_only
# Public attachments download URLs download expiration
SIGNED_URL_EXPIRES_IN=1h SIGNED_URL_EXPIRES_IN=1h
I18N_TRANSLATION_FILENAME=messages I18N_TRANSLATION_FILENAME=messages

View File

@ -12,7 +12,6 @@ services:
- app-network - app-network
volumes: volumes:
- api-data:/app/uploads - api-data:/app/uploads
- api-avatars-data:/app/avatars
depends_on: depends_on:
mongo: mongo:
condition: service_healthy condition: service_healthy
@ -56,7 +55,6 @@ services:
volumes: volumes:
mongo-data: mongo-data:
api-data: api-data:
api-avatars-data:
networks: networks:
db-network: db-network:

View File

@ -532,6 +532,7 @@
"invite": "Invite", "invite": "Invite",
"send": "Send", "send": "Send",
"fields": "Fields", "fields": "Fields",
"upload": "Upload",
"import": "Import", "import": "Import",
"export": "Export", "export": "Export",
"manage": "Manage", "manage": "Manage",

View File

@ -533,6 +533,7 @@
"invite": "Inviter", "invite": "Inviter",
"send": "Envoyer", "send": "Envoyer",
"fields": "Champs", "fields": "Champs",
"upload": "Téléverser",
"import": "Import", "import": "Import",
"export": "Export", "export": "Export",
"manage": "Gérer", "manage": "Gérer",

View File

@ -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<HTMLDivElement, AvatarInputProps>(
({ 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 (
<Box
ref={ref}
sx={{
position: "relative",
}}
>
<FormLabel
component="h2"
style={{ display: "inline-block", marginBottom: 1 }}
>
{label}
</FormLabel>
<Avatar
src={avatarSrc}
color={theme.palette.text.secondary}
sx={{ width: size, height: size, margin: "auto" }}
variant="rounded"
/>
<Box
sx={{
position: "absolute",
right: "50%",
bottom: "1rem",
transform: "translateX(50%)",
}}
>
<FileUploadButton
accept={accept}
label={t("button.upload")}
onChange={handleChange}
isLoading={false}
/>
</Box>
{helperText ? (
<FormHelperText error={error}>{helperText}</FormHelperText>
) : null}
</Box>
);
},
);
AvatarInput.displayName = "AttachmentInput";
export default AvatarInput;

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: * 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. * 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). * 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 { Grid } from "@mui/material";
import { GridRenderCellParams } from "@mui/x-data-grid"; import { GridRenderCellParams } from "@mui/x-data-grid";
@ -29,13 +30,7 @@ export const buildRenderPicture = (
}} }}
> >
<img <img
src={getAvatarSrc( src={getAvatarSrc(apiUrl, entityType, params.row.id)}
apiUrl,
entityType,
entityType === EntityType.USER
? params.row.id
: params.row.foreign_id,
)}
style={{ width: "36px", height: "36px" }} style={{ width: "36px", height: "36px" }}
/> />
</Grid> </Grid>

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: * 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. * 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). * 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 { import {
Avatar, Avatar,
ChatContainer, ChatContainer,
@ -70,11 +71,7 @@ export function Chat() {
<ConversationHeader> <ConversationHeader>
<Avatar <Avatar
name={subscriber?.first_name} name={subscriber?.first_name}
src={getAvatarSrc( src={getAvatarSrc(apiUrl, EntityType.SUBSCRIBER, subscriber.id)}
apiUrl,
EntityType.SUBSCRIBER,
subscriber.foreign_id,
)}
/> />
<ConversationHeader.Content> <ConversationHeader.Content>
<ChatHeader /> <ChatHeader />
@ -127,9 +124,8 @@ export function Chat() {
message.sender message.sender
? EntityType.SUBSCRIBER ? EntityType.SUBSCRIBER
: EntityType.USER, : EntityType.USER,
(message.sender (message.sender ? subscriber.id : message.sentBy) ||
? subscriber.foreign_id "",
: message.sentBy) || "",
)} )}
/>, />,
] ]

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: * 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. * 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). * 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 { import {
Avatar, Avatar,
Conversation, Conversation,
@ -50,30 +51,26 @@ export const SubscribersList = (props: {
loadingMore={isFetching} loadingMore={isFetching}
onYReachEnd={handleLoadMore} onYReachEnd={handleLoadMore}
> >
{subscribers.map((conversation) => ( {subscribers.map((subscriber) => (
<Conversation <Conversation
onClick={() => chat.setSubscriberId(conversation.id)} onClick={() => chat.setSubscriberId(subscriber.id)}
className="changeColor" className="changeColor"
key={conversation.id} key={subscriber.id}
active={chat.subscriber?.id === conversation.id} active={chat.subscriber?.id === subscriber.id}
> >
<Avatar <Avatar
src={getAvatarSrc( src={getAvatarSrc(apiUrl, EntityType.SUBSCRIBER, subscriber.id)}
apiUrl,
EntityType.SUBSCRIBER,
conversation.foreign_id,
)}
/> />
<Conversation.Content> <Conversation.Content>
<div> <div>
{conversation.first_name} {conversation.last_name} {subscriber.first_name} {subscriber.last_name}
</div> </div>
<div className="cs-conversation__info"> <div className="cs-conversation__info">
{conversation.lastvisit?.toLocaleString(i18n.language)} {subscriber.lastvisit?.toLocaleString(i18n.language)}
</div> </div>
</Conversation.Content> </Conversation.Content>
<Conversation.Operations visible> <Conversation.Operations visible>
<Chip size="small" label={conversation.channel.name} /> <Chip size="small" label={subscriber.channel.name} />
</Conversation.Operations> </Conversation.Operations>
</Conversation> </Conversation>
))} ))}

View File

@ -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: * 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. * 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). * 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 CheckIcon from "@mui/icons-material/Check";
import DeleteIcon from "@mui/icons-material/Delete";
import EmailIcon from "@mui/icons-material/Email"; import EmailIcon from "@mui/icons-material/Email";
import KeyIcon from "@mui/icons-material/Key"; import KeyIcon from "@mui/icons-material/Key";
import LanguageIcon from "@mui/icons-material/Language"; import LanguageIcon from "@mui/icons-material/Language";
@ -16,10 +16,10 @@ import { FC } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useQueryClient } from "react-query"; import { useQueryClient } from "react-query";
import AttachmentInput from "@/app-components/attachment/AttachmentInput";
import { ContentItem } from "@/app-components/dialogs"; import { ContentItem } from "@/app-components/dialogs";
import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer"; import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer";
import { Adornment } from "@/app-components/inputs/Adornment"; import { Adornment } from "@/app-components/inputs/Adornment";
import AvatarInput from "@/app-components/inputs/AvatarInput";
import { Input } from "@/app-components/inputs/Input"; import { Input } from "@/app-components/inputs/Input";
import { PasswordInput } from "@/app-components/inputs/PasswordInput"; import { PasswordInput } from "@/app-components/inputs/PasswordInput";
import { useUpdateProfile } from "@/hooks/entities/auth-hooks"; import { useUpdateProfile } from "@/hooks/entities/auth-hooks";
@ -27,11 +27,9 @@ import { CURRENT_USER_KEY } from "@/hooks/useAuth";
import { useToast } from "@/hooks/useToast"; import { useToast } from "@/hooks/useToast";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { useValidationRules } from "@/hooks/useValidationRules"; 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"; import { MIME_TYPES } from "@/utils/attachment";
type TUserProfileExtendedPayload = IUserAttributes & { password2: string };
type ProfileFormProps = { user: IUser }; type ProfileFormProps = { user: IUser };
export const ProfileForm: FC<ProfileFormProps> = ({ user }) => { export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
@ -55,14 +53,12 @@ export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
formState: { errors }, formState: { errors },
register, register,
setValue, setValue,
getValues, } = useForm<IProfileAttributes>({
} = useForm<TUserProfileExtendedPayload>({
defaultValues: { defaultValues: {
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
email: user.email, email: user.email,
language: user.language, language: user.language,
avatar: user.avatar,
}, },
}); });
const rules = useValidationRules(); const rules = useValidationRules();
@ -89,7 +85,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
password, password,
password2: _password2, password2: _password2,
...rest ...rest
}: TUserProfileExtendedPayload) => { }: IProfileAttributes) => {
await updateProfile({ await updateProfile({
...rest, ...rest,
password: password || undefined, password: password || undefined,
@ -106,32 +102,13 @@ export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
render={({ field }) => ( render={({ field }) => (
<> <>
<Box sx={{ position: "relative" }}> <Box sx={{ position: "relative" }}>
<AttachmentInput <AvatarInput
label={t("label.avatar")} label={t("label.avatar")}
format="small"
accept={MIME_TYPES["images"].join(",")} accept={MIME_TYPES["images"].join(",")}
enableMediaLibrary={false}
size={256} size={256}
{...field} {...field}
onChange={(attachment) => setValue("avatar", attachment)} onChange={(file) => setValue("avatar", file)}
/> />
{getValues("avatar") ? (
<Button
startIcon={<DeleteIcon />}
onClick={() => setValue("avatar", null)}
color="error"
variant="contained"
size="small"
sx={{
position: "absolute",
right: "50%",
bottom: "1rem",
transform: "translateX(50%)",
}}
>
{t("button.remove")}
</Button>
) : null}
</Box> </Box>
<Typography <Typography
variant="body2" variant="body2"

View File

@ -1,17 +1,23 @@
/* /*
* 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: * 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. * 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). * 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 { useEffect } from "react"; import { useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import { EntityType, TMutationOptions } from "@/services/types"; import { EntityType, TMutationOptions } from "@/services/types";
import { ILoginAttributes } from "@/types/auth/login.types"; import { ILoginAttributes } from "@/types/auth/login.types";
import { IUser, IUserAttributes, IUserStub } from "@/types/user.types"; import {
IProfileAttributes,
IUser,
IUserAttributes,
IUserStub,
} from "@/types/user.types";
import { useSocket } from "@/websocket/socket-hooks"; import { useSocket } from "@/websocket/socket-hooks";
import { useFind } from "../crud/useFind"; import { useFind } from "../crud/useFind";
@ -156,7 +162,7 @@ export const useLoadSettings = () => {
export const useUpdateProfile = ( export const useUpdateProfile = (
options?: Omit< options?: Omit<
TMutationOptions<IUserStub, Error, Partial<IUserAttributes>>, TMutationOptions<IUserStub, Error, Partial<IProfileAttributes>>,
"mutationFn" "mutationFn"
>, >,
) => { ) => {

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: * 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. * 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). * 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 { AxiosInstance, AxiosResponse } from "axios";
import { ILoginAttributes } from "@/types/auth/login.types"; 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 { IInvitation, IInvitationAttributes } from "@/types/invitation.types";
import { INlpDatasetSampleAttributes } from "@/types/nlp-sample.types"; import { INlpDatasetSampleAttributes } from "@/types/nlp-sample.types";
import { IResetPayload, IResetRequest } from "@/types/reset.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"; import { EntityType, Format, TCount, TypeByFormat } from "./types";
@ -100,15 +106,27 @@ export class ApiClient {
return data; return data;
} }
async updateProfile(id: string, payload: Partial<IUserAttributes>) { async updateProfile(id: string, payload: Partial<IProfileAttributes>) {
const { _csrf } = await this.getCsrf(); 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< const { data } = await this.request.patch<
IUserStub, IUserStub,
AxiosResponse<IUserStub>, AxiosResponse<IUserStub>,
Partial<IUserAttributes> & ICsrf Partial<IProfileAttributes>
>(`${ROUTES.PROFILE}/${id}`, { >(`${ROUTES.PROFILE}/${id}?_csrf=${_csrf}`, payload, {
...payload, headers: {
_csrf, "Content-Type": "multipart/form-data",
},
}); });
return data; return data;

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: * 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. * 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). * 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 { EntityType, Format } from "@/services/types";
import { IAttachment } from "./attachment.types"; import { IAttachment } from "./attachment.types";
@ -27,6 +28,11 @@ export interface IUserStub
extends IBaseSchema, extends IBaseSchema,
OmitPopulate<IUserAttributes, EntityType.USER> {} OmitPopulate<IUserAttributes, EntityType.USER> {}
export interface IProfileAttributes extends Partial<IUserStub> {
password2?: string;
avatar?: File | null;
}
export interface IUser extends IUserStub, IFormat<Format.BASIC> { export interface IUser extends IUserStub, IFormat<Format.BASIC> {
roles: string[]; //populated by default roles: string[]; //populated by default
avatar: string | null; avatar: string | null;