import { inject, injectable } from 'inversify'; import { Document } from 'mongoose'; import { extension } from 'mime-types'; import probe from 'probe-image-size'; import { CdmLogger } from '@cdm-logger/core'; import { BaseService2, GetAllArgs } from '@common-stack/store-mongo'; import { IFileInfoService, IFileInfoModel, IFileInfoServiceInput, IFileInfoUploadedInput, SERVER_TYPES, IFileImageDimensions, IGenerateKeyOptions, AsDomainType, IFileImageFilter, ImageFilterName, FileRefType, } from 'common/server'; import { IAwsS3Service } from '@container-stack/file-info-core'; import { config } from '../config'; import { AWSS3Service } from './aws-s3-service'; import { FileInfoRepository } from '../store'; @injectable() export class FileInfoService extends BaseService2 implements IFileInfoService { private logger: CdmLogger.ILogger; constructor( @inject(SERVER_TYPES.FileInfoRepository) protected fileInfoRepository: FileInfoRepository, @inject(SERVER_TYPES.AwsS3Service) protected awsS3Service: IAwsS3Service, @inject('Logger') logger: CdmLogger.ILogger, ) { super(fileInfoRepository); this.logger = logger.child({ className: 'FileInfoService' }); } private static getFileSize(file: { createReadStream: () => any }) { const { createReadStream } = file; const readStream = createReadStream(); return new Promise((resolve) => { let length = 0; readStream.on('data', (chunk) => { length += chunk.length; }); readStream.on('end', () => { resolve(length); }); }); } private addComputedProperties(file: AsDomainType): AsDomainType { return { ...file, extension: extension(file.mimeType), url: `${this.awsS3Service.basePath}/${file.url}`, }; } private isFileInfoServiceInput(data: any): data is IFileInfoServiceInput { return ( data && typeof data === 'object' && 'file' in data && 'ref' in data && 'refType' in data && 'createdBy' in data ); } async get(id: string): Promise> { try { this.logger.trace('get with params (%j)', { id }); const file = await super.get(id); return this.addComputedProperties(file); } catch (error) { this.logger.error('Error getting file by ID: %o', error?.message); throw error; } } async getAll(options: GetAllArgs>): Promise[]> { try { this.logger.trace('getAll with params (%j)', options); const files = await super.getAll(options); return files.map((file) => this.addComputedProperties(file)); } catch (error) { this.logger.error('Error getting all files: %o', error?.message); throw error; } } async getAllWithCount( options: GetAllArgs>, ): Promise<{ data: AsDomainType[]; totalCount: number }> { try { this.logger.trace('getAllWithCount with params (%j)', options); const result = await super.getAllWithCount(options); return { ...result, data: result.data.map((file) => this.addComputedProperties(file)), }; } catch (error) { this.logger.error('Error getting files with count: %o', error?.message); throw error; } } async create(data: T): Promise> { try { this.logger.trace('create with params (%j)', { ...data, file: '[File]' }); // Type guard to ensure we have the correct data type if (!this.isFileInfoServiceInput(data)) { throw new Error('Invalid data type for file creation'); } const { file, ref, refType, createdBy, ...rest } = data as IFileInfoServiceInput; const fileResolved = await file; if (!fileResolved.filename || !fileResolved.mimetype) { throw new Error('File must have filename and mimetype'); } const uploadedImage = await this.awsS3Service.uploadFile(fileResolved, { userId: createdBy, ref, refType: refType as FileRefType, }); const { filename, mimetype, createReadStream } = fileResolved; const readStream = createReadStream(); let dimensions = {}; if (mimetype.includes('image')) { const result = await probe(readStream); dimensions = { width: result.width, height: result.height, }; } const size = await FileInfoService.getFileSize({ createReadStream }); const session = await this.fileInfoRepository.model.db.startSession(); session.startTransaction(); try { const fileCreated = await this.fileInfoRepository.create({ name: filename, mimeType: mimetype, size, ref, refType, createdBy, url: uploadedImage.url, ...dimensions, ...rest, }); await session.commitTransaction(); return this.addComputedProperties(fileCreated); } catch (e) { await session.abortTransaction(); await this.deleteByUrl(uploadedImage.url); throw e; } finally { session.endSession(); } } catch (error) { this.logger.error('Error creating file: %o', error?.message); throw error; } } async createUploadedFile(data: IFileInfoUploadedInput): Promise> { try { this.logger.trace('createUploadedFile with params (%j)', data); if (!data.url || !data.createdBy) { throw new Error('URL and createdBy are required'); } const { url } = data; const result = await this.fileInfoRepository.create({ ...data, url: new URL(url).pathname.replace(/^\/+/, ''), }); return result; } catch (error) { this.logger.error('Error creating uploaded file: %o', error?.message); throw error; } } async uploadByUrl(options: IGenerateKeyOptions): Promise { try { this.logger.trace('uploadByUrl with params (%j)', options); if (!options.userId || !options.filename) { throw new Error('userId and filename are required'); } const result = await this.awsS3Service.createSignedUrl(options); return result; } catch (error) { this.logger.error('Error creating upload URL: %o', error?.message); throw error; } } async deleteByUrl(url: string): Promise { try { this.logger.trace('deleteByUrl with params (%j)', { url }); if (!url) { throw new Error('URL is required'); } await this.awsS3Service.deleteFile(url); await this.fileInfoRepository.delete({ url }); return true; } catch (error) { this.logger.error('Error deleting file by URL: %o', error?.message); throw error; } } async delete(id: string): Promise { try { this.logger.trace('delete with params (%j)', { id }); if (!id) { throw new Error('ID is required'); } const file = await this.fileInfoRepository.get({ id }); if (!file) { throw new Error('File not found'); } const session = await this.fileInfoRepository.model.db.startSession(); session.startTransaction(); try { await this.awsS3Service.deleteFile(file.url); await this.fileInfoRepository.delete({ id }); await session.commitTransaction(); } catch (e) { await session.abortTransaction(); throw e; } finally { session.endSession(); } return true; } catch (error) { this.logger.error('Error deleting file: %o', error?.message); throw error; } } async getByUrl(url: string): Promise> { try { this.logger.trace('getByUrl with params (%j)', { url }); if (!url) { throw new Error('URL is required'); } const includeBasePath = url.includes(this.awsS3Service.basePath); const baseUrl = `${this.awsS3Service.basePath}/`; const finalUrl = includeBasePath ? url.replace(baseUrl, '') : AWSS3Service.getKeyFromEncodedUrl(url); const result = await this.fileInfoRepository.get({ url: finalUrl, }); return result; } catch (error) { this.logger.error('Error getting file by URL: %o', error?.message); throw error; } } getResizedImage(url: string, { width, height }: IFileImageDimensions): string { try { this.logger.trace('getResizedImage with params (%j)', { url, width, height }); if (!url || !width || !height) { throw new Error('URL, width, and height are required'); } const result = this.awsS3Service.generateImageHandlerURL(url, { [ImageFilterName.Resize]: `${width}x${height}`, }); return result; } catch (error) { this.logger.error('Error generating resized image URL: %o', error?.message); throw error; } } getWebpImage(url: string, filters: IFileImageFilter): string { try { this.logger.trace('getWebpImage with params (%j)', { url, filters }); if (!url) { throw new Error('URL is required'); } const result = this.awsS3Service.generateImageHandlerURL(url, filters); return result; } catch (error) { this.logger.error('Error generating WebP image URL: %o', error?.message); throw error; } } getThumbnailImage(url: string): string { try { this.logger.trace('getThumbnailImage with params (%j)', { url }); if (!url) { throw new Error('URL is required'); } const result = this.getResizedImage(url, { width: config.THUMBNAIL_IMAGE_WIDTH, height: config.THUMBNAIL_IMAGE_HEIGTH, }); return result; } catch (error) { this.logger.error('Error generating thumbnail image URL: %o', error?.message); throw error; } } getDefaultImage(url: string): string { try { this.logger.trace('getDefaultImage with params (%j)', { url }); if (!url) { throw new Error('URL is required'); } const result = this.getResizedImage(url, { width: config.IMAGE_HEIGHT, height: config.IMAGE_WIDTH, }); return result; } catch (error) { this.logger.error('Error generating default image URL: %o', error?.message); throw error; } } getPreviewImage(url: string): string { try { this.logger.trace('getPreviewImage with params (%j)', { url }); if (!url) { throw new Error('URL is required'); } const result = this.getResizedImage(url, { width: config.PREVIEW_IMAGE_WIDTH, height: config.PREVIEW_IMAGE_HEIGHT, }); return result; } catch (error) { this.logger.error('Error generating preview image URL: %o', error?.message); throw error; } } async createDownloadSignedUrl(s3KeyOrUrl: string, expiresIn: number = 300, bucketName?: string): Promise { try { this.logger.trace('createDownloadSignedUrl with params (%j)', { s3KeyOrUrl, expiresIn, bucketName }); if (!s3KeyOrUrl) { throw new Error('S3 key or URL is required'); } const result = await this.awsS3Service.createDownloadSignedUrl(s3KeyOrUrl, expiresIn, bucketName); return result; } catch (error) { this.logger.error('Error creating download signed URL: %o', error?.message); throw error; } } }