Files
upstream-rocketchat/apps/meteor/server/services/upload/service.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

230 lines
7.8 KiB
TypeScript

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<IUpload> {
const fileStore = FileUpload.getStore('Uploads');
return fileStore.insert({ ...details, ...(federation && { federation }) }, buffer);
}
async sendFileMessage({ roomId, file, userId, message }: ISendFileMessageParams): Promise<boolean | undefined> {
return sendFileMessage(userId, { roomId, file, msgData: message });
}
async sendFileLivechatMessage({ roomId, visitorToken, file, message }: ISendFileLivechatMessageParams): Promise<boolean> {
return sendFileLivechatMessage({ roomId, visitorToken, file, msgData: message });
}
async getFileBuffer({ file }: { file: IUpload }): Promise<Buffer> {
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<IUpload>, roomId: string, user: IUser): Promise<FilesAndAttachments> {
return parseFileIntoMessageAttachments(file, roomId, user);
}
async canDeleteFile(
user: Pick<IUser, '_id' | 'username'>,
file: Pick<IUpload, '_id' | 'userId' | 'rid' | 'expiresAt' | 'uploadedAt'>,
msg: IMessage | null,
): Promise<boolean> {
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<void> {
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<Stream.Readable> {
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<IUploadDetails, 'size'>;
}): Promise<IUpload> {
const resolver = Promise.withResolvers<IUpload>();
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<IUser, '_id' | 'username'>, buffer: Buffer, contentType: string, service: 'rest'): Promise<void> {
return setUserAvatar(user, buffer, contentType, service);
}
async resetUserAvatar(user: AtLeast<IUser, '_id' | 'username'>): Promise<void> {
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 });
}
}