Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forty-geese-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes missing file size and MP3 validation for custom sounds upload
15 changes: 13 additions & 2 deletions apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,18 +19,26 @@ declare module '@rocket.chat/ddp-client' {
}

Meteor.methods<ServerMethods>({
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();
Expand Down
17 changes: 14 additions & 3 deletions apps/meteor/client/hooks/useSingleFileInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>();

Expand Down Expand Up @@ -40,17 +42,26 @@ 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);

return (): void => {
fileInput.removeEventListener('change', handleFiles, false);
};
}, [fileField, fileType, onSetFile]);
}, [fileField, fileType, onSetFile, maxSize, onError]);

const onClick = useEffectEvent(() => ref?.current?.click());
const reset = useEffectEvent(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the only error that can be triggered by the hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment, the only explicit error condition handled by the hook is the maxSize validation.
The native <input type="file"> element does not throw runtime errors on cancel or invalid selection ,it simply returns an empty files list.

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
Expand Down
9 changes: 8 additions & 1 deletion apps/meteor/client/views/admin/customSounds/EditSound.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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 (
<>
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E';
export const MAX_CUSTOM_SOUND_SIZE_BYTES = 5242880;
22 changes: 11 additions & 11 deletions apps/meteor/tests/end-to-end/api/custom-sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'];
Expand All @@ -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,
Expand Down Expand Up @@ -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);
});
});

Expand All @@ -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);
});
});
});
Expand All @@ -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);
});
});

Expand All @@ -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);
});
});
});
Expand Down
Binary file added apps/meteor/tests/mocks/files/audio_mock.mp3
Binary file not shown.
Binary file removed apps/meteor/tests/mocks/files/audio_mock.wav
Binary file not shown.
Loading