import { BlobServiceClient } from '@azure/storage-blob'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import * as crypto from 'crypto'; import * as fs from 'fs'; import { CommonService } from './common/common.service'; import * as util from 'util'; import { BaseMedias } from './base/base.medias'; import { MediasModel } from './entities/medias.entity'; import { IBaseMediasAttr } from './interfaces/base.medias-attr.interface'; import { Readable } from 'stream'; import { InternalMediaDto } from './dto/internal-medias.dto'; import { ExternalMediaDto } from './dto/external-media.dto'; import { createId } from '@paralleldrive/cuid2'; import { Op } from 'sequelize'; import { ActionEnum, Activity } from '@tomei/activity-history'; import { MediasRepository } from './medias.repository'; import { LoginUser } from '@tomei/sso'; type MediaFile = { buffer: Buffer; iv?: Buffer; isEncrypted?: boolean; }; type FilePath = { filePath: string; ivFilePath: string; }; export class Medias extends BaseMedias { ObjectName: string; TableName: string; ObjectId: string; ObjectType = 'Media'; private _blobServiceClient: BlobServiceClient | null = null; private get blobServiceClient(): BlobServiceClient { if (!this._blobServiceClient) { this._blobServiceClient = BlobServiceClient.fromConnectionString( process.env.AZURE_STORAGE_CONNECTION_STRING, ); } return this._blobServiceClient; } container: string; algorithm = 'aes-256-cbc'; commonService: CommonService; entity = typeof MediasModel; private static _Repo = new MediasRepository(); constructor(commonService?: CommonService, media?: IBaseMediasAttr) { super(media); this.commonService = commonService; try { this.container = process.env.MEDIA_AZUREBLOB_CONTAINER_NAME; if (!this.container && this.IsStorageTypeAzure()) { throw new BadRequestException( 'CONTAINER_NAME Environtment Variable name not found', ); } } catch (error) { throw error; } } public async createMedias( isExternalMedias: boolean, loginUser: LoginUser, stream?: Express.Multer.File, dbTransaction?: any, ) { try { const media = await super.create({ transaction: dbTransaction, }); if (!isExternalMedias) { await this.createInternalMedias(stream); } if (media) { const activity = new Activity(); activity.ActivityId = createId(); activity.Action = ActionEnum.CREATE; activity.Description = 'Add New Media'; activity.EntityType = 'Media'; activity.EntityId = this.MediaId; activity.EntityValueBefore = JSON.stringify({}); activity.EntityValueAfter = JSON.stringify({ ...media.get({ plain: true }), }); await activity.create(loginUser.ObjectId, dbTransaction); } return media; } catch (error) { throw error; } } public async updateMedias( isExternalMedias: boolean, loginUser: LoginUser, stream?: Express.Multer.File, dbTransaction?: any, ) { try { const oldMedia = await super.findOne({ where: { MediaId: this.MediaId }, transaction: dbTransaction, }); const oldMediaAttr = oldMedia.get({ plain: true }); const media = await super.update({ transaction: dbTransaction, }); if (!isExternalMedias) { await this.updateInternalMedia(stream, oldMediaAttr); } if (media) { const activity = new Activity(); activity.ActivityId = createId(); activity.Action = ActionEnum.UPDATE; activity.Description = 'Update Media'; activity.EntityType = 'Media'; activity.EntityId = this.MediaId; activity.EntityValueBefore = JSON.stringify({ ...oldMedia.get({ plain: true }), }); activity.EntityValueAfter = JSON.stringify({ ...media.get({ plain: true }), }); await activity.create(loginUser.ObjectId, dbTransaction); } return media; } catch (error) { throw error; } } private IsStorageTypeAzure(): boolean { const mediaStorageType = process.env.MEDIA_STORAGE_TYPE; if (mediaStorageType === 'azure') { return true; } else { return false; } } private async updateInternalMedia( stream: Express.Multer.File, oldMedia: MediasModel, ): Promise { try { this.FileExtension = this.FileExtension.split('.')[0]; this.FileName = this.FileName.replace(/\.[^/.]+$/, ''); let file: MediaFile; if (this.IsEncryptedYN === 'Y') { file = await this.encrypt(stream.buffer); } else { file = { buffer: stream.buffer, isEncrypted: false, }; } if (this.IsStorageTypeAzure()) { await this.deleteUploadedFileFromAzure( oldMedia.URL, oldMedia.FileName, oldMedia.FileExtension, oldMedia.IsEncryptedYN, ); await this.uploadFileToAzure(file); } else { const filePath = super.createSaveLocation(); const isDirExist = util.promisify(fs.exists); const isDir = await isDirExist(filePath); if (isDir) { fs.unlinkSync(oldMedia.FilePath); if (this.IsEncryptedYN === 'Y') { fs.unlinkSync(this.getIvPath()); } } await this.uploadFileToLocal(file); } } catch (error) { throw error; } } private async createInternalMedias( stream: Express.Multer.File, ): Promise { try { this.FileExtension = this.FileExtension.split('.')[0]; this.FileName = this.FileName.replace(/\.[^/.]+$/, ''); let file: MediaFile; if (this.IsEncryptedYN === 'Y') { file = await this.encrypt(stream.buffer); } else { file = { buffer: stream.buffer, isEncrypted: false, }; } if (this.IsStorageTypeAzure()) { this.uploadFileToAzure(file); } else { this.uploadFileToLocal(file); } } catch (error) { throw error; } } private getIvPath(oldUrl?: string) { const url = oldUrl ? oldUrl : super.createSaveLocation(); return url + `/${this.FileName}iv.iv`; } private async uploadFileToLocal(file: MediaFile): Promise { try { const savelocation = super.createSaveLocation(); const isDirExist = util.promisify(fs.exists); const createDir = util.promisify(fs.mkdir); const isDir = await isDirExist(savelocation); if (!isDir) { await createDir(savelocation, { recursive: true }); } // Make sure file is buffer not an ArrayBuffer if (file.buffer instanceof ArrayBuffer) { file.buffer = Buffer.from(file.buffer); } const writeFileContent = util.promisify(fs.writeFile); await writeFileContent(this.FilePath, file.buffer); if (file.isEncrypted) { await writeFileContent(this.getIvPath(), file.iv); } const path: FilePath = { filePath: super.createSaveLocation(), ivFilePath: this.getIvPath() ? this.getIvPath() : '', }; return path; } catch (error) { throw error; } } private async deleteUploadedFileFromLocal( path: string, isEncryptedYN: string, ): Promise { try { if (!fs.existsSync(path)) return; const stats = fs.statSync(path); if (stats.isDirectory()) { fs.rmSync(path, { recursive: true, force: true }); } else { fs.unlinkSync(path); } if (isEncryptedYN === 'Y' && fs.existsSync(this.getIvPath())) { fs.unlinkSync(this.getIvPath()); } } catch (error) { console.error('Error deleting file/directory:', error); throw error; } } private async uploadFileToAzure(file: MediaFile): Promise { const filePath = await this.uploadFileToAzureStorage( file.buffer, this.ObjectType, this.ObjectId, this.FileName, this.FileExtension, ); let ivPath: string; if (this.IsEncryptedYN === 'Y') { ivPath = await this.uploadFileToAzureStorage( file.iv, this.ObjectType, this.ObjectId, this.FileName + 'iv', 'iv', ); } const path: FilePath = { filePath, ivFilePath: ivPath, }; return path; } private async deleteUploadedFileFromAzure( path: string, FileName: string, FileExtension: string, isEncryptedYN: string, ): Promise { try { await this.deleteUploadedFileToAzureStorage( path, FileName, FileExtension, ); if (isEncryptedYN === 'Y') { await this.deleteUploadedFileToAzureStorage( path, FileName + 'iv', '.iv', ); } } catch (error) { throw error; } } private async uploadFileToAzureStorage( bufferFile: Buffer, ObjectType: string, ObjectId: string, FileName: string, FileExtension: string, ): Promise { try { const fileName = FileName.replace(/\.[^/.]+$/, ''); const path = `${this.container}/${ObjectType}/${ObjectId}`; const blobcontainer = this.blobServiceClient.getContainerClient(path); const blockBlobClient = await blobcontainer.getBlockBlobClient( `${fileName}.${FileExtension}`, ); await blockBlobClient.uploadData(bufferFile); return path; } catch (error) { throw error; } } private async deleteUploadedFileToAzureStorage( path: string, FileName: string, FileExtension: string, ): Promise { const fileName = FileName.replace(/\.[^/.]+$/, ''); const blobcontainer = this.blobServiceClient.getContainerClient(path); const blockBlobClient = await blobcontainer.getBlockBlobClient( `${fileName}.${FileExtension}`, ); return await blockBlobClient.deleteIfExists(); } private setKey(): string { try { const key = process.env.MEDIA_ENCRYPT_KEY; if (!key) { throw new BadRequestException( 'MEDIA_ENCRYPT_KEY Environtment Variable name not found', ); } return key; } catch (error) { throw error; } } async findFile(): Promise { try { let fileBuffer: Buffer; if (this.IsStorageTypeAzure()) { fileBuffer = await this.getFileFromAzure( this.URL, this.FileName, this.FileExtension, ); } else { try { fileBuffer = await this.getFileFromLocal(this.FilePath); } catch (error) { if (error instanceof Error && error.message.includes('ENOENT')) { console.warn( `Local file missing: ${this.FilePath}, fetching from Azure.`, ); fileBuffer = await this.getFileFromAzure( this.URL, this.FileName, this.FileExtension, ); const file: MediaFile = { buffer: fileBuffer, isEncrypted: false, }; await this.uploadFileToLocal(file); } else { throw error; } } } let file: MediaFile = { buffer: fileBuffer, isEncrypted: this.IsEncryptedYN === 'Y' ? true : false, }; if (file.isEncrypted) { try { if (this.IsStorageTypeAzure()) { file.iv = await this.getFileFromAzure( this.URL, this.FileName + 'iv', 'iv', ); } else { file.iv = await this.getFileFromLocal(this.getIvPath()); } } catch (error) { if (error instanceof Error && error.message.includes('ENOENT')) { console.warn( `IV file missing: ${this.getIvPath()}, fetching from Azure.`, ); file.iv = await this.getFileFromAzure( this.URL, this.FileName + 'iv', 'iv', ); await this.uploadFileToLocal(file); } else { throw error; } } file = await this.decrypt(file); } return file.buffer; } catch (error) { throw error; } } private async stream2buffer( stream: Readable | fs.ReadStream, ): Promise { return new Promise((resolve, reject) => { const buffer = Array(); stream.on('data', (chunk) => buffer.push(chunk)); stream.on('end', () => resolve(Buffer.concat(buffer))); stream.on('error', (err) => reject(`error converting stream - ${err}`)); }); } private async getFileFromAzure( path: string, FileName: string, FileExtension: string, ): Promise { try { const fileName = FileName.replace(/\.[^/.]+$/, ''); const blobcontainer = this.blobServiceClient.getContainerClient(path); const blockBlobClient = await blobcontainer.getBlockBlobClient( `${fileName}.${FileExtension}`, ); const downloadResponse = await blockBlobClient.download(); const ReadStream = new Readable().wrap( downloadResponse.readableStreamBody, ); const file = await this.stream2buffer(ReadStream); return file; } catch (error) { throw error; } } private async getFileFromLocal(pathfile: string): Promise { try { const stream = fs.createReadStream(pathfile); const file = await this.stream2buffer(stream); return file; } catch (error) { throw error; } } public async encrypt(fileBuffer: Buffer): Promise { try { const key = this.setKey(); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(this.algorithm, key, iv); const resultBuffer = Buffer.concat([ cipher.update(fileBuffer), cipher.final(), ]); const result: MediaFile = { buffer: resultBuffer, iv, isEncrypted: true, }; return result; } catch (error) { throw error; } } public async decrypt(file: MediaFile): Promise { try { const key = this.setKey(); const iv = file.iv; const decipher = crypto.createDecipheriv(this.algorithm, key, iv); const resultBuffer = Buffer.concat([ decipher.update(file.buffer), decipher.final(), ]); const result: MediaFile = { buffer: resultBuffer, isEncrypted: false, }; return result; } catch (error) { throw error; } } public async delete(loginUser: LoginUser, dbTransaction?: any): Promise { try { const data: MediasModel = await super.delete({ transaction: dbTransaction, }); if (this.IsExternalYN === 'N') { if (this.IsStorageTypeAzure()) { await this.deleteUploadedFileFromAzure( this.URL, this.FileName, this.FileExtension, this.IsEncryptedYN, ); } else { await this.deleteUploadedFileFromLocal(this.URL, this.IsEncryptedYN); } } const activity = new Activity(); activity.ActivityId = createId(); activity.Action = ActionEnum.DELETE; activity.Description = 'Deleted media (ID: ${this.MediaId})'; activity.EntityType = 'Media'; activity.EntityId = this.MediaId; activity.EntityValueBefore = JSON.stringify(data); activity.EntityValueAfter = JSON.stringify({}); await activity.create(loginUser.ObjectId, dbTransaction); return { message: 'Media has been deleted.' }; } catch (error) { throw error; } } async postInternal( fileStream: Express.Multer.File, createMediaDto: InternalMediaDto, loginUser: LoginUser, dbTransaction?: any, ) { try { const mediaAttr: IBaseMediasAttr = { ...createMediaDto, MediaId: createId(), IsExternalYN: 'N', ExternalSource: '', URL: '', FilePath: '', CreatedAt: new Date(), UpdatedAt: new Date(), CreatedById: loginUser.ObjectId, UpdatedById: loginUser.ObjectId, }; this.init(mediaAttr); const createdData = await this.createMedias( false, loginUser, fileStream, dbTransaction, ); return createdData; } catch (error) { throw error; } } async postExternal(createMediaDto: ExternalMediaDto, loginUser: LoginUser) { const mediaAttr: IBaseMediasAttr = { ...createMediaDto, MediaId: createId(), IsExternalYN: 'Y', CreatedAt: new Date(), UpdatedAt: new Date(), IsEncryptedYN: 'N', FilePath: '', FileName: '', FileExtension: '', CreatedById: loginUser.ObjectId, UpdatedById: loginUser.ObjectId, }; this.init(mediaAttr); await this.createMedias(true, loginUser); return mediaAttr; } async getAll(rows: number, page: number, search: string) { let searchObj: any; try { searchObj = search ? JSON.parse(search) : {}; } catch (err) { throw new BadRequestException('Bad value for search.'); } const { ...mediaFilter } = searchObj; const queryObj = {}; Object.entries(mediaFilter).forEach(([key, value]) => { queryObj[key] = { [Op.substring]: value, }; }); const offset = rows * (page - 1); const options = { order: [['CreatedAt', 'DESC']], limit: rows, offset, where: queryObj, distinct: true, }; return this.findAllWithPagination(options); } async getOne(id: string, dbTransaction?: any) { try { const media = await this.findOne({ where: { MediaId: id }, transaction: dbTransaction, }); if (!media) { throw new NotFoundException(`Media not found with id ${id}`); } return media; } catch (error) { throw error; } } async getFile(id: string, dbTransaction?: any) { try { const media = await this.findOne({ where: { MediaId: id }, transaction: dbTransaction, }); if (!media) { throw new NotFoundException(`Media not found with id ${id}`); } this.init({ ...media.get({ plain: true }) }); const fileBuffer: Buffer = await this.findFile(); return { fileBuffer, resOption: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename=${media.FileName}.${media.FileExtension}`, 'Content-Length': fileBuffer.length, }, }; } catch (error) { throw error; } } async remove(id: string, loginUser: LoginUser, dbTransaction?: any) { try { const media = await this.findOne({ where: { MediaId: id }, transaction: dbTransaction, }); if (!media) { throw new NotFoundException(`Media not found with id ${id}`); } const mediaAttr: IBaseMediasAttr = { ...media.get({ plain: true }), UpdatedAt: new Date(), UpdatedById: loginUser.ObjectId, }; this.init(mediaAttr); return await this.delete(loginUser, dbTransaction); } catch (error) { throw error; } } async putExternal( id: string, updatedMediaDto: ExternalMediaDto, userId: string, dbTransaction?: any, ) { try { const media = await this.findOne({ where: { MediaId: id }, transaction: dbTransaction, }); if (!media) { throw new NotFoundException(`Media not found with id ${id}`); } const mediaAttr: IBaseMediasAttr = { ...media.get({ plain: true }), ...updatedMediaDto, UpdatedAt: new Date(), UpdatedById: userId, }; this.init(mediaAttr); return await this.updateMedias(true, null, dbTransaction); } catch (error) { throw error; } } async putInternal( fileStream: Express.Multer.File, id: string, updatedMediaDto: InternalMediaDto, loginUser: LoginUser, dbTransaction?: any, ) { try { const media = await this.findOne({ where: { MediaId: id }, transaction: dbTransaction, }); if (!media) { throw new NotFoundException(`Media not found with id ${id}`); } const mediaAttr: IBaseMediasAttr = { ...media.get({ plain: true }), ...updatedMediaDto, UpdatedAt: new Date(), UpdatedById: loginUser.ObjectId, }; this.init(mediaAttr); return await this.updateMedias( false, loginUser, fileStream, dbTransaction, ); } catch (error) { throw error; } } }