fix: avatar dir

This commit is contained in:
Mohamed Marrouchi 2025-01-05 08:52:36 +01:00
parent c35be05416
commit e16660a0a0
10 changed files with 79 additions and 35 deletions

2
api/.gitignore vendored
View File

@ -4,4 +4,4 @@ dist/
coverage/ coverage/
uploads/ uploads/
documentation/ documentation/
avatars avatars

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

@ -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 {
@ -88,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);
@ -129,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 {
@ -195,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);
@ -225,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 {
@ -233,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,
@ -255,7 +254,8 @@ export class AttachmentService extends BaseService<Attachment> {
if (this.getStoragePlugin()) { if (this.getStoragePlugin()) {
return await this.getStoragePlugin().download(attachment); return await this.getStoragePlugin().download(attachment);
} else { } else {
const path = join(rootDir, attachment.location); const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) { if (!fileExists(path)) {
throw new NotFoundException('No file was found'); throw new NotFoundException('No file was found');
} }
@ -289,10 +289,12 @@ export class AttachmentService extends BaseService<Attachment> {
if (this.getStoragePlugin()) { if (this.getStoragePlugin()) {
return await this.getStoragePlugin().readAsBuffer(attachment); return await this.getStoragePlugin().readAsBuffer(attachment);
} else { } else {
const path = join(rootDir, attachment.location); const path = resolve(join(rootDir, attachment.location));
if (!fileExists(path)) { if (!fileExists(path)) {
throw new NotFoundException('No file was found'); throw new NotFoundException('No file was found');
} }
return await fs.promises.readFile(path); // Reads the file content as a Buffer return await fs.promises.readFile(path); // Reads the file content as a Buffer
} }
} }

View File

@ -23,7 +23,6 @@ import { AttachmentService } from '@/attachment/services/attachment.service';
import { config } from '@/config'; 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';
@ -145,7 +144,6 @@ export class SubscriberController extends BaseController<
* @param id - The unique identifier of the subscriber whose profile picture is to be retrieved. * @param id - The unique identifier of the subscriber whose profile picture is to be retrieved.
* @returns A streamable file containing the avatar image. * @returns A streamable file containing the avatar image.
*/ */
@Roles('public')
@Get(':id/profile_pic') @Get(':id/profile_pic')
async getAvatar(@Param('id') id: string): Promise<StreamableFile> { async getAvatar(@Param('id') id: string): Promise<StreamableFile> {
const subscriber = await this.subscriberService.findOneAndPopulate(id); const subscriber = await this.subscriberService.findOneAndPopulate(id);

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

@ -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). * 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 mongoose from 'mongoose';
import attachmentSchema, { import attachmentSchema, {
Attachment, Attachment,
} from '@/attachment/schemas/attachment.schema'; } from '@/attachment/schemas/attachment.schema';
import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema';
import { config } from '@/config';
import { MigrationServices } from '../types'; 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 = { module.exports = {
async up(services: MigrationServices) { async up(services: MigrationServices) {
await populateSubscriberAvatar(services); await populateSubscriberAvatar(services);
await updateOldAvatarsPath(services);
return true; return true;
}, },
async down(services: MigrationServices) { async down(services: MigrationServices) {
await unpopulateSubscriberAvatar(services); await unpopulateSubscriberAvatar(services);
await restoreOldAvatarsPath(services);
return true; return true;
}, },
}; };

View File

@ -50,5 +50,6 @@ export abstract class BaseStoragePlugin extends BasePlugin {
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

@ -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. * @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 getAvatar(@Param('id') id: string) { async getAvatar(@Param('id') id: string) {
const user = await this.userService.findOneAndPopulate(id); const user = await this.userService.findOneAndPopulate(id);

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: