diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index 15a433a21413..1ac0c4a5b081 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.ts @@ -1,19 +1,22 @@ import type { Readable } from 'stream'; -import { Meteor } from 'meteor/meteor'; import type { Request } from 'express'; import busboy from 'busboy'; import type { ValidateFunction } from 'ajv'; -type UploadResult = { - file: Readable; +import { MeteorError } from '../../../../server/sdk/errors'; + +type UploadResult = { + file: Readable & { truncated: boolean }; + fieldname: string; filename: string; encoding: string; mimetype: string; fileBuffer: Buffer; + fields: K; }; -export const getUploadFormData = async < +export async function getUploadFormData< T extends string, K extends Record = Record, V extends ValidateFunction = ValidateFunction, @@ -22,63 +25,103 @@ export const getUploadFormData = async < options: { field?: T; validate?: V; + sizeLimit?: number; } = {}, -): Promise<[UploadResult, K, T]> => - new Promise((resolve, reject) => { - const bb = busboy({ headers: request.headers, defParamCharset: 'utf8' }); - const fields = Object.create(null) as K; - - let uploadedFile: UploadResult | undefined; - - let assetName: T | undefined; - - bb.on( - 'file', - ( - fieldname: string, - file: Readable, - { filename, encoding, mimeType: mimetype }: { filename: string; encoding: string; mimeType: string }, - ) => { - const fileData: Uint8Array[] = []; - - file.on('data', (data: any) => fileData.push(data)); - - file.on('end', () => { - if (uploadedFile) { - return reject('Just 1 file is allowed'); - } - if (options.field && fieldname !== options.field) { - return reject(new Meteor.Error('invalid-field')); - } - uploadedFile = { - file, - filename, - encoding, - mimetype, - fileBuffer: Buffer.concat(fileData), - }; - - assetName = fieldname as T; - }); - }, - ); - - bb.on('field', (fieldname: keyof K, value: K[keyof K]) => { - fields[fieldname] = value; +): Promise> { + const limits = { + files: 1, + ...(options.sizeLimit && options.sizeLimit > -1 && { fileSize: options.sizeLimit }), + }; + + const bb = busboy({ headers: request.headers, defParamCharset: 'utf8', limits }); + const fields = Object.create(null) as K; + + let uploadedFile: UploadResult | undefined; + + let returnResult = (_value: UploadResult) => { + // noop + }; + let returnError = (_error?: Error | string | null | undefined) => { + // noop + }; + + function onField(fieldname: keyof K, value: K[keyof K]) { + fields[fieldname] = value; + } + + function onEnd() { + if (!uploadedFile) { + return returnError(new MeteorError('No file uploaded')); + } + if (options.validate !== undefined && !options.validate(fields)) { + return returnError(new MeteorError(`Invalid fields ${options.validate.errors?.join(', ')}`)); + } + return returnResult(uploadedFile); + } + + function onFile( + fieldname: string, + file: Readable & { truncated: boolean }, + { filename, encoding, mimeType: mimetype }: { filename: string; encoding: string; mimeType: string }, + ) { + if (options.field && fieldname !== options.field) { + file.resume(); + return returnError(new MeteorError('invalid-field')); + } + + const fileChunks: Uint8Array[] = []; + file.on('data', function (chunk) { + fileChunks.push(chunk); }); - bb.on('finish', () => { - if (!uploadedFile || !assetName) { - return reject('No file uploaded'); - } - if (options.validate === undefined) { - return resolve([uploadedFile, fields, assetName]); - } - if (!options.validate(fields)) { - return reject(`Invalid fields${options.validate.errors?.join(', ')}`); + file.on('end', function () { + if (file.truncated) { + fileChunks.length = 0; + return returnError(new MeteorError('error-file-too-large')); } - return resolve([uploadedFile, fields, assetName]); + + uploadedFile = { + file, + filename, + encoding, + mimetype, + fieldname, + fields, + fileBuffer: Buffer.concat(fileChunks), + }; }); + } + + function cleanup() { + request.unpipe(bb); + request.on('readable', request.read.bind(request)); + bb.removeAllListeners(); + } + + bb.on('field', onField); + bb.on('file', onFile); + bb.on('close', cleanup); + bb.on('end', onEnd); + bb.on('finish', onEnd); + + bb.on('error', function (err: Error) { + returnError(err); + }); + + bb.on('partsLimit', function () { + returnError(); + }); + bb.on('filesLimit', function () { + returnError('Just 1 file is allowed'); + }); + bb.on('fieldsLimit', function () { + returnError(); + }); + + request.pipe(bb); - request.pipe(bb); + return new Promise((resolve, reject) => { + returnResult = resolve; + returnError = reject; }); +} diff --git a/apps/meteor/app/api/server/v1/assets.ts b/apps/meteor/app/api/server/v1/assets.ts index db3d63827535..2c4bf90b68aa 100644 --- a/apps/meteor/app/api/server/v1/assets.ts +++ b/apps/meteor/app/api/server/v1/assets.ts @@ -4,20 +4,25 @@ import { isAssetsUnsetAssetProps } from '@rocket.chat/rest-typings'; import { RocketChatAssets } from '../../../assets/server'; import { API } from '../api'; import { getUploadFormData } from '../lib/getUploadFormData'; +import { settings } from '../../../settings/server'; API.v1.addRoute( 'assets.setAsset', { authRequired: true }, { async post() { - const [asset, { refreshAllClients, assetName: customName }, fileName] = await getUploadFormData( + const asset = await getUploadFormData( { request: this.request, }, - { field: 'asset' }, + { field: 'asset', sizeLimit: settings.get('FileUpload_MaxFileSize') }, ); - const assetName = customName || fileName; + const { fileBuffer, fields, filename, mimetype } = asset; + + const { refreshAllClients, assetName: customName } = fields; + + const assetName = customName || filename; const assetsKeys = Object.keys(RocketChatAssets.assets); const isValidAsset = assetsKeys.includes(assetName); @@ -25,7 +30,7 @@ API.v1.addRoute( throw new Meteor.Error('error-invalid-asset', 'Invalid asset'); } - Meteor.call('setAsset', asset.fileBuffer, asset.mimetype, assetName); + Meteor.call('setAsset', fileBuffer, mimetype, assetName); if (refreshAllClients) { Meteor.call('refreshClients'); } diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index e0027765352e..1be07e459a51 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -6,6 +6,7 @@ import { getUploadFormData } from '../lib/getUploadFormData'; import { findEmojisCustom } from '../lib/emoji-custom'; import { Media } from '../../../../server/sdk'; import { SystemLogger } from '../../../../server/lib/logger/system'; +import { settings } from '../../../settings/server'; API.v1.addRoute( 'emoji-custom.list', @@ -68,19 +69,21 @@ API.v1.addRoute( { authRequired: true }, { async post() { - const [emoji, fields] = await getUploadFormData( + const emoji = await getUploadFormData( { request: this.request, }, - { field: 'emoji' }, + { field: 'emoji', sizeLimit: settings.get('FileUpload_MaxFileSize') }, ); - const isUploadable = await Media.isImage(emoji.fileBuffer); + const { fields, fileBuffer, mimetype } = emoji; + + const isUploadable = await Media.isImage(fileBuffer); if (!isUploadable) { throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); } - const [, extension] = emoji.mimetype.split('/'); + const [, extension] = mimetype.split('/'); fields.extension = extension; try { @@ -89,7 +92,7 @@ API.v1.addRoute( newFile: true, aliases: fields.aliases || '', }); - Meteor.call('uploadEmojiCustom', emoji.fileBuffer, emoji.mimetype, { + Meteor.call('uploadEmojiCustom', fileBuffer, mimetype, { ...fields, newFile: true, aliases: fields.aliases || '', @@ -109,13 +112,15 @@ API.v1.addRoute( { authRequired: true }, { async post() { - const [emoji, fields] = await getUploadFormData( + const emoji = await getUploadFormData( { request: this.request, }, - { field: 'emoji' }, + { field: 'emoji', sizeLimit: settings.get('FileUpload_MaxFileSize') }, ); + const { fields, fileBuffer, mimetype } = emoji; + if (!fields._id) { throw new Meteor.Error('The required "_id" query param is missing.'); } @@ -128,15 +133,15 @@ API.v1.addRoute( fields.previousName = emojiToUpdate.name; fields.previousExtension = emojiToUpdate.extension; fields.aliases = fields.aliases || ''; - const newFile = Boolean(emoji?.fileBuffer.length); + const newFile = Boolean(emoji && fileBuffer.length); if (fields.newFile) { - const isUploadable = await Media.isImage(emoji.fileBuffer); + const isUploadable = await Media.isImage(fileBuffer); if (!isUploadable) { throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); } - const [, extension] = emoji.mimetype.split('/'); + const [, extension] = mimetype.split('/'); fields.extension = extension; } else { fields.extension = emojiToUpdate.extension; @@ -144,7 +149,7 @@ API.v1.addRoute( Meteor.call('insertOrUpdateEmoji', { ...fields, newFile }); if (fields.newFile) { - Meteor.call('uploadEmojiCustom', emoji.fileBuffer, emoji.mimetype, { ...fields, newFile }); + Meteor.call('uploadEmojiCustom', fileBuffer, mimetype, { ...fields, newFile }); } return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/rooms.js b/apps/meteor/app/api/server/v1/rooms.js index aab20cbc0238..e5a8d49f8bc2 100644 --- a/apps/meteor/app/api/server/v1/rooms.js +++ b/apps/meteor/app/api/server/v1/rooms.js @@ -81,39 +81,41 @@ API.v1.addRoute( 'rooms.upload/:rid', { authRequired: true }, { - post() { + async post() { if (!canAccessRoomId(this.urlParams.rid, this.userId)) { return API.v1.unauthorized(); } - const [file, fields] = Promise.await( - getUploadFormData( - { - request: this.request, - }, - { field: 'file' }, - ), + const file = await getUploadFormData( + { + request: this.request, + }, + { field: 'file', sizeLimit: settings.get('FileUpload_MaxFileSize') }, ); if (!file) { throw new Meteor.Error('invalid-field'); } + const { fields } = file; + let { fileBuffer } = file; + const details = { name: file.filename, - size: file.fileBuffer.length, + size: fileBuffer.length, type: file.mimetype, rid: this.urlParams.rid, userId: this.userId, }; const stripExif = settings.get('Message_Attachments_Strip_Exif'); - const fileStore = FileUpload.getStore('Uploads'); if (stripExif) { // No need to check mime. Library will ignore any files without exif/xmp tags (like BMP, ico, PDF, etc) - file.fileBuffer = Promise.await(Media.stripExifFromBuffer(file.fileBuffer)); + fileBuffer = await Media.stripExifFromBuffer(fileBuffer); } - const uploadedFile = fileStore.insertSync(details, file.fileBuffer); + + const fileStore = FileUpload.getStore('Uploads'); + const uploadedFile = await fileStore.insert(details, fileBuffer); uploadedFile.description = fields.description; diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 3ee5698eb842..80775f4ca0b2 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -189,19 +189,19 @@ API.v1.addRoute( return API.v1.success(); } - const [image, fields] = await getUploadFormData( + const image = await getUploadFormData( { request: this.request, }, - { - field: 'image', - }, + { field: 'image', sizeLimit: settings.get('FileUpload_MaxFileSize') }, ); if (!image) { return API.v1.failure("The 'image' param is required"); } + const { fields, fileBuffer, mimetype } = image; + const sentTheUserByFormData = fields.userId || fields.username; if (sentTheUserByFormData) { if (fields.userId) { @@ -220,7 +220,7 @@ API.v1.addRoute( } } - setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); + setUserAvatar(user, fileBuffer, mimetype, 'rest'); return API.v1.success(); }, diff --git a/apps/meteor/app/apps/server/communication/rest.js b/apps/meteor/app/apps/server/communication/rest.js index 7fa99248b418..5f0848548b9f 100644 --- a/apps/meteor/app/apps/server/communication/rest.js +++ b/apps/meteor/app/apps/server/communication/rest.js @@ -213,13 +213,16 @@ export class AppsRestApi { return API.v1.failure({ error: 'Direct installation of an App is disabled.' }); } - const [app, formData] = await getUploadFormData( + const app = await getUploadFormData( { request: this.request, }, - { field: 'app' }, + { field: 'app', sizeLimit: settings.get('FileUpload_MaxFileSize') }, ); - buff = app?.fileBuffer; + + const { fields: formData } = app; + + buff = app.fileBuffer; permissionsGranted = (() => { try { const permissions = JSON.parse(formData?.permissions || ''); @@ -497,13 +500,16 @@ export class AppsRestApi { return API.v1.failure({ error: 'Direct updating of an App is disabled.' }); } - const [app, formData] = await getUploadFormData( + const app = await getUploadFormData( { request: this.request, }, - { field: 'app' }, + { field: 'app', sizeLimit: settings.get('FileUpload_MaxFileSize') }, ); - buff = app?.fileBuffer; + + const { fields: formData } = app; + + buff = app.fileBuffer; permissionsGranted = (() => { try { const permissions = JSON.parse(formData?.permissions || ''); diff --git a/apps/meteor/app/livechat/imports/server/rest/upload.ts b/apps/meteor/app/livechat/imports/server/rest/upload.ts index c7b8544ed101..3abac872ec1f 100644 --- a/apps/meteor/app/livechat/imports/server/rest/upload.ts +++ b/apps/meteor/app/livechat/imports/server/rest/upload.ts @@ -27,23 +27,27 @@ API.v1.addRoute('livechat/upload/:rid', { return API.v1.unauthorized(); } - const [file, fields] = await getUploadFormData( + const maxFileSize = settings.get('FileUpload_MaxFileSize') || 104857600; + + const file = await getUploadFormData( { request: this.request, }, - { field: 'file' }, + { field: 'file', sizeLimit: maxFileSize }, ); - if (!fileUploadIsValidContentType(file.mimetype)) { + const { fields, fileBuffer, filename, mimetype } = file; + + if (!fileUploadIsValidContentType(mimetype)) { return API.v1.failure({ reason: 'error-type-not-allowed', }); } - const maxFileSize = settings.get('FileUpload_MaxFileSize') || 104857600; + const buffLength = fileBuffer.length; // -1 maxFileSize means there is no limit - if (maxFileSize > -1 && file.fileBuffer.length > maxFileSize) { + if (maxFileSize > -1 && buffLength > maxFileSize) { return API.v1.failure({ reason: 'error-size-not-allowed', sizeAllowed: filesize(maxFileSize), @@ -53,14 +57,14 @@ API.v1.addRoute('livechat/upload/:rid', { const fileStore = FileUpload.getStore('Uploads'); const details = { - name: file.filename, - size: file.fileBuffer.length, - type: file.mimetype, + name: filename, + size: buffLength, + type: mimetype, rid: this.urlParams.rid, visitorToken, }; - const uploadedFile = fileStore.insertSync(details, file.fileBuffer); + const uploadedFile = await fileStore.insert(details, fileBuffer); if (!uploadedFile) { return API.v1.failure('Invalid file'); } diff --git a/apps/meteor/server/sdk/errors.ts b/apps/meteor/server/sdk/errors.ts index 5b56c84de87f..f86c55eb2c2a 100644 --- a/apps/meteor/server/sdk/errors.ts +++ b/apps/meteor/server/sdk/errors.ts @@ -4,7 +4,7 @@ export class MeteorError extends Error { public readonly errorType = 'Meteor.Error'; public constructor(public readonly error: string | number, public readonly reason?: string, public readonly details?: any) { - super(String(error)); + super(`${reason ? `${reason} ` : ''}[${String(error)}]`); } public toJSON(): any { @@ -13,7 +13,7 @@ export class MeteorError extends Error { errorType: this.errorType, error: this.error, reason: this.reason, - message: `${this.reason ? `${this.reason} ` : ''}[${this.error}]`, + message: this.message, ...(this.details && { details: this.details }), }; } diff --git a/apps/meteor/tests/unit/server/sdk/errors.tests.ts b/apps/meteor/tests/unit/server/sdk/errors.tests.ts new file mode 100644 index 000000000000..212ffa5d84d6 --- /dev/null +++ b/apps/meteor/tests/unit/server/sdk/errors.tests.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; + +import { MeteorError } from '../../../../server/sdk/errors'; + +describe('MeteorError', () => { + it('should create an error with no reason like Meteor.Error', () => { + const error = new MeteorError('no reason'); + + const stringfiedError = { isClientSafe: true, errorType: 'Meteor.Error', error: 'no reason', message: '[no reason]' }; + + expect(error.error).to.equal('no reason'); + expect(error.reason).to.be.undefined; + expect(error.message).to.equal('[no reason]'); + expect(error.details).to.be.undefined; + expect(error.isClientSafe).to.be.true; + expect(error.errorType).to.equal('Meteor.Error'); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal(stringfiedError); + }); + it('should create an error with reason like Meteor.Error', () => { + const error = new MeteorError('some message', 'some reason'); + + const stringfiedError = { + isClientSafe: true, + errorType: 'Meteor.Error', + error: 'some message', + message: 'some reason [some message]', + reason: 'some reason', + }; + + expect(error.error).to.equal('some message'); + expect(error.reason).to.equal('some reason'); + expect(error.message).to.equal('some reason [some message]'); + expect(error.details).to.be.undefined; + expect(error.isClientSafe).to.be.true; + expect(error.errorType).to.equal('Meteor.Error'); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal(stringfiedError); + }); + it('should create an error with reason and details like Meteor.Error', () => { + const error = new MeteorError('some message', 'some reason', { some: 'details' }); + + const stringfiedError = { + isClientSafe: true, + errorType: 'Meteor.Error', + error: 'some message', + message: 'some reason [some message]', + reason: 'some reason', + details: { some: 'details' }, + }; + + expect(error.error).to.equal('some message'); + expect(error.reason).to.equal('some reason'); + expect(error.message).to.equal('some reason [some message]'); + expect(error.details).to.be.deep.equal({ some: 'details' }); + expect(error.isClientSafe).to.be.true; + expect(error.errorType).to.equal('Meteor.Error'); + expect(JSON.parse(JSON.stringify(error))).to.deep.equal(stringfiedError); + }); +});