diff --git a/.changeset/forty-geese-do.md b/.changeset/forty-geese-do.md new file mode 100644 index 0000000000000..c0df70578e51a --- /dev/null +++ b/.changeset/forty-geese-do.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes missing file size and MP3 validation for custom sounds upload diff --git a/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts b/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts index 64286bb71d86a..de0154a40a869 100644 --- a/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts @@ -1,9 +1,12 @@ import { api } from '@rocket.chat/core-services'; import type { RequiredField } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { fromBuffer } from 'file-type'; import { Meteor } from 'meteor/meteor'; import type { ICustomSoundData } from './insertOrUpdateSound'; +import { MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; +import { MIME } from '../../../../server/ufs/ufs-mime'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { RocketChatFile } from '../../../file/server'; import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; @@ -16,18 +19,26 @@ declare module '@rocket.chat/ddp-client' { } Meteor.methods({ - async uploadCustomSound(binaryContent, contentType, soundData) { + async uploadCustomSound(binaryContent, _contentType, soundData) { if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { throw new Meteor.Error('not_authorized'); } + if (binaryContent.length > MAX_CUSTOM_SOUND_SIZE_BYTES) { + throw new Meteor.Error('file-too-large', 'Max size is 5MB'); + } const file = Buffer.from(binaryContent, 'binary'); + const mimeType = await fromBuffer(file); + if (mimeType?.mime !== MIME.mp3 || mimeType.ext !== 'mp3' || soundData.extension !== 'mp3') { + throw new Meteor.Error('invalid-file-type', 'Only MP3 files are allowed'); + } + const rs = RocketChatFile.bufferToStream(file); await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.extension}`); return new Promise((resolve) => { - const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType); + const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, MIME.mp3); ws.on('end', () => { setTimeout(() => api.broadcast('notify.updateCustomSound', { soundData }), 500); resolve(); diff --git a/apps/meteor/client/hooks/useSingleFileInput.ts b/apps/meteor/client/hooks/useSingleFileInput.ts index 629152008d014..bda6c9a1f353d 100644 --- a/apps/meteor/client/hooks/useSingleFileInput.ts +++ b/apps/meteor/client/hooks/useSingleFileInput.ts @@ -5,6 +5,8 @@ export const useSingleFileInput = ( onSetFile: (file: File, formData: FormData) => void, fileType = 'image/*', fileField = 'image', + maxSize?: number, + onError?: () => void, ): [onClick: () => void, reset: () => void] => { const ref = useRef(); @@ -40,9 +42,18 @@ export const useSingleFileInput = ( if (!fileInput?.files?.length) { return; } + + const file = fileInput.files[0]; + + if (maxSize !== undefined && file.size > maxSize) { + onError?.(); + fileInput.value = ''; + return; + } + const formData = new FormData(); - formData.append(fileField, fileInput.files[0]); - onSetFile(fileInput.files[0], formData); + formData.append(fileField, file); + onSetFile(file, formData); }; fileInput.addEventListener('change', handleFiles, false); @@ -50,7 +61,7 @@ export const useSingleFileInput = ( return (): void => { fileInput.removeEventListener('change', handleFiles, false); }; - }, [fileField, fileType, onSetFile]); + }, [fileField, fileType, onSetFile, maxSize, onError]); const onClick = useEffectEvent(() => ref?.current?.click()); const reset = useEffectEvent(() => { diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index b3a75763c0932..62370c3e0aeb3 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -1,11 +1,13 @@ import { Field, FieldLabel, FieldRow, TextInput, Box, Margins, Button, ButtonGroup, IconButton } from '@rocket.chat/fuselage'; import { ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, useMethod } from '@rocket.chat/ui-contexts'; +import fileSize from 'filesize'; import type { ReactElement, FormEvent } from 'react'; import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { validate, createSoundData } from './lib'; +import { MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type AddCustomSoundProps = { @@ -28,7 +30,12 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr setSound(soundFile); }, []); - const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/mp3'); + const [clickUpload] = useSingleFileInput(handleChangeFile, '.mp3,audio/mpeg', 'audio', MAX_CUSTOM_SOUND_SIZE_BYTES, () => { + dispatchToastMessage({ + type: 'error', + message: t('File_exceeds_allowed_size_of_bytes', { size: fileSize(MAX_CUSTOM_SOUND_SIZE_BYTES, { base: 2, standard: 'jedec' }) }), + }); + }); const saveAction = useCallback( // FIXME diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index f46ce0e175b61..3767af59ce87c 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -1,11 +1,13 @@ import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, IconButton } from '@rocket.chat/fuselage'; import { GenericModal, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; import { useSetModal, useToastMessageDispatch, useMethod } from '@rocket.chat/ui-contexts'; +import fileSize from 'filesize'; import type { ReactElement, SyntheticEvent } from 'react'; import { useCallback, useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { validate, createSoundData } from './lib'; +import { MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type EditSoundProps = { @@ -123,7 +125,12 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl ); }, [_id, close, deleteCustomSound, dispatchToastMessage, onChange, setModal, t]); - const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/mp3'); + const [clickUpload] = useSingleFileInput(handleChangeFile, '.mp3,audio/mpeg', 'audio', MAX_CUSTOM_SOUND_SIZE_BYTES, () => { + dispatchToastMessage({ + type: 'error', + message: t('File_exceeds_allowed_size_of_bytes', { size: fileSize(MAX_CUSTOM_SOUND_SIZE_BYTES, { base: 2, standard: 'jedec' }) }), + }); + }); return ( <> diff --git a/apps/meteor/lib/constants.ts b/apps/meteor/lib/constants.ts index 61b66421ce446..4cfa99fc0cdbc 100644 --- a/apps/meteor/lib/constants.ts +++ b/apps/meteor/lib/constants.ts @@ -1 +1,2 @@ export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E'; +export const MAX_CUSTOM_SOUND_SIZE_BYTES = 5242880; diff --git a/apps/meteor/tests/end-to-end/api/custom-sounds.ts b/apps/meteor/tests/end-to-end/api/custom-sounds.ts index b035a5d6fec40..82b030cc3c03f 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -39,7 +39,7 @@ async function uploadCustomSound(binary: string, fileName: string, fileId: strin msg: 'method', id: '2', method: 'uploadCustomSound', - params: [binary, 'audio/wav', { name: fileName, extension: 'wav', newFile: true, _id: fileId }], + params: [binary, 'audio/mpeg', { name: fileName, extension: 'mp3', newFile: true, _id: fileId }], }), }) .expect(200); @@ -70,7 +70,7 @@ describe('[CustomSounds]', () => { before((done) => getCredentials(done)); before(async () => { - const data = readFileSync(path.resolve(__dirname, '../../mocks/files/audio_mock.wav')); + const data = readFileSync(path.resolve(__dirname, '../../mocks/files/audio_mock.mp3')); binary = data.toString('binary'); fileId = await insertOrUpdateSound(fileName); @@ -171,12 +171,12 @@ describe('[CustomSounds]', () => { it('should return success if the the requested exists', (done) => { void request - .get(`/custom-sounds/${fileId}.wav`) + .get(`/custom-sounds/${fileId}.mp3`) .set(credentials) .expect(200) .expect((res) => { expect(res.headers).to.have.property('last-modified'); - expect(res.headers).to.have.property('content-type', 'audio/wav'); + expect(res.headers).to.have.property('content-type', 'audio/mpeg'); expect(res.headers).to.have.property('cache-control', 'public, max-age=0'); expect(res.headers).to.have.property('expires', '-1'); uploadDate = res.headers['last-modified']; @@ -186,7 +186,7 @@ describe('[CustomSounds]', () => { it('should return not modified if the the requested file contains a valid-since equal to the upload date', (done) => { void request - .get(`/custom-sounds/${fileId}.wav`) + .get(`/custom-sounds/${fileId}.mp3`) .set(credentials) .set({ 'if-modified-since': uploadDate, @@ -285,8 +285,8 @@ describe('[CustomSounds]', () => { }); it('should resolve GridFS files only', async () => { - await request.get(`/custom-sounds/${gridFsFileId}.wav`).set(credentials).expect(200); - await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(404); + await request.get(`/custom-sounds/${gridFsFileId}.mp3`).set(credentials).expect(200); + await request.get(`/custom-sounds/${fsFileId}.mp3`).set(credentials).expect(404); }); }); @@ -296,8 +296,8 @@ describe('[CustomSounds]', () => { }); it('should resolve FileSystem files only', async () => { - await request.get(`/custom-sounds/${gridFsFileId}.wav`).set(credentials).expect(404); - await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(200); + await request.get(`/custom-sounds/${gridFsFileId}.mp3`).set(credentials).expect(404); + await request.get(`/custom-sounds/${fsFileId}.mp3`).set(credentials).expect(200); }); }); }); @@ -309,7 +309,7 @@ describe('[CustomSounds]', () => { describe('when file system path is the default one', () => { it('should resolve files', async () => { - await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(200); + await request.get(`/custom-sounds/${fsFileId}.mp3`).set(credentials).expect(200); }); }); @@ -323,7 +323,7 @@ describe('[CustomSounds]', () => { }); it('should NOT resolve files', async () => { - await request.get(`/custom-sounds/${fsFileId}.wav`).set(credentials).expect(404); + await request.get(`/custom-sounds/${fsFileId}.mp3`).set(credentials).expect(404); }); }); }); diff --git a/apps/meteor/tests/mocks/files/audio_mock.mp3 b/apps/meteor/tests/mocks/files/audio_mock.mp3 new file mode 100644 index 0000000000000..246100823a667 Binary files /dev/null and b/apps/meteor/tests/mocks/files/audio_mock.mp3 differ diff --git a/apps/meteor/tests/mocks/files/audio_mock.wav b/apps/meteor/tests/mocks/files/audio_mock.wav deleted file mode 100644 index cec7e00bfd62f..0000000000000 Binary files a/apps/meteor/tests/mocks/files/audio_mock.wav and /dev/null differ