mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: refactor attachment storage to use helpers
This commit is contained in:
@@ -16,8 +16,13 @@ import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Request } from 'express';
|
||||
|
||||
import LocalStorageHelper from '@/extensions/helpers/local-storage/index.helper';
|
||||
import { HelperService } from '@/helper/helper.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { SettingRepository } from '@/setting/repositories/setting.repository';
|
||||
import { SettingModel } from '@/setting/schemas/setting.schema';
|
||||
import { SettingSeeder } from '@/setting/seeds/setting.seed';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
import { ModelRepository } from '@/user/repositories/model.repository';
|
||||
import { PermissionRepository } from '@/user/repositories/permission.repository';
|
||||
import { ModelModel } from '@/user/schemas/model.schema';
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
attachmentFixtures,
|
||||
installAttachmentFixtures,
|
||||
} from '@/utils/test/fixtures/attachment';
|
||||
import { installSettingFixtures } from '@/utils/test/fixtures/setting';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
@@ -51,16 +57,23 @@ describe('AttachmentController', () => {
|
||||
let attachmentController: AttachmentController;
|
||||
let attachmentService: AttachmentService;
|
||||
let attachmentToDelete: Attachment;
|
||||
let helperService: HelperService;
|
||||
let settingService: SettingService;
|
||||
let loggerService: LoggerService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AttachmentController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installAttachmentFixtures),
|
||||
rootMongooseTestModule(async () => {
|
||||
await installSettingFixtures();
|
||||
await installAttachmentFixtures();
|
||||
}),
|
||||
MongooseModule.forFeature([
|
||||
AttachmentModel,
|
||||
PermissionModel,
|
||||
ModelModel,
|
||||
SettingModel,
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
@@ -68,11 +81,20 @@ describe('AttachmentController', () => {
|
||||
AttachmentRepository,
|
||||
PermissionService,
|
||||
PermissionRepository,
|
||||
SettingRepository,
|
||||
ModelService,
|
||||
ModelRepository,
|
||||
LoggerService,
|
||||
EventEmitter2,
|
||||
PluginService,
|
||||
SettingSeeder,
|
||||
SettingService,
|
||||
HelperService,
|
||||
// {
|
||||
// provide: HelperService,
|
||||
// useValue: {
|
||||
// getDefaultHelper: jest.fn(),
|
||||
// },
|
||||
// },
|
||||
{
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: {
|
||||
@@ -89,6 +111,14 @@ describe('AttachmentController', () => {
|
||||
attachmentToDelete = (await attachmentService.findOne({
|
||||
name: 'store1.jpg',
|
||||
}))!;
|
||||
|
||||
helperService = module.get<HelperService>(HelperService);
|
||||
settingService = module.get<SettingService>(SettingService);
|
||||
loggerService = module.get<LoggerService>(LoggerService);
|
||||
|
||||
helperService.register(
|
||||
new LocalStorageHelper(settingService, helperService, loggerService),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
@@ -6,312 +6,87 @@
|
||||
* 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 os from 'os';
|
||||
import { join, normalize, resolve } from 'path';
|
||||
import { Readable, Stream } from 'stream';
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import fetch from 'node-fetch';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
import { Injectable, Optional, StreamableFile } from '@nestjs/common';
|
||||
|
||||
import { config } from '@/config';
|
||||
import { HelperService } from '@/helper/helper.service';
|
||||
import { HelperType } from '@/helper/types';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { PluginInstance } from '@/plugins/map-types';
|
||||
import { PluginService } from '@/plugins/plugins.service';
|
||||
import { PluginType } from '@/plugins/types';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
|
||||
import { AttachmentMetadataDto } from '../dto/attachment.dto';
|
||||
import { AttachmentRepository } from '../repositories/attachment.repository';
|
||||
import { Attachment } from '../schemas/attachment.schema';
|
||||
import { AttachmentResourceRef } from '../types';
|
||||
import {
|
||||
fileExists,
|
||||
generateUniqueFilename,
|
||||
getStreamableFile,
|
||||
} from '../utilities';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService extends BaseService<Attachment> {
|
||||
private storagePlugin: PluginInstance<PluginType.storage> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly repository: AttachmentRepository,
|
||||
private readonly logger: LoggerService,
|
||||
@Optional() private readonly pluginService: PluginService,
|
||||
@Optional() private readonly helperService: HelperService,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* A storage plugin is a alternative way to store files, instead of local filesystem, you can
|
||||
* have a plugin that would store files in a 3rd party system (Minio, AWS S3, ...)
|
||||
* Stores a file using the default storage helper and creates an attachment record.
|
||||
*
|
||||
* @param foreign_id The unique identifier of the user, used to locate the profile picture.
|
||||
* @returns A singleton instance of the storage plugin
|
||||
*/
|
||||
getStoragePlugin() {
|
||||
if (!this.storagePlugin) {
|
||||
const plugins = this.pluginService.getAllByType(PluginType.storage);
|
||||
|
||||
if (plugins.length === 1) {
|
||||
this.storagePlugin = plugins[0];
|
||||
} else if (plugins.length > 1) {
|
||||
throw new Error(
|
||||
'Multiple storage plugins are detected, please ensure only one is available',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.storagePlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a user's profile picture either from a 3rd party storage system or from a local directory based on configuration.
|
||||
* This method retrieves the default storage helper via the `HelperService` and
|
||||
* delegates the file storage operation to it. The returned metadata is then used
|
||||
* to create a new `Attachment` record in the database.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
async downloadProfilePic(
|
||||
foreign_id: string,
|
||||
): Promise<StreamableFile | undefined> {
|
||||
if (this.getStoragePlugin()) {
|
||||
try {
|
||||
const pict = foreign_id + '.jpeg';
|
||||
const picture =
|
||||
await this.getStoragePlugin()?.downloadProfilePic?.(pict);
|
||||
return picture;
|
||||
} catch (err) {
|
||||
this.logger.error('Error downloading profile picture', err);
|
||||
throw new NotFoundException('Profile picture not found');
|
||||
}
|
||||
} else {
|
||||
const path = resolve(
|
||||
join(config.parameters.avatarDir, `${foreign_id}.jpeg`),
|
||||
);
|
||||
if (fs.existsSync(path)) {
|
||||
const picturetream = fs.createReadStream(path);
|
||||
return new StreamableFile(picturetream);
|
||||
} else {
|
||||
throw new NotFoundException('Profile picture not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async uploadProfilePic(data: Buffer | fetch.Response, filename: string) {
|
||||
if (this.getStoragePlugin()) {
|
||||
// Upload profile picture
|
||||
const picture = {
|
||||
originalname: filename,
|
||||
buffer: Buffer.isBuffer(data) ? data : await data.buffer(),
|
||||
} as Express.Multer.File;
|
||||
try {
|
||||
await this.getStoragePlugin()?.uploadAvatar?.(picture);
|
||||
this.logger.log(
|
||||
`Profile picture uploaded successfully to ${
|
||||
this.getStoragePlugin()?.name
|
||||
}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error while uploading profile picture to ${
|
||||
this.getStoragePlugin()?.name
|
||||
}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Save profile picture locally
|
||||
const dirPath = resolve(join(config.parameters.avatarDir, filename));
|
||||
|
||||
try {
|
||||
if (Buffer.isBuffer(data)) {
|
||||
await fs.promises.writeFile(dirPath, data);
|
||||
} else {
|
||||
const dest = fs.createWriteStream(dirPath);
|
||||
data.body.pipe(dest);
|
||||
}
|
||||
this.logger.debug(
|
||||
'Messenger Channel Handler : Profile picture fetched successfully',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Messenger Channel Handler : Error while creating directory',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachment root directory given the resource reference
|
||||
*
|
||||
* @param ref The attachment resource reference
|
||||
* @returns The root directory path
|
||||
*/
|
||||
getRootDirByResourceRef(ref: AttachmentResourceRef) {
|
||||
return ref === AttachmentResourceRef.SubscriberAvatar ||
|
||||
ref === AttachmentResourceRef.UserAvatar
|
||||
? config.parameters.avatarDir
|
||||
: config.parameters.uploadDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files to the server. If a storage plugin is configured it uploads files accordingly.
|
||||
* Otherwise, uploads files to the local directory.
|
||||
*
|
||||
* @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.
|
||||
* @param file - The file to be stored. This can be a buffer, a stream, a readable, or a file from an Express Multer upload.
|
||||
* @param metadata - The metadata associated with the file, such as name, size, and type.
|
||||
* @returns A promise resolving to the created `Attachment` record.
|
||||
*/
|
||||
async store(
|
||||
file: Buffer | Stream | Readable | Express.Multer.File,
|
||||
metadata: AttachmentMetadataDto,
|
||||
): Promise<Attachment | null> {
|
||||
if (this.getStoragePlugin()) {
|
||||
const storedDto = await this.getStoragePlugin()?.store?.(file, metadata);
|
||||
return storedDto ? await this.create(storedDto) : null;
|
||||
} else {
|
||||
const rootDir = this.getRootDirByResourceRef(metadata.resourceRef);
|
||||
const uniqueFilename = generateUniqueFilename(metadata.name);
|
||||
const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename)));
|
||||
|
||||
if (Buffer.isBuffer(file)) {
|
||||
await fs.promises.writeFile(filePath, file);
|
||||
} else if (file instanceof Readable || file instanceof Stream) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
file.pipe(writeStream);
|
||||
// @TODO: Calc size here?
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
} else {
|
||||
if (file.path) {
|
||||
// For example, if the file is an instance of `Express.Multer.File` (diskStorage case)
|
||||
const srcFilePath = fs.realpathSync(resolve(file.path));
|
||||
// Get the system's temporary directory in a cross-platform way
|
||||
const tempDir = os.tmpdir();
|
||||
const normalizedTempDir = normalize(tempDir);
|
||||
|
||||
if (!srcFilePath.startsWith(normalizedTempDir)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
await fs.promises.copyFile(srcFilePath, filePath);
|
||||
await fs.promises.unlink(srcFilePath);
|
||||
} else {
|
||||
await fs.promises.writeFile(filePath, file.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
const location = filePath.replace(rootDir, '');
|
||||
return await this.create({
|
||||
...metadata,
|
||||
location,
|
||||
});
|
||||
}
|
||||
): Promise<Attachment> {
|
||||
const storageHelper = await this.helperService.getDefaultHelper(
|
||||
HelperType.STORAGE,
|
||||
);
|
||||
const dto = await storageHelper.store(file, metadata);
|
||||
return await this.create(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an attachment identified by the provided parameters.
|
||||
* Downloads the specified attachment using the default storage helper.
|
||||
*
|
||||
* @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.
|
||||
* @param The attachment object containing the metadata required for the download.
|
||||
* @returns A promise resolving to a `StreamableFile` instance of the downloaded attachment.
|
||||
*/
|
||||
async download(attachment: Attachment): Promise<StreamableFile> {
|
||||
if (this.getStoragePlugin()) {
|
||||
const streamableFile =
|
||||
await this.getStoragePlugin()?.download(attachment);
|
||||
if (!streamableFile) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
return streamableFile;
|
||||
} else {
|
||||
const rootDir = this.getRootDirByResourceRef(attachment.resourceRef);
|
||||
const path = resolve(join(rootDir, attachment.location));
|
||||
|
||||
if (!fileExists(path)) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
const disposition = `attachment; filename="${encodeURIComponent(
|
||||
attachment.name,
|
||||
)}"`;
|
||||
|
||||
return getStreamableFile({
|
||||
path,
|
||||
options: {
|
||||
type: attachment.type,
|
||||
length: attachment.size,
|
||||
disposition,
|
||||
},
|
||||
});
|
||||
}
|
||||
const storageHelper = await this.helperService.getDefaultHelper(
|
||||
HelperType.STORAGE,
|
||||
);
|
||||
return storageHelper.download(attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an attachment identified by the provided parameters as a Buffer.
|
||||
* Reads the specified attachment as a buffer using the default storage helper.
|
||||
*
|
||||
* @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 attachment file.
|
||||
* @param attachment - The attachment object containing the metadata required to locate the file.
|
||||
* @returns A promise resolving to the file content as a `Buffer`, or `undefined` if the file cannot be read.
|
||||
*/
|
||||
async readAsBuffer(
|
||||
attachment: Attachment,
|
||||
rootDir = config.parameters.uploadDir,
|
||||
): Promise<Buffer | undefined> {
|
||||
if (this.getStoragePlugin()) {
|
||||
return await this.getStoragePlugin()?.readAsBuffer?.(attachment);
|
||||
} else {
|
||||
const path = resolve(join(rootDir, attachment.location));
|
||||
|
||||
if (!fileExists(path)) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
return await fs.promises.readFile(path); // Reads the file content as a Buffer
|
||||
}
|
||||
async readAsBuffer(attachment: Attachment): Promise<Buffer | undefined> {
|
||||
const storageHelper = await this.helperService.getDefaultHelper(
|
||||
HelperType.STORAGE,
|
||||
);
|
||||
return storageHelper.readAsBuffer(attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an attachment identified by the provided parameters as a Stream.
|
||||
* Reads the specified attachment as a stream using the default storage helper.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @param rootDir - Root folder path where the attachment should be located.
|
||||
* @returns A promise that resolves to a Stream representing the attachment file.
|
||||
* @param attachment - The attachment object containing the metadata required to locate the file.
|
||||
* @returns A promise resolving to the file content as a `Stream`, or `undefined` if the file cannot be read.
|
||||
*/
|
||||
async readAsStream(
|
||||
attachment: Attachment,
|
||||
rootDir = config.parameters.uploadDir,
|
||||
): Promise<Stream | undefined> {
|
||||
if (this.getStoragePlugin()) {
|
||||
return await this.getStoragePlugin()?.readAsStream?.(attachment);
|
||||
} else {
|
||||
const path = resolve(join(rootDir, attachment.location));
|
||||
|
||||
if (!fileExists(path)) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
return fs.createReadStream(path); // Reads the file content as a Buffer
|
||||
}
|
||||
async readAsStream(attachment: Attachment): Promise<Stream | undefined> {
|
||||
const storageHelper = await this.helperService.getDefaultHelper(
|
||||
HelperType.STORAGE,
|
||||
);
|
||||
return await storageHelper.readAsStream(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
191
api/src/extensions/helpers/local-storage/index.helper.ts
Normal file
191
api/src/extensions/helpers/local-storage/index.helper.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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 os from 'os';
|
||||
import { join, normalize, resolve } from 'path';
|
||||
import { Readable, Stream } from 'stream';
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
|
||||
import {
|
||||
AttachmentCreateDto,
|
||||
AttachmentMetadataDto,
|
||||
} from '@/attachment/dto/attachment.dto';
|
||||
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentResourceRef } from '@/attachment/types';
|
||||
import {
|
||||
fileExists,
|
||||
generateUniqueFilename,
|
||||
getStreamableFile,
|
||||
} from '@/attachment/utilities';
|
||||
import { config } from '@/config';
|
||||
import { HelperService } from '@/helper/helper.service';
|
||||
import BaseStorageHelper from '@/helper/lib/base-storage-helper';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
|
||||
import { LOCAL_STORAGE_HELPER_NAME } from './settings';
|
||||
|
||||
@Injectable()
|
||||
export default class LocalStorageHelper
|
||||
extends BaseStorageHelper<typeof LOCAL_STORAGE_HELPER_NAME>
|
||||
implements OnModuleInit
|
||||
{
|
||||
constructor(
|
||||
settingService: SettingService,
|
||||
helperService: HelperService,
|
||||
logger: LoggerService,
|
||||
) {
|
||||
super(LOCAL_STORAGE_HELPER_NAME, settingService, helperService, logger);
|
||||
}
|
||||
|
||||
getPath() {
|
||||
return __dirname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachment root directory given the resource reference
|
||||
*
|
||||
* @param ref The attachment resource reference
|
||||
* @returns The root directory path
|
||||
*/
|
||||
private getRootDirByResourceRef(ref: AttachmentResourceRef) {
|
||||
return ref === AttachmentResourceRef.SubscriberAvatar ||
|
||||
ref === AttachmentResourceRef.UserAvatar
|
||||
? config.parameters.avatarDir
|
||||
: config.parameters.uploadDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a attachment file to the local directory.
|
||||
*
|
||||
* @param file - The file
|
||||
* @param metadata - The attachment metadata informations.
|
||||
* @returns A promise that resolves to the uploaded attachment.
|
||||
*/
|
||||
async store(
|
||||
file: Buffer | Stream | Readable | Express.Multer.File,
|
||||
metadata: AttachmentMetadataDto,
|
||||
): Promise<AttachmentCreateDto> {
|
||||
const rootDir = this.getRootDirByResourceRef(metadata.resourceRef);
|
||||
const uniqueFilename = generateUniqueFilename(metadata.name);
|
||||
const filePath = resolve(join(rootDir, sanitizeFilename(uniqueFilename)));
|
||||
|
||||
if (Buffer.isBuffer(file)) {
|
||||
await fs.promises.writeFile(filePath, file);
|
||||
} else if (file instanceof Readable || file instanceof Stream) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
file.pipe(writeStream);
|
||||
// @TODO: Calc size here?
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
} else {
|
||||
if (file.path) {
|
||||
// For example, if the file is an instance of `Express.Multer.File` (diskStorage case)
|
||||
const srcFilePath = fs.realpathSync(resolve(file.path));
|
||||
// Get the system's temporary directory in a cross-platform way
|
||||
const tempDir = os.tmpdir();
|
||||
const normalizedTempDir = normalize(tempDir);
|
||||
|
||||
if (!srcFilePath.startsWith(normalizedTempDir)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
await fs.promises.copyFile(srcFilePath, filePath);
|
||||
await fs.promises.unlink(srcFilePath);
|
||||
} else {
|
||||
await fs.promises.writeFile(filePath, file.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
const location = filePath.replace(rootDir, '');
|
||||
return {
|
||||
...metadata,
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an attachment identified by the provided parameters.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
|
||||
*/
|
||||
async download(attachment: Attachment): Promise<StreamableFile> {
|
||||
const rootDir = this.getRootDirByResourceRef(attachment.resourceRef);
|
||||
const path = resolve(join(rootDir, attachment.location));
|
||||
|
||||
if (!fileExists(path)) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
const disposition = `attachment; filename="${encodeURIComponent(
|
||||
attachment.name,
|
||||
)}"`;
|
||||
|
||||
return getStreamableFile({
|
||||
path,
|
||||
options: {
|
||||
type: attachment.type,
|
||||
length: attachment.size,
|
||||
disposition,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an attachment identified by the provided parameters as a Buffer.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @returns A promise that resolves to a Buffer representing the attachment file.
|
||||
*/
|
||||
async readAsBuffer(attachment: Attachment): Promise<Buffer | undefined> {
|
||||
const path = resolve(
|
||||
join(
|
||||
this.getRootDirByResourceRef(attachment.resourceRef),
|
||||
attachment.location,
|
||||
),
|
||||
);
|
||||
|
||||
if (!fileExists(path)) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
return await fs.promises.readFile(path); // Reads the file content as a Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an attachment identified by the provided parameters as a Stream.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @returns A promise that resolves to a Stream representing the attachment file.
|
||||
*/
|
||||
async readAsStream(attachment: Attachment): Promise<Stream | undefined> {
|
||||
const path = resolve(
|
||||
join(
|
||||
this.getRootDirByResourceRef(attachment.resourceRef),
|
||||
attachment.location,
|
||||
),
|
||||
);
|
||||
|
||||
if (!fileExists(path)) {
|
||||
throw new NotFoundException('No file was found');
|
||||
}
|
||||
|
||||
return fs.createReadStream(path); // Reads the file content as a Buffer
|
||||
}
|
||||
}
|
||||
8
api/src/extensions/helpers/local-storage/package.json
Normal file
8
api/src/extensions/helpers/local-storage/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "hexabot-helper-local-storage",
|
||||
"version": "2.2.0",
|
||||
"description": "The default Hexabot Helper Extension for Hexabot to enable local storage for attachment files",
|
||||
"dependencies": {},
|
||||
"author": "Hexastack",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
17
api/src/extensions/helpers/local-storage/settings.ts
Normal file
17
api/src/extensions/helpers/local-storage/settings.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { HelperSetting } from '@/helper/types';
|
||||
|
||||
export const LOCAL_STORAGE_HELPER_NAME = 'local-storage-helper';
|
||||
|
||||
export const LOCAL_STORAGE_HELPER_NAMESPACE = 'local-storage-helper';
|
||||
|
||||
export default [] as const satisfies HelperSetting<
|
||||
typeof LOCAL_STORAGE_HELPER_NAME
|
||||
>[];
|
||||
@@ -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.
|
||||
@@ -108,6 +108,7 @@ export class HelperService {
|
||||
/**
|
||||
* Get default NLU helper.
|
||||
*
|
||||
* @deprecated Use getDefaultHelper() instead
|
||||
* @returns - The helper
|
||||
*/
|
||||
async getDefaultNluHelper() {
|
||||
@@ -128,6 +129,7 @@ export class HelperService {
|
||||
/**
|
||||
* Get default LLM helper.
|
||||
*
|
||||
* @deprecated Use getDefaultHelper() instead
|
||||
* @returns - The helper
|
||||
*/
|
||||
async getDefaultLlmHelper() {
|
||||
@@ -144,4 +146,31 @@ export class HelperService {
|
||||
|
||||
return defaultHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default helper for a specific type.
|
||||
*
|
||||
* @param type - The type of the helper (e.g., NLU, LLM, STORAGE).
|
||||
* @returns - The helper
|
||||
*/
|
||||
async getDefaultHelper<T extends HelperType>(type: T) {
|
||||
if (type === HelperType.UTIL) {
|
||||
throw new Error(
|
||||
`Default helpers are not available for type: ${HelperType.UTIL}`,
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await this.settingService.getSettings();
|
||||
const defaultHelperName = settings.chatbot_settings[
|
||||
`default_${type}_helper` as any
|
||||
] as HelperName;
|
||||
|
||||
const defaultHelper = this.get<T>(type, defaultHelperName);
|
||||
|
||||
if (!defaultHelper) {
|
||||
throw new Error(`Unable to find default ${type.toUpperCase()} helper`);
|
||||
}
|
||||
|
||||
return defaultHelper;
|
||||
}
|
||||
}
|
||||
|
||||
76
api/src/helper/lib/base-storage-helper.ts
Normal file
76
api/src/helper/lib/base-storage-helper.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 { Readable, Stream } from 'stream';
|
||||
|
||||
import { StreamableFile } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
AttachmentCreateDto,
|
||||
AttachmentMetadataDto,
|
||||
} from '@/attachment/dto/attachment.dto';
|
||||
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { SettingService } from '@/setting/services/setting.service';
|
||||
|
||||
import { HelperService } from '../helper.service';
|
||||
import { HelperName, HelperType } from '../types';
|
||||
|
||||
import BaseHelper from './base-helper';
|
||||
|
||||
export default abstract class BaseStorageHelper<
|
||||
N extends HelperName = HelperName,
|
||||
> extends BaseHelper<N> {
|
||||
protected readonly type: HelperType = HelperType.STORAGE;
|
||||
|
||||
constructor(
|
||||
name: N,
|
||||
settingService: SettingService,
|
||||
helperService: HelperService,
|
||||
logger: LoggerService,
|
||||
) {
|
||||
super(name, settingService, helperService, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files to the server. If a storage helper is configured it uploads files accordingly.
|
||||
* Otherwise, uploads files to the local directory.
|
||||
*
|
||||
* @param file - The file
|
||||
* @param metadata - The attachment metadata informations.
|
||||
* @returns A promise that resolves to an array of uploaded attachments.
|
||||
*/
|
||||
abstract store(
|
||||
_file: Buffer | Stream | Readable | Express.Multer.File,
|
||||
_metadata: AttachmentMetadataDto,
|
||||
): Promise<AttachmentCreateDto>;
|
||||
|
||||
/**
|
||||
* Downloads an attachment identified by the provided parameters.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @returns A promise that resolves to a StreamableFile representing the downloaded attachment.
|
||||
*/
|
||||
abstract download(attachment: Attachment): Promise<StreamableFile>;
|
||||
|
||||
/**
|
||||
* Downloads an attachment identified by the provided parameters as a Buffer.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @returns A promise that resolves to a Buffer representing the attachment file.
|
||||
*/
|
||||
abstract readAsBuffer(attachment: Attachment): Promise<Buffer | undefined>;
|
||||
|
||||
/**
|
||||
* Returns an attachment identified by the provided parameters as a Stream.
|
||||
*
|
||||
* @param attachment - The attachment to download.
|
||||
* @returns A promise that resolves to a Stream representing the attachment file.
|
||||
*/
|
||||
abstract readAsStream(attachment: Attachment): Promise<Stream | undefined>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -12,6 +12,7 @@ import { HyphenToUnderscore } from '@/utils/types/extension';
|
||||
import BaseHelper from './lib/base-helper';
|
||||
import BaseLlmHelper from './lib/base-llm-helper';
|
||||
import BaseNlpHelper from './lib/base-nlp-helper';
|
||||
import BaseStorageHelper from './lib/base-storage-helper';
|
||||
|
||||
export namespace NLU {
|
||||
export interface ParseEntity {
|
||||
@@ -84,16 +85,20 @@ export namespace LLM {
|
||||
export enum HelperType {
|
||||
NLU = 'nlu',
|
||||
LLM = 'llm',
|
||||
STORAGE = 'storage',
|
||||
UTIL = 'util',
|
||||
}
|
||||
|
||||
export type HelperName = `${string}-helper`;
|
||||
|
||||
export type TypeOfHelper<T extends HelperType> = T extends HelperType.LLM
|
||||
? BaseLlmHelper<HelperName>
|
||||
: T extends HelperType.NLU
|
||||
? BaseNlpHelper<HelperName>
|
||||
: BaseHelper;
|
||||
interface HelperTypeMap {
|
||||
[HelperType.NLU]: BaseNlpHelper<HelperName>;
|
||||
[HelperType.LLM]: BaseLlmHelper<HelperName>;
|
||||
[HelperType.STORAGE]: BaseStorageHelper<HelperName>;
|
||||
[HelperType.UTIL]: BaseHelper;
|
||||
}
|
||||
|
||||
export type TypeOfHelper<T extends HelperType> = HelperTypeMap[T];
|
||||
|
||||
export type HelperRegistry<H extends BaseHelper = BaseHelper> = Map<
|
||||
HelperType,
|
||||
|
||||
@@ -772,6 +772,42 @@ const migrateAndPopulateAttachmentMessages = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const addDefaultStorageHelper = async ({ logger }: MigrationServices) => {
|
||||
const SettingModel = mongoose.model<Setting>(Setting.name, settingSchema);
|
||||
try {
|
||||
await SettingModel.create({
|
||||
group: 'chatbot_settings',
|
||||
label: 'default_storage_helper',
|
||||
value: 'local-storage-helper',
|
||||
type: SettingType.select,
|
||||
config: {
|
||||
multiple: false,
|
||||
allowCreate: false,
|
||||
entity: 'Helper',
|
||||
idKey: 'name',
|
||||
labelKey: 'name',
|
||||
},
|
||||
weight: 2,
|
||||
});
|
||||
logger.log('Successfuly added the default local storage helper setting');
|
||||
} catch (err) {
|
||||
logger.error('Unable to add the default local storage helper setting');
|
||||
}
|
||||
};
|
||||
|
||||
const removeDefaultStorageHelper = async ({ logger }: MigrationServices) => {
|
||||
const SettingModel = mongoose.model<Setting>(Setting.name, settingSchema);
|
||||
try {
|
||||
await SettingModel.deleteOne({
|
||||
group: 'chatbot_settings',
|
||||
label: 'default_storage_helper',
|
||||
});
|
||||
logger.log('Successfuly removed the default local storage helper setting');
|
||||
} catch (err) {
|
||||
logger.error('Unable to remove the default local storage helper setting');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
async up(services: MigrationServices) {
|
||||
await updateOldAvatarsPath(services);
|
||||
@@ -784,6 +820,7 @@ module.exports = {
|
||||
await populateSettingAttachments(services);
|
||||
await populateUserAvatars(services);
|
||||
await populateSubscriberAvatars(services);
|
||||
await addDefaultStorageHelper(services);
|
||||
return true;
|
||||
},
|
||||
async down(services: MigrationServices) {
|
||||
@@ -792,6 +829,7 @@ module.exports = {
|
||||
await restoreOldAvatarsPath(services);
|
||||
await migrateAttachmentBlocks(MigrationAction.DOWN, services);
|
||||
await migrateAttachmentContents(MigrationAction.DOWN, services);
|
||||
await removeDefaultStorageHelper(services);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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 { Readable, Stream } from 'stream';
|
||||
|
||||
import { Injectable, StreamableFile } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
AttachmentCreateDto,
|
||||
AttachmentMetadataDto,
|
||||
} from '@/attachment/dto/attachment.dto';
|
||||
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
|
||||
import { BasePlugin } from './base-plugin.service';
|
||||
import { PluginService } from './plugins.service';
|
||||
import { PluginName, PluginType } from './types';
|
||||
|
||||
@Injectable()
|
||||
export abstract class BaseStoragePlugin extends BasePlugin {
|
||||
public readonly type: PluginType = PluginType.storage;
|
||||
|
||||
constructor(name: PluginName, pluginService: PluginService<BasePlugin>) {
|
||||
super(name, pluginService);
|
||||
}
|
||||
|
||||
/** @deprecated use download() instead */
|
||||
fileExists?(attachment: Attachment): Promise<boolean>;
|
||||
|
||||
/** @deprecated use store() instead */
|
||||
upload?(file: Express.Multer.File): Promise<AttachmentCreateDto>;
|
||||
|
||||
/** @deprecated use store() instead */
|
||||
uploadAvatar?(file: Express.Multer.File): Promise<any>;
|
||||
|
||||
abstract download(attachment: Attachment): Promise<StreamableFile>;
|
||||
|
||||
/** @deprecated use download() instead */
|
||||
downloadProfilePic?(name: string): Promise<StreamableFile>;
|
||||
|
||||
readAsBuffer?(attachment: Attachment): Promise<Buffer>;
|
||||
|
||||
readAsStream?(attachment: Attachment): Promise<Stream>;
|
||||
|
||||
store?(
|
||||
file: Buffer | Stream | Readable | Express.Multer.File,
|
||||
metadata: AttachmentMetadataDto,
|
||||
): Promise<Attachment>;
|
||||
}
|
||||
@@ -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,13 +9,11 @@
|
||||
import { BaseBlockPlugin } from './base-block-plugin';
|
||||
import { BaseEventPlugin } from './base-event-plugin';
|
||||
import { BasePlugin } from './base-plugin.service';
|
||||
import { BaseStoragePlugin } from './base-storage-plugin';
|
||||
import { PluginType } from './types';
|
||||
|
||||
const PLUGIN_TYPE_MAP = {
|
||||
[PluginType.event]: BaseEventPlugin,
|
||||
[PluginType.block]: BaseBlockPlugin,
|
||||
[PluginType.storage]: BaseStoragePlugin,
|
||||
};
|
||||
|
||||
export type PluginTypeMap = typeof PLUGIN_TYPE_MAP;
|
||||
|
||||
@@ -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.
|
||||
@@ -17,7 +17,6 @@ export type PluginName = `${string}-plugin`;
|
||||
export enum PluginType {
|
||||
event = 'event',
|
||||
block = 'block',
|
||||
storage = 'storage',
|
||||
}
|
||||
|
||||
export interface CustomBlocks {}
|
||||
|
||||
@@ -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.
|
||||
@@ -38,12 +38,26 @@ export const DEFAULT_SETTINGS = [
|
||||
},
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
group: 'chatbot_settings',
|
||||
label: 'default_storage_helper',
|
||||
value: 'local-storage-helper',
|
||||
type: SettingType.select,
|
||||
config: {
|
||||
multiple: false,
|
||||
allowCreate: false,
|
||||
entity: 'Helper',
|
||||
idKey: 'name',
|
||||
labelKey: 'name',
|
||||
},
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
group: 'chatbot_settings',
|
||||
label: 'global_fallback',
|
||||
value: true,
|
||||
type: SettingType.checkbox,
|
||||
weight: 3,
|
||||
weight: 4,
|
||||
},
|
||||
{
|
||||
group: 'chatbot_settings',
|
||||
@@ -58,7 +72,7 @@ export const DEFAULT_SETTINGS = [
|
||||
idKey: 'id',
|
||||
labelKey: 'name',
|
||||
},
|
||||
weight: 4,
|
||||
weight: 5,
|
||||
},
|
||||
{
|
||||
group: 'chatbot_settings',
|
||||
@@ -68,7 +82,7 @@ export const DEFAULT_SETTINGS = [
|
||||
"I'm really sorry but i don't quite understand what you are saying :(",
|
||||
] as string[],
|
||||
type: SettingType.multiple_text,
|
||||
weight: 5,
|
||||
weight: 6,
|
||||
translatable: true,
|
||||
},
|
||||
{
|
||||
|
||||
9
api/src/utils/test/fixtures/setting.ts
vendored
9
api/src/utils/test/fixtures/setting.ts
vendored
@@ -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.
|
||||
@@ -13,6 +13,13 @@ import { SettingModel } from '@/setting/schemas/setting.schema';
|
||||
import { SettingType } from '@/setting/schemas/types';
|
||||
|
||||
export const settingFixtures: SettingCreateDto[] = [
|
||||
{
|
||||
group: 'chatbot_settings',
|
||||
label: 'default_storage_helper',
|
||||
value: 'local-storage-helper',
|
||||
type: SettingType.text,
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
group: 'contact',
|
||||
label: 'contact_email_recipient',
|
||||
|
||||
Reference in New Issue
Block a user