import fs from 'node:fs'; import type Stream from 'node:stream'; import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; import { api, ServiceClassInternal } from '@rocket.chat/core-services'; import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from '@rocket.chat/core-services'; import type { IUpload, IUser, FilesAndAttachments, IMessage, AtLeast } from '@rocket.chat/core-typings'; import { isFileAttachment } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Uploads, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import sharp from 'sharp'; import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../lib/constants'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; import { canDeleteMessageAsync } from '../../lib/authorization/canDeleteMessage'; import { i18n } from '../../lib/i18n'; import { FileUpload } from '../../lib/media/file-upload'; import { updateMessage } from '../../lib/messages/updateMessage'; import { setUserAvatar } from '../../lib/users/setUserAvatar'; import { parseFileIntoMessageAttachments, sendFileMessage } from '../../meteor-methods/messages/sendFileMessage'; import { sendFileLivechatMessage } from '../../meteor-methods/omnichannel/sendFileLivechatMessage'; import { UploadFS } from '../../ufs'; const logger = new Logger('UploadService'); export class UploadService extends ServiceClassInternal implements IUploadService { protected name = 'upload'; async uploadFile({ buffer, details, federation }: IUploadFileParams): Promise { const fileStore = FileUpload.getStore('Uploads'); return fileStore.insert({ ...details, ...(federation && { federation }) }, buffer); } async sendFileMessage({ roomId, file, userId, message }: ISendFileMessageParams): Promise { return sendFileMessage(userId, { roomId, file, msgData: message }); } async sendFileLivechatMessage({ roomId, visitorToken, file, message }: ISendFileLivechatMessageParams): Promise { return sendFileLivechatMessage({ roomId, visitorToken, file, msgData: message }); } async getFileBuffer({ file }: { file: IUpload }): Promise { const buffer = await FileUpload.getBuffer(file); if (!(buffer instanceof Buffer)) { throw new Error('Unknown error'); } return buffer; } async extractMetadata(file: IUpload): Promise<{ height?: number; width?: number; format?: string }> { return FileUpload.extractMetadata(file); } async parseFileIntoMessageAttachments(file: Partial, roomId: string, user: IUser): Promise { return parseFileIntoMessageAttachments(file, roomId, user); } async canDeleteFile( user: Pick, file: Pick, msg: IMessage | null, ): Promise { if (msg) { return canDeleteMessageAsync(user, msg); } if (!file.userId || !file.rid) { return false; } // If file is not confirmed and was sent by the same user if (file.expiresAt && file.userId === user._id) { return canAccessRoomIdAsync(file.rid, user._id); } // It's a confirmed file but it has no message, so use data from the file to run message delete permission checks const msgForValidation = { u: { _id: file.userId }, ts: file.uploadedAt, rid: file.rid }; return canDeleteMessageAsync(user, msgForValidation); } async deleteFile(user: IUser, fileId: IUpload['_id'], msg: IMessage | null): Promise<{ deletedFiles: IUpload['_id'][] }> { // Find every file that is derived from the file that is being deleted (its thumbnails) const additionalFiles = await Uploads.findAllByOriginalFileId(fileId, { projection: { _id: 1 } }) .map(({ _id }) => _id) .toArray(); const allFiles = [fileId, ...additionalFiles]; if (msg) { await this.updateMessageRemovingFiles(msg, allFiles, user); } return this.removeFileAndDerivates(fileId, additionalFiles); } private async removeFileAndDerivates( fileId: IUpload['_id'], additionalFiles: IUpload['_id'][], ): Promise<{ deletedFiles: IUpload['_id'][] }> { const store = FileUpload.getStore('Uploads'); // Delete the main file first; await store.deleteById(fileId); // The main file is already deleted; From here forward we'll return a success response even if some sub-process fails const deletedFiles: IUpload['_id'][] = [fileId]; // Delete them one by one as the store may include requests to external services for await (const id of additionalFiles) { try { await store.deleteById(id); deletedFiles.push(id); } catch (err) { logger.error({ msg: 'Failed to delete derived file', fileId: id, originalFileId: fileId, err }); } } return { deletedFiles }; } private async updateMessageRemovingFiles(msg: IMessage, filesToRemove: IUpload['_id'][], user: IUser): Promise { const text = `_${i18n.t('File_removed')}_`; const color = NOTIFICATION_ATTACHMENT_COLOR; const newFiles = msg.files?.filter((file) => !filesToRemove.includes(file._id)); const newAttachments = msg.attachments?.map((attachment) => { if (!isFileAttachment(attachment)) { return attachment; } // If the attachment doesn't have a `fileId`, we assume it's an old message with only one file, in which case checking the id is not needed if (attachment.fileId && !filesToRemove.includes(attachment.fileId)) { return attachment; } return { type: 'removed-file', color, text, ...(attachment.fileId && { fileId: attachment.fileId }) }; }); const newFile = msg.file?._id && !filesToRemove.includes(msg.file._id) ? msg.file : newFiles?.[0]; const editedMessage = { ...msg, files: newFiles, attachments: newAttachments, file: newFile, }; await updateMessage(editedMessage, user, msg); } async streamUploadedFile({ file, imageResizeOpts, }: { file: IUpload; imageResizeOpts?: { width: number; height: number }; }): Promise { const stream = await FileUpload.getStore('Uploads')._store.getReadStream(file._id, file); if (!stream) { throw new Error('error-file-not-found'); } if (file?.type?.includes('image') && imageResizeOpts) { const { width, height } = imageResizeOpts; const transformer = sharp().resize({ width, height, fit: 'contain' }); stream.on('error', (err) => transformer.destroy(err)); return stream.pipe(transformer); } return stream; } async uploadFileFromStream({ streamParam, details, }: { streamParam: Stream.Readable; details: Omit; }): Promise { const resolver = Promise.withResolvers(); const tempFilePath = UploadFS.getTempFilePath(Random.id()); const writeStream = fs.createWriteStream(tempFilePath); streamParam.pipe(writeStream); const cleanup = (err: unknown) => { fs.promises.unlink(tempFilePath).catch(() => undefined); resolver.reject(err); }; writeStream.on('finish', async () => { FileUpload.getStore('Uploads') .insert( { ...details, size: writeStream.bytesWritten, }, tempFilePath, ) .then(resolver.resolve) .catch(cleanup); }); streamParam.on('error', async (err) => { writeStream.destroy(); cleanup(err); }); writeStream.on('error', cleanup); return resolver.promise; } async setUserAvatar(user: Pick, buffer: Buffer, contentType: string, service: 'rest'): Promise { return setUserAvatar(user, buffer, contentType, service); } async resetUserAvatar(user: AtLeast): Promise { if (!user.username) { throw new Error('Username is required to reset avatar'); } await FileUpload.getStore('Avatars').deleteByName(user.username); await Users.unsetAvatarData(user._id); void api.broadcast('user.avatarUpdate', { username: user.username, avatarETag: undefined }); } }