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