From a0e1ac0b16efdeeaeb0af27ad026391780d0c590 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 18:02:51 -0300 Subject: [PATCH 01/29] add update and create custom-sounds endpoints --- .../meteor/app/api/server/v1/custom-sounds.ts | 133 +++++++++++++++++- .../server/lib/insertOrUpdateSound.ts | 75 ++++++++++ .../server/lib/uploadCustomSound.ts | 25 ++++ .../server/methods/insertOrUpdateSound.ts | 72 +--------- .../server/methods/uploadCustomSound.ts | 21 +-- apps/meteor/lib/constants.ts | 1 + 6 files changed, 238 insertions(+), 89 deletions(-) create mode 100644 apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts create mode 100644 apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 149a8a20a79e0..a0eb18948a059 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -4,6 +4,8 @@ import type { PaginatedResult } from '@rocket.chat/rest-typings'; import { isCustomSoundsGetOneProps, isCustomSoundsListProps, + isCustomSoundsCreateProps, + isCustomSoundsUpdateProps, ajv, validateBadRequestErrorResponse, validateNotFoundErrorResponse, @@ -12,9 +14,14 @@ import { } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; +import { MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; +import { SystemLogger } from '../../../../server/lib/logger/system'; +import { insertOrUpdateSound } from '../../../custom-sounds/server/lib/insertOrUpdateSound'; +import { uploadCustomSound } from '../../../custom-sounds/server/lib/uploadCustomSound'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; +import { getUploadFormData } from '../lib/getUploadFormData'; const customSoundsEndpoints = API.v1 .get( @@ -69,7 +76,7 @@ const customSoundsEndpoints = API.v1 const filter = { ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), }; const { cursor, totalCount } = CustomSounds.findPaginated(filter, { @@ -124,6 +131,130 @@ const customSoundsEndpoints = API.v1 return API.v1.success({ sound }); }, + ) + .post( + 'custom-sounds.create', + { + response: { + 200: ajv.compile<{ sound: Pick; success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + sound: { + type: 'object', + properties: { + _id: { + type: 'string', + description: 'The ID of the sound.', + }, + }, + required: ['_id'], + }, + }, + required: ['success', 'sound'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const sound = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsCreateProps, + }, + ); + + const { fields, fileBuffer, mimetype } = sound; + + let _id; + + try { + _id = await insertOrUpdateSound({ + name: fields.name, + extension: 'mp3', + }); + await uploadCustomSound(fileBuffer, mimetype, { _id, name: fields.name, extension: 'mp3' }); + } catch (error) { + SystemLogger.error({ error }); + return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); + } + return API.v1.success({ sound: { _id } }); + }, + ) + .post( + 'custom-sounds.update', + { + response: { + 200: ajv.compile<{ success: boolean }>({ + additionalProperties: false, + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + authRequired: true, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const sound = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + fileOptional: true, + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsUpdateProps, + }, + ); + + const { fields, fileBuffer, mimetype } = sound; + + const soundToUpdate = await CustomSounds.findOneById>(fields._id, { + projection: { _id: 1, name: 1, extension: 1 }, + }); + if (!soundToUpdate) { + return API.v1.failure('Custom Sound not found.'); + } + + try { + await insertOrUpdateSound({ + _id: fields._id, + name: fields.name, + extension: 'mp3', + newFile: Boolean(fields.newFile), + previousName: soundToUpdate.name, + previousExtension: soundToUpdate.extension, + }); + if (fileBuffer) { + await uploadCustomSound(fileBuffer, mimetype, { _id: fields._id, name: fields.name, extension: 'mp3' }); + } + } catch (error) { + SystemLogger.error({ error }); + return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); + } + return API.v1.success({}); + }, ); export type CustomSoundEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts new file mode 100644 index 0000000000000..09ac6d71ed24b --- /dev/null +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -0,0 +1,75 @@ +import { api } from '@rocket.chat/core-services'; +import { CustomSounds } from '@rocket.chat/models'; +import { check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import type { ICustomSoundData } from '../methods/insertOrUpdateSound'; +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise => { + if (!soundData.name?.trim()) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { + method: 'insertOrUpdateSound', + field: 'Name', + }); + } + + // let nameValidation = new RegExp('^[0-9a-zA-Z-_+;.]+$'); + + // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[\s,:><&"'\/\\\(\)]/; + + // silently strip colon; this allows for uploading :soundname: as soundname + soundData.name = soundData.name.replace(/:/g, ''); + + if (nameValidation.test(soundData.name)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${soundData.name} is not a valid name`, { + method: 'insertOrUpdateSound', + input: soundData.name, + field: 'Name', + }); + } + + let matchingResults; + + if (soundData._id) { + check(soundData._id, String); + matchingResults = await CustomSounds.findByNameExceptId(soundData.name, soundData._id).toArray(); + } else { + matchingResults = await CustomSounds.findByName(soundData.name).toArray(); + } + + if (matchingResults.length > 0) { + throw new Meteor.Error('Custom_Sound_Error_Name_Already_In_Use', 'The custom sound name is already in use', { + method: 'insertOrUpdateSound', + }); + } + + if (!soundData._id) { + return ( + await CustomSounds.create({ + name: soundData.name, + extension: soundData.extension, + }) + ).insertedId; + } + + // update sound + if (soundData.newFile) { + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); + } + + if (soundData.name !== soundData.previousName) { + await CustomSounds.setName(soundData._id, soundData.name); + void api.broadcast('notify.updateCustomSound', { + soundData: { + _id: soundData._id, + name: soundData.name, + extension: soundData.extension, + }, + }); + } + + return soundData._id; +}; diff --git a/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts new file mode 100644 index 0000000000000..250717a9449ae --- /dev/null +++ b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts @@ -0,0 +1,25 @@ +import { api } from '@rocket.chat/core-services'; +import type { RequiredField } from '@rocket.chat/core-typings'; + +import { RocketChatFile } from '../../../file/server'; +import type { ICustomSoundData } from '../methods/insertOrUpdateSound'; +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const uploadCustomSound = async ( + buffer: Buffer, + contentType: string, + soundData: RequiredField, +): Promise => { + const rs = RocketChatFile.bufferToStream(buffer); + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.extension}`); + + return new Promise((resolve) => { + const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType); + ws.on('end', () => { + setTimeout(() => api.broadcast('notify.updateCustomSound', { soundData }), 500); + resolve(); + }); + + rs.pipe(ws); + }); +}; diff --git a/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts index 1b922c6b162ed..cf4b935774ce5 100644 --- a/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts @@ -1,11 +1,8 @@ -import { api } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { CustomSounds } from '@rocket.chat/models'; -import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; +import { insertOrUpdateSound } from '../lib/insertOrUpdateSound'; export type ICustomSoundData = { _id?: string; @@ -32,71 +29,6 @@ Meteor.methods({ if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { throw new Meteor.Error('not_authorized'); } - - if (!soundData.name?.trim()) { - throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { - method: 'insertOrUpdateSound', - field: 'Name', - }); - } - - // let nameValidation = new RegExp('^[0-9a-zA-Z-_+;.]+$'); - - // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) - // more practical than allowing specific sets of characters; also allows foreign languages - const nameValidation = /[\s,:><&"'\/\\\(\)]/; - - // silently strip colon; this allows for uploading :soundname: as soundname - soundData.name = soundData.name.replace(/:/g, ''); - - if (nameValidation.test(soundData.name)) { - throw new Meteor.Error('error-input-is-not-a-valid-field', `${soundData.name} is not a valid name`, { - method: 'insertOrUpdateSound', - input: soundData.name, - field: 'Name', - }); - } - - let matchingResults = []; - - if (soundData._id) { - check(soundData._id, String); - matchingResults = await CustomSounds.findByNameExceptId(soundData.name, soundData._id).toArray(); - } else { - matchingResults = await CustomSounds.findByName(soundData.name).toArray(); - } - - if (matchingResults.length > 0) { - throw new Meteor.Error('Custom_Sound_Error_Name_Already_In_Use', 'The custom sound name is already in use', { - method: 'insertOrUpdateSound', - }); - } - - if (!soundData._id) { - return ( - await CustomSounds.create({ - name: soundData.name, - extension: soundData.extension, - }) - ).insertedId; - } - - // update sound - if (soundData.newFile) { - await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); - } - - if (soundData.name !== soundData.previousName) { - await CustomSounds.setName(soundData._id, soundData.name); - void api.broadcast('notify.updateCustomSound', { - soundData: { - _id: soundData._id, - name: soundData.name, - extension: soundData.extension, - }, - }); - } - - return soundData._id; + return insertOrUpdateSound(soundData); }, }); diff --git a/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts b/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts index 64286bb71d86a..3cdadd229f074 100644 --- a/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/uploadCustomSound.ts @@ -1,12 +1,10 @@ -import { api } from '@rocket.chat/core-services'; import type { RequiredField } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import type { ICustomSoundData } from './insertOrUpdateSound'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { RocketChatFile } from '../../../file/server'; -import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; +import { uploadCustomSound } from '../lib/uploadCustomSound'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -20,20 +18,7 @@ Meteor.methods({ if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { throw new Meteor.Error('not_authorized'); } - - const file = Buffer.from(binaryContent, 'binary'); - - 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); - ws.on('end', () => { - setTimeout(() => api.broadcast('notify.updateCustomSound', { soundData }), 500); - resolve(); - }); - - rs.pipe(ws); - }); + const buffer = Buffer.from(binaryContent, 'binary'); + await uploadCustomSound(buffer, contentType, soundData); }, }); 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; From 35be16f9e6886a34c3e0833e6e04808f251cb0f8 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 12 Mar 2026 18:07:48 -0300 Subject: [PATCH 02/29] add custom-sounds validation types for create and update --- packages/rest-typings/src/v1/customSounds.ts | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index 759d995b6620f..4cc91bf441173 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -50,3 +50,43 @@ const CustomSoundsListSchema = { }; export const isCustomSoundsListProps = ajv.compile(CustomSoundsListSchema); + +type CustomSoundsCreate = { _id: ICustomSound['_id'] }; + +const CustomSoundsCreateSchema = { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + }, + }, + required: ['name'], + additionalProperties: false, +}; + +export const isCustomSoundsCreateProps = ajv.compile(CustomSoundsCreateSchema); + +type CustomSoundsUpdate = { _id: ICustomSound['_id'] }; + +const CustomSoundsUpdateSchema = { + type: 'object', + properties: { + _id: { + type: 'string', + minLength: 1, + }, + name: { + type: 'string', + minLength: 1, + }, + newFile: { + type: 'boolean', + } + }, + required: ['_id', 'name'], + additionalProperties: false, +}; + +export const isCustomSoundsUpdateProps = ajv.compile(CustomSoundsUpdateSchema); + From 9c46b021aaec20f231b6309f7f65eacc51491174 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 18:23:07 -0300 Subject: [PATCH 03/29] adapt client to new create custom sound endpoint --- .../meteor/app/api/server/v1/custom-sounds.ts | 8 +- .../meteor/client/hooks/useSingleFileInput.ts | 17 +++- .../admin/customSounds/AddCustomSound.tsx | 92 +++++++------------ .../admin/customSounds/CustomSoundsPage.tsx | 2 +- .../views/admin/customSounds/EditSound.tsx | 9 +- .../client/views/admin/customSounds/lib.ts | 12 +-- packages/rest-typings/src/v1/customSounds.ts | 27 ++++-- 7 files changed, 81 insertions(+), 86 deletions(-) diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index a0eb18948a059..6d4fd30888ede 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -183,9 +183,9 @@ const customSoundsEndpoints = API.v1 try { _id = await insertOrUpdateSound({ name: fields.name, - extension: 'mp3', + extension: fields.extension, }); - await uploadCustomSound(fileBuffer, mimetype, { _id, name: fields.name, extension: 'mp3' }); + await uploadCustomSound(fileBuffer, mimetype, { _id, name: fields.name, extension: fields.extension }); } catch (error) { SystemLogger.error({ error }); return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); @@ -241,13 +241,13 @@ const customSoundsEndpoints = API.v1 await insertOrUpdateSound({ _id: fields._id, name: fields.name, - extension: 'mp3', + extension: fields.extension, newFile: Boolean(fields.newFile), previousName: soundToUpdate.name, previousExtension: soundToUpdate.extension, }); if (fileBuffer) { - await uploadCustomSound(fileBuffer, mimetype, { _id: fields._id, name: fields.name, extension: 'mp3' }); + await uploadCustomSound(fileBuffer, mimetype, { _id: fields._id, name: fields.name, extension: fields.extension }); } } catch (error) { SystemLogger.error({ error }); 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..ca1044934e9b2 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -1,89 +1,65 @@ 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 { useToastMessageDispatch } 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 { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type AddCustomSoundProps = { - goToNew: (_id: string) => () => void; + _goToNew: (_id: string) => () => void; close: () => void; onChange: () => void; }; -const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundProps): ReactElement => { +const AddCustomSound = ({ _goToNew, close, onChange, ...props }: AddCustomSoundProps): ReactElement => { const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const [name, setName] = useState(''); - const [sound, setSound] = useState<{ name: string }>(); + const [sound, setSound] = useState(); - const uploadCustomSound = useMethod('uploadCustomSound'); - const insertOrUpdateSound = useMethod('insertOrUpdateSound'); + const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/custom-sounds.create', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') }); + onChange(); + close(); + }, + }); const handleChangeFile = useCallback((soundFile: File) => { setSound(soundFile); }, []); - const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/mp3'); - - const saveAction = useCallback( - // FIXME - async (name: string, soundFile: any) => { - const soundData = createSoundData(soundFile, name); - const validation = validate(soundData, soundFile) as Array[0]>; - - validation.forEach((invalidFieldName) => { - throw new Error(t('Required_field', { field: t(invalidFieldName) })); - }); - - try { - const soundId = await insertOrUpdateSound(soundData); - - if (!soundId) { - return undefined; - } + const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/*', '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' }) }), + }); + }); - dispatchToastMessage({ type: 'success', message: t('Uploading_file') }); + const handleSave = useCallback(async () => { + const soundData = createSoundData(sound, name); + const validation = validate(soundData, sound) as Array[0]>; - const reader = new FileReader(); - reader.readAsBinaryString(soundFile); - reader.onloadend = (): void => { - try { - uploadCustomSound(reader.result as string, soundFile.type, { - ...soundData, - _id: soundId, - random: Math.round(Math.random() * 1000), - }); - dispatchToastMessage({ type: 'success', message: t('File_uploaded') }); - } catch (error) { - (typeof error === 'string' || error instanceof Error) && dispatchToastMessage({ type: 'error', message: error }); - } - }; - close(); - return soundId; - } catch (error) { - (typeof error === 'string' || error instanceof Error) && dispatchToastMessage({ type: 'error', message: error }); - } - }, - [dispatchToastMessage, insertOrUpdateSound, t, uploadCustomSound], - ); + validation.forEach((invalidFieldName) => { + dispatchToastMessage({ type: 'error', message: t('Required_field', { field: t(invalidFieldName) }) }); + throw new Error(t('Required_field', { field: t(invalidFieldName) })); + }); - const handleSave = useCallback(async () => { - try { - const result = await saveAction(name, sound); - if (result) { - dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') }); - } - result && goToNew(result); - onChange(); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + const formData = new FormData(); + if (sound) { + formData.append('sound', sound); } - }, [dispatchToastMessage, goToNew, name, onChange, saveAction, sound, t]); + formData.append('name', name); + formData.append('extension', soundData.extension); + await saveAction(formData); + }, [sound, name, saveAction, t, dispatchToastMessage]); return ( <> diff --git a/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx b/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx index 1959756505ace..e500f5fcccb6d 100644 --- a/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx +++ b/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx @@ -68,7 +68,7 @@ const CustomSoundsPage = () => { {context === 'edit' && } - {context === 'new' && } + {context === 'new' && } )} diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index f46ce0e175b61..db7ad1fa640e0 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, 'audio/*', '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/client/views/admin/customSounds/lib.ts b/apps/meteor/client/views/admin/customSounds/lib.ts index c447bad77bdeb..1a4d3f2782f96 100644 --- a/apps/meteor/client/views/admin/customSounds/lib.ts +++ b/apps/meteor/client/views/admin/customSounds/lib.ts @@ -1,13 +1,7 @@ import type { ICustomSoundData } from '../../../../app/custom-sounds/server/methods/insertOrUpdateSound'; -type ICustomSoundFile = { - name: string; - type: string; - extension?: string; -}; - // Here previousData will define if it is an update or a new entry -export function validate(soundData: ICustomSoundData, soundFile?: ICustomSoundFile): ('Name' | 'Sound File' | 'FileType')[] { +export function validate(soundData: ICustomSoundData, soundFile?: File): ('Name' | 'Sound File' | 'FileType')[] { const errors: ('Name' | 'Sound File' | 'FileType')[] = []; if (!soundData.name) { @@ -20,7 +14,7 @@ export function validate(soundData: ICustomSoundData, soundFile?: ICustomSoundFi if (soundFile) { if (!soundData.previousSound || soundData.previousSound !== soundFile) { - if (!/audio\/mp3/.test(soundFile.type) && !/audio\/mpeg/.test(soundFile.type) && !/audio\/x-mpeg/.test(soundFile.type)) { + if (!soundFile.type.startsWith('audio/')) { errors.push('FileType'); } } @@ -30,7 +24,7 @@ export function validate(soundData: ICustomSoundData, soundFile?: ICustomSoundFi } export const createSoundData = ( - soundFile: ICustomSoundFile, + soundFile: File | undefined, name: string, previousData?: { _id: string; diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index 4cc91bf441173..b1bdfe1f19bb3 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -51,23 +51,27 @@ const CustomSoundsListSchema = { export const isCustomSoundsListProps = ajv.compile(CustomSoundsListSchema); -type CustomSoundsCreate = { _id: ICustomSound['_id'] }; +type CustomSoundsCreate = { _id: ICustomSound['_id']; extension: ICustomSound['extension'] }; const CustomSoundsCreateSchema = { type: 'object', properties: { - name: { + name: { + type: 'string', + minLength: 1, + }, + extension: { type: 'string', minLength: 1, }, }, - required: ['name'], + required: ['name', 'extension'], additionalProperties: false, }; export const isCustomSoundsCreateProps = ajv.compile(CustomSoundsCreateSchema); -type CustomSoundsUpdate = { _id: ICustomSound['_id'] }; +type CustomSoundsUpdate = { _id: ICustomSound['_id']; extension: ICustomSound['extension'] }; const CustomSoundsUpdateSchema = { type: 'object', @@ -76,17 +80,20 @@ const CustomSoundsUpdateSchema = { type: 'string', minLength: 1, }, - name: { + name: { + type: 'string', + minLength: 1, + }, + extension: { type: 'string', minLength: 1, }, - newFile: { - type: 'boolean', - } + newFile: { + type: 'boolean', + }, }, - required: ['_id', 'name'], + required: ['_id', 'name', 'extension'], additionalProperties: false, }; export const isCustomSoundsUpdateProps = ajv.compile(CustomSoundsUpdateSchema); - From 7596a73e2bb511d2e9367014cb8b94a525814a13 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 13 Mar 2026 19:48:27 -0300 Subject: [PATCH 04/29] EditSound update wip --- .../app/api/server/lib/getUploadFormData.ts | 2 +- .../views/admin/customSounds/EditSound.tsx | 104 +++++++----------- .../client/views/admin/customSounds/lib.ts | 4 +- packages/rest-typings/src/v1/customSounds.ts | 6 +- 4 files changed, 49 insertions(+), 67 deletions(-) diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index 5841a5b58c32b..c0c232ecf3f68 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.ts @@ -102,7 +102,7 @@ export async function getUploadFormData< return reject(new MeteorError('No file uploaded')); } if (options.validate !== undefined && !options.validate(fields)) { - return reject(new MeteorError(`Invalid fields ${options.validate.errors?.join(', ')}`)); + return reject(new MeteorError(`Invalid fields ${options.validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join(', ')}`)); } return resolve(uploadedFile); } diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index db7ad1fa640e0..259c9fe6963c1 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { validate, createSoundData } from './lib'; import { MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; +import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type EditSoundProps = { @@ -16,7 +17,7 @@ type EditSoundProps = { data: { _id: string; name: string; - extension?: string; + extension: string; }; }; @@ -29,78 +30,57 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl const previousSound = useMemo(() => data || {}, [data]); const [name, setName] = useState(() => data?.name ?? ''); - const [sound, setSound] = useState< - | { - _id: string; - name: string; - extension?: string; - } - | File - >(() => data); + const [file, setFile] = useState(); useEffect(() => { setName(previousName || ''); - setSound(previousSound || ''); - }, [previousName, previousSound, _id]); + }, [previousName]); const deleteCustomSound = useMethod('deleteCustomSound'); - const uploadCustomSound = useMethod('uploadCustomSound'); - const insertOrUpdateSound = useMethod('insertOrUpdateSound'); + + const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/custom-sounds.update', { + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') }); + close(); + }, + }); const handleChangeFile = useCallback((soundFile: File) => { - setSound(soundFile); + setFile(soundFile); }, []); - const hasUnsavedChanges = useMemo(() => previousName !== name || previousSound !== sound, [name, previousName, previousSound, sound]); - - const saveAction = useCallback( - // FIXME - async (sound: any) => { - const soundData = createSoundData(sound, name, { previousName, previousSound, _id, extension: sound.extension }); - const validation = validate(soundData, sound); - if (validation.length === 0) { - let soundId: string; - try { - soundId = await insertOrUpdateSound(soundData); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - return; - } - - soundData._id = soundId; - soundData.random = Math.round(Math.random() * 1000); - - if (sound && sound !== previousSound) { - dispatchToastMessage({ type: 'success', message: t('Uploading_file') }); - - const reader = new FileReader(); - reader.readAsBinaryString(sound); - reader.onloadend = (): void => { - try { - uploadCustomSound(reader.result as string, sound.type, { ...soundData, _id: soundId }); - return dispatchToastMessage({ type: 'success', message: t('File_uploaded') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; - } - close(); - } - - validation.forEach((invalidFieldName) => - dispatchToastMessage({ - type: 'error', - message: t('Required_field', { field: t(invalidFieldName) }), - }), - ); - }, - [_id, dispatchToastMessage, insertOrUpdateSound, name, previousName, previousSound, t, uploadCustomSound], - ); + const hasUnsavedChanges = useMemo(() => previousName !== name || !!file, [name, previousName, file]); const handleSave = useCallback(async () => { - await saveAction(sound); + const soundData = createSoundData(file, name, { + previousName, + previousSound, + _id, + extension: data.extension, + }); + const validation = validate(soundData, file); + validation.forEach((invalidFieldName) => { + dispatchToastMessage({ type: 'error', message: t('Required_field', { field: t(invalidFieldName) }) }); + throw new Error(t('Required_field', { field: t(invalidFieldName) })); + }); + + const formData = new FormData(); + + formData.append('_id', _id); + formData.append('name', name); + + if (file) { + formData.append('extension', soundData.extension); + formData.append('newFile', 'true'); + formData.append('sound', file); + } else { + formData.append('extension', data.extension); + formData.append('newFile', 'false'); + } + + await saveAction(formData); onChange(); - }, [saveAction, sound, onChange]); + }, [_id, dispatchToastMessage, name, previousName, previousSound, saveAction, file, t, onChange, data.extension]); const handleDeleteButtonClick = useCallback(() => { const handleDelete = async (): Promise => { @@ -150,7 +130,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl - {sound?.name || 'none'} + {data.name || 'none'} diff --git a/apps/meteor/client/views/admin/customSounds/lib.ts b/apps/meteor/client/views/admin/customSounds/lib.ts index 1a4d3f2782f96..5a64a7e6077b2 100644 --- a/apps/meteor/client/views/admin/customSounds/lib.ts +++ b/apps/meteor/client/views/admin/customSounds/lib.ts @@ -38,7 +38,7 @@ export const createSoundData = ( if (!previousData) { return { name: name.trim(), - extension: soundFile?.name.split('.').pop() || '', + extension: soundFile?.name?.split('.').pop() || '', newFile: true, }; } @@ -46,7 +46,7 @@ export const createSoundData = ( return { _id: previousData._id, name, - extension: soundFile?.name.split('.').pop() || '', + extension: soundFile?.name?.split('.').pop() || '', previousName: previousData.previousName, previousExtension: previousData.previousSound?.extension, previousSound: previousData.previousSound, diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index b1bdfe1f19bb3..6cb2b96df435c 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -51,7 +51,7 @@ const CustomSoundsListSchema = { export const isCustomSoundsListProps = ajv.compile(CustomSoundsListSchema); -type CustomSoundsCreate = { _id: ICustomSound['_id']; extension: ICustomSound['extension'] }; +type CustomSoundsCreate = Pick; const CustomSoundsCreateSchema = { type: 'object', @@ -71,7 +71,9 @@ const CustomSoundsCreateSchema = { export const isCustomSoundsCreateProps = ajv.compile(CustomSoundsCreateSchema); -type CustomSoundsUpdate = { _id: ICustomSound['_id']; extension: ICustomSound['extension'] }; +type CustomSoundsUpdate = Pick & { + newFile?: boolean; +}; const CustomSoundsUpdateSchema = { type: 'object', From 9fd5cd81b1446df185c1d395f6c2dd95b3fd2da2 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 16 Mar 2026 22:27:39 -0300 Subject: [PATCH 05/29] fix linter error --- apps/meteor/lib/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/lib/constants.ts b/apps/meteor/lib/constants.ts index 4c51a0329b3fe..32ba432877594 100644 --- a/apps/meteor/lib/constants.ts +++ b/apps/meteor/lib/constants.ts @@ -1,3 +1,3 @@ export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E'; export const MAX_CUSTOM_SOUND_SIZE_BYTES = 5242880; -export const MAX_MULTIPLE_UPLOADED_FILES = 10; \ No newline at end of file +export const MAX_MULTIPLE_UPLOADED_FILES = 10; From 8478c7a001f261df2d622450c0e3a09c8a918510 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 11:30:43 -0300 Subject: [PATCH 06/29] fix update and add setExtension method for custom sounds --- apps/meteor/app/api/server/v1/custom-sounds.ts | 7 +++++-- .../custom-sounds/server/lib/insertOrUpdateSound.ts | 11 +++++++++++ .../app/custom-sounds/server/lib/uploadCustomSound.ts | 2 +- .../client/views/admin/customSounds/EditSound.tsx | 6 +----- .../model-typings/src/models/ICustomSoundsModel.ts | 1 + packages/models/src/models/CustomSounds.ts | 10 ++++++++++ packages/rest-typings/src/v1/customSounds.ts | 9 ++------- 7 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 6d4fd30888ede..4e38f7126b4e5 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -242,12 +242,15 @@ const customSoundsEndpoints = API.v1 _id: fields._id, name: fields.name, extension: fields.extension, - newFile: Boolean(fields.newFile), previousName: soundToUpdate.name, previousExtension: soundToUpdate.extension, }); if (fileBuffer) { - await uploadCustomSound(fileBuffer, mimetype, { _id: fields._id, name: fields.name, extension: fields.extension }); + await uploadCustomSound(fileBuffer, mimetype, { + _id: fields._id, + previousExtension: soundToUpdate.extension, + extension: fields.extension, + }); } } catch (error) { SystemLogger.error({ error }); diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts index 09ac6d71ed24b..e16941e1f65fd 100644 --- a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -71,5 +71,16 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< }); } + if (soundData.extension !== soundData.previousExtension) { + await CustomSounds.setExtension(soundData._id, soundData.extension); + void api.broadcast('notify.updateCustomSound', { + soundData: { + _id: soundData._id, + name: soundData.name, + extension: soundData.extension, + }, + }); + } + return soundData._id; }; diff --git a/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts index 250717a9449ae..99d92c63a3dfd 100644 --- a/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts @@ -11,7 +11,7 @@ export const uploadCustomSound = async ( soundData: RequiredField, ): Promise => { const rs = RocketChatFile.bufferToStream(buffer); - await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.extension}`); + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); return new Promise((resolve) => { const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType); diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 259c9fe6963c1..91d637c709cc2 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -70,12 +70,8 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl formData.append('name', name); if (file) { - formData.append('extension', soundData.extension); - formData.append('newFile', 'true'); formData.append('sound', file); - } else { - formData.append('extension', data.extension); - formData.append('newFile', 'false'); + formData.append('extension', soundData.extension); } await saveAction(formData); diff --git a/packages/model-typings/src/models/ICustomSoundsModel.ts b/packages/model-typings/src/models/ICustomSoundsModel.ts index 1709f4efe4a98..2971ec6ceb2b4 100644 --- a/packages/model-typings/src/models/ICustomSoundsModel.ts +++ b/packages/model-typings/src/models/ICustomSoundsModel.ts @@ -8,4 +8,5 @@ export interface ICustomSoundsModel extends IBaseModel { findByNameExceptId(name: string, except: string, options?: FindOptions): FindCursor; setName(_id: string, name: string): Promise; create(data: Omit): Promise>>; + setExtension(_id: string, extension: string): Promise; } diff --git a/packages/models/src/models/CustomSounds.ts b/packages/models/src/models/CustomSounds.ts index 07c5b9c594a13..eb4f3474bfb37 100644 --- a/packages/models/src/models/CustomSounds.ts +++ b/packages/models/src/models/CustomSounds.ts @@ -46,4 +46,14 @@ export class CustomSoundsRaw extends BaseRaw implements ICustomSou create(data: Omit): Promise>> { return this.insertOne(data); } + + setExtension(_id: string, extension: string): Promise { + const update = { + $set: { + extension, + }, + }; + + return this.updateOne({ _id }, update); + } } diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index 6cb2b96df435c..9f007b1327e7a 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -71,9 +71,7 @@ const CustomSoundsCreateSchema = { export const isCustomSoundsCreateProps = ajv.compile(CustomSoundsCreateSchema); -type CustomSoundsUpdate = Pick & { - newFile?: boolean; -}; +type CustomSoundsUpdate = Pick; const CustomSoundsUpdateSchema = { type: 'object', @@ -90,11 +88,8 @@ const CustomSoundsUpdateSchema = { type: 'string', minLength: 1, }, - newFile: { - type: 'boolean', - }, }, - required: ['_id', 'name', 'extension'], + required: ['_id', 'name'], additionalProperties: false, }; From 34752b135c464c5ec0339c084752633739198562 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Tue, 17 Mar 2026 16:13:48 -0300 Subject: [PATCH 07/29] address ai comments --- .../meteor/app/api/server/v1/custom-sounds.ts | 31 +++++++++++++------ .../admin/customSounds/AddCustomSound.tsx | 20 +++++++----- .../views/admin/customSounds/EditSound.tsx | 22 ++++++++----- .../client/views/admin/customSounds/lib.ts | 23 +++++++++++--- apps/meteor/lib/constants.ts | 3 +- packages/rest-typings/src/v1/customSounds.ts | 4 ++- 6 files changed, 71 insertions(+), 32 deletions(-) diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 4e38f7126b4e5..2f03986b226b9 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -14,7 +14,7 @@ import { } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import { MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; +import { MAX_CUSTOM_SOUND_SIZE_BYTES, CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../../lib/constants'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { insertOrUpdateSound } from '../../../custom-sounds/server/lib/insertOrUpdateSound'; import { uploadCustomSound } from '../../../custom-sounds/server/lib/uploadCustomSound'; @@ -76,7 +76,7 @@ const customSoundsEndpoints = API.v1 const filter = { ...query, - ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), + ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), }; const { cursor, totalCount } = CustomSounds.findPaginated(filter, { @@ -178,6 +178,10 @@ const customSoundsEndpoints = API.v1 const { fields, fileBuffer, mimetype } = sound; + if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(mimetype)) { + return API.v1.failure('MIME type not allowed'); + } + let _id; try { @@ -230,6 +234,10 @@ const customSoundsEndpoints = API.v1 const { fields, fileBuffer, mimetype } = sound; + if (fileBuffer && !CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(mimetype)) { + return API.v1.failure('MIME type not allowed'); + } + const soundToUpdate = await CustomSounds.findOneById>(fields._id, { projection: { _id: 1, name: 1, extension: 1 }, }); @@ -237,21 +245,24 @@ const customSoundsEndpoints = API.v1 return API.v1.failure('Custom Sound not found.'); } + const nextExtension = fileBuffer ? fields.extension : soundToUpdate.extension; + try { - await insertOrUpdateSound({ - _id: fields._id, - name: fields.name, - extension: fields.extension, - previousName: soundToUpdate.name, - previousExtension: soundToUpdate.extension, - }); if (fileBuffer) { await uploadCustomSound(fileBuffer, mimetype, { _id: fields._id, + name: fields.name, previousExtension: soundToUpdate.extension, - extension: fields.extension, + extension: nextExtension, }); } + await insertOrUpdateSound({ + _id: fields._id, + name: fields.name, + extension: nextExtension, + previousName: soundToUpdate.name, + previousExtension: soundToUpdate.extension, + }); } catch (error) { SystemLogger.error({ error }); return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index ca1044934e9b2..43e31ea67e2e6 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -7,7 +7,7 @@ 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 { CUSTOM_SOUND_ALLOWED_MIME_TYPES, MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; @@ -36,12 +36,18 @@ const AddCustomSound = ({ _goToNew, close, onChange, ...props }: AddCustomSoundP setSound(soundFile); }, []); - const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/*', '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 [clickUpload] = useSingleFileInput( + handleChangeFile, + CUSTOM_SOUND_ALLOWED_MIME_TYPES.join(','), + '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 handleSave = useCallback(async () => { const soundData = createSoundData(sound, name); diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 91d637c709cc2..e1a38ff93f421 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -7,7 +7,7 @@ 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 { CUSTOM_SOUND_ALLOWED_MIME_TYPES, MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; @@ -101,12 +101,18 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl ); }, [_id, close, deleteCustomSound, dispatchToastMessage, onChange, setModal, t]); - const [clickUpload] = useSingleFileInput(handleChangeFile, 'audio/*', '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 [clickUpload] = useSingleFileInput( + handleChangeFile, + CUSTOM_SOUND_ALLOWED_MIME_TYPES.join(','), + '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 ( <> @@ -126,7 +132,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl - {data.name || 'none'} + {file?.name || `${data.name}.${data.extension}` || t('None')} diff --git a/apps/meteor/client/views/admin/customSounds/lib.ts b/apps/meteor/client/views/admin/customSounds/lib.ts index 5a64a7e6077b2..1a6480a7cae55 100644 --- a/apps/meteor/client/views/admin/customSounds/lib.ts +++ b/apps/meteor/client/views/admin/customSounds/lib.ts @@ -1,4 +1,19 @@ import type { ICustomSoundData } from '../../../../app/custom-sounds/server/methods/insertOrUpdateSound'; +import { CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../../lib/constants'; + +const getExtension = (file?: File): string => { + if (!file?.name) { + return ''; + } + + const dotIndex = file.name.lastIndexOf('.'); + + if (dotIndex <= 0 || dotIndex === file.name.length - 1) { + return ''; + } + + return file.name.slice(dotIndex + 1).toLowerCase(); +}; // Here previousData will define if it is an update or a new entry export function validate(soundData: ICustomSoundData, soundFile?: File): ('Name' | 'Sound File' | 'FileType')[] { @@ -14,7 +29,7 @@ export function validate(soundData: ICustomSoundData, soundFile?: File): ('Name' if (soundFile) { if (!soundData.previousSound || soundData.previousSound !== soundFile) { - if (!soundFile.type.startsWith('audio/')) { + if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(soundFile.type)) { errors.push('FileType'); } } @@ -38,18 +53,16 @@ export const createSoundData = ( if (!previousData) { return { name: name.trim(), - extension: soundFile?.name?.split('.').pop() || '', - newFile: true, + extension: getExtension(soundFile), }; } return { _id: previousData._id, name, - extension: soundFile?.name?.split('.').pop() || '', + extension: getExtension(soundFile), previousName: previousData.previousName, previousExtension: previousData.previousSound?.extension, previousSound: previousData.previousSound, - newFile: false, }; }; diff --git a/apps/meteor/lib/constants.ts b/apps/meteor/lib/constants.ts index 32ba432877594..6d258a8876d36 100644 --- a/apps/meteor/lib/constants.ts +++ b/apps/meteor/lib/constants.ts @@ -1,3 +1,4 @@ export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E'; -export const MAX_CUSTOM_SOUND_SIZE_BYTES = 5242880; export const MAX_MULTIPLE_UPLOADED_FILES = 10; +export const MAX_CUSTOM_SOUND_SIZE_BYTES = 5242880; +export const CUSTOM_SOUND_ALLOWED_MIME_TYPES = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav']; diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index 9f007b1327e7a..608b4bd377f28 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -71,7 +71,9 @@ const CustomSoundsCreateSchema = { export const isCustomSoundsCreateProps = ajv.compile(CustomSoundsCreateSchema); -type CustomSoundsUpdate = Pick; +type CustomSoundsUpdate = Pick & { + extension?: ICustomSound['extension']; +}; const CustomSoundsUpdateSchema = { type: 'object', From d1c328e1859dfdf983802620c46300aba0729312 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 18 Mar 2026 21:34:19 -0300 Subject: [PATCH 08/29] fix goToNew --- .../client/hooks/useEndpointUploadMutation.ts | 16 +++++++++++++--- .../views/admin/customSounds/AddCustomSound.tsx | 8 ++++---- .../admin/customSounds/CustomSoundsPage.tsx | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/meteor/client/hooks/useEndpointUploadMutation.ts b/apps/meteor/client/hooks/useEndpointUploadMutation.ts index 82b6a5f91a5c8..cf9d09e129d0b 100644 --- a/apps/meteor/client/hooks/useEndpointUploadMutation.ts +++ b/apps/meteor/client/hooks/useEndpointUploadMutation.ts @@ -2,14 +2,23 @@ import type { PathFor, PathPattern } from '@rocket.chat/rest-typings'; import { useToastMessageDispatch, useUpload } from '@rocket.chat/ui-contexts'; import { useMutation, type UseMutationOptions } from '@tanstack/react-query'; -type UseEndpointUploadOptions = Omit, 'mutationFn'>; +interface IUploadResult { + success: boolean; + status?: string; + [key: string]: any; +} -export const useEndpointUploadMutation = (endpoint: TPathPattern, options?: UseEndpointUploadOptions) => { +type UseEndpointUploadOptions = Omit, 'mutationFn'>; + +export const useEndpointUploadMutation = ( + endpoint: TPathPattern, + options?: UseEndpointUploadOptions, +) => { const sendData = useUpload(endpoint as PathFor<'POST'>); const dispatchToastMessage = useToastMessageDispatch(); return useMutation({ - mutationFn: async (formData: FormData) => { + mutationFn: async (formData: FormData): Promise => { const data = sendData(formData); const promise = data instanceof Promise ? data : data.promise; const result = await promise; @@ -17,6 +26,7 @@ export const useEndpointUploadMutation = (endp if (!result.success) { throw new Error(String(result.status)); } + return result as TData; }, onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index 43e31ea67e2e6..df6adc64749e1 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -12,12 +12,12 @@ import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutat import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type AddCustomSoundProps = { - _goToNew: (_id: string) => () => void; + goToNew: (_id: string) => () => void; close: () => void; onChange: () => void; }; -const AddCustomSound = ({ _goToNew, close, onChange, ...props }: AddCustomSoundProps): ReactElement => { +const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundProps): ReactElement => { const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); @@ -25,10 +25,10 @@ const AddCustomSound = ({ _goToNew, close, onChange, ...props }: AddCustomSoundP const [sound, setSound] = useState(); const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/custom-sounds.create', { - onSuccess: () => { + onSuccess: ({ sound }) => { dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') }); onChange(); - close(); + goToNew(sound._id)(); }, }); diff --git a/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx b/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx index e500f5fcccb6d..1959756505ace 100644 --- a/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx +++ b/apps/meteor/client/views/admin/customSounds/CustomSoundsPage.tsx @@ -68,7 +68,7 @@ const CustomSoundsPage = () => { {context === 'edit' && } - {context === 'new' && } + {context === 'new' && } )} From 8be29384752561621951a511f24c8f4b0fc61e43 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 19:28:22 -0300 Subject: [PATCH 09/29] add changeset --- .changeset/mighty-icons-kiss.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/mighty-icons-kiss.md diff --git a/.changeset/mighty-icons-kiss.md b/.changeset/mighty-icons-kiss.md new file mode 100644 index 0000000000000..6ccb3ae6f12da --- /dev/null +++ b/.changeset/mighty-icons-kiss.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Adds new API endpoints `custom-sounds.create` and `custom-sounds.update` to manage custom sounds with strict file validation for size and specific MIME types to ensure system compatibility. From b7e231a18ad2bd86c6fecd101ef4d0e52b0eaa28 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Thu, 19 Mar 2026 20:58:23 -0300 Subject: [PATCH 10/29] address ai comments --- .../app/api/server/lib/getUploadFormData.ts | 4 +++- .../server/lib/insertOrUpdateSound.ts | 8 ++++---- .../views/admin/customSounds/AddCustomSound.tsx | 14 +++++++++----- .../views/admin/customSounds/EditSound.tsx | 16 +++++++++------- .../client/views/admin/customSounds/lib.ts | 2 +- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index c0c232ecf3f68..12d91c5c4ac91 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.ts @@ -102,7 +102,9 @@ export async function getUploadFormData< return reject(new MeteorError('No file uploaded')); } if (options.validate !== undefined && !options.validate(fields)) { - return reject(new MeteorError(`Invalid fields ${options.validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join(', ')}`)); + return reject( + new MeteorError(`Invalid fields ${(options.validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join(', ')}`), + ); } return resolve(uploadedFile); } diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts index e16941e1f65fd..0bc56fd4c3150 100644 --- a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -7,7 +7,10 @@ import type { ICustomSoundData } from '../methods/insertOrUpdateSound'; import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise => { - if (!soundData.name?.trim()) { + // silently strip colon; this allows for uploading :soundname: as soundname + soundData.name = (soundData.name || '').replace(/:/g, ''); + + if (!soundData.name.trim()) { throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { method: 'insertOrUpdateSound', field: 'Name', @@ -20,9 +23,6 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< // more practical than allowing specific sets of characters; also allows foreign languages const nameValidation = /[\s,:><&"'\/\\\(\)]/; - // silently strip colon; this allows for uploading :soundname: as soundname - soundData.name = soundData.name.replace(/:/g, ''); - if (nameValidation.test(soundData.name)) { throw new Meteor.Error('error-input-is-not-a-valid-field', `${soundData.name} is not a valid name`, { method: 'insertOrUpdateSound', diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index df6adc64749e1..0c63b9beaf89c 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -51,12 +51,16 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr const handleSave = useCallback(async () => { const soundData = createSoundData(sound, name); - const validation = validate(soundData, sound) as Array[0]>; - validation.forEach((invalidFieldName) => { - dispatchToastMessage({ type: 'error', message: t('Required_field', { field: t(invalidFieldName) }) }); - throw new Error(t('Required_field', { field: t(invalidFieldName) })); - }); + const validation = validate(soundData, sound) as Array[0]>; + if (validation.length > 0) { + const firstInvalidField = validation[0]; + dispatchToastMessage({ + type: 'error', + message: t('Required_field', { field: t(firstInvalidField) }), + }); + return; + } const formData = new FormData(); if (sound) { diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index e1a38ff93f421..3318bfe51636d 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -58,22 +58,24 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl _id, extension: data.extension, }); + const validation = validate(soundData, file); - validation.forEach((invalidFieldName) => { - dispatchToastMessage({ type: 'error', message: t('Required_field', { field: t(invalidFieldName) }) }); - throw new Error(t('Required_field', { field: t(invalidFieldName) })); - }); + if (validation.length > 0) { + const firstInvalidField = validation[0]; + dispatchToastMessage({ + type: 'error', + message: t('Required_field', { field: t(firstInvalidField) }), + }); + return; + } const formData = new FormData(); - formData.append('_id', _id); formData.append('name', name); - if (file) { formData.append('sound', file); formData.append('extension', soundData.extension); } - await saveAction(formData); onChange(); }, [_id, dispatchToastMessage, name, previousName, previousSound, saveAction, file, t, onChange, data.extension]); diff --git a/apps/meteor/client/views/admin/customSounds/lib.ts b/apps/meteor/client/views/admin/customSounds/lib.ts index 1a6480a7cae55..e612c99bfcf13 100644 --- a/apps/meteor/client/views/admin/customSounds/lib.ts +++ b/apps/meteor/client/views/admin/customSounds/lib.ts @@ -59,7 +59,7 @@ export const createSoundData = ( return { _id: previousData._id, - name, + name: name.trim(), extension: getExtension(soundFile), previousName: previousData.previousName, previousExtension: previousData.previousSound?.extension, From eb7fedf3774b9b7239a78f9352fc2434d2e8f90e Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 12:57:28 -0300 Subject: [PATCH 11/29] destructure properties directly on function call --- apps/meteor/app/api/server/v1/custom-sounds.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 2f03986b226b9..5750efb48b431 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -165,7 +165,7 @@ const customSoundsEndpoints = API.v1 permissionsRequired: ['manage-sounds'], }, async function action() { - const sound = await getUploadFormData( + const { fields, fileBuffer, mimetype } = await getUploadFormData( { request: this.request, }, @@ -176,8 +176,6 @@ const customSoundsEndpoints = API.v1 }, ); - const { fields, fileBuffer, mimetype } = sound; - if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(mimetype)) { return API.v1.failure('MIME type not allowed'); } @@ -220,7 +218,7 @@ const customSoundsEndpoints = API.v1 permissionsRequired: ['manage-sounds'], }, async function action() { - const sound = await getUploadFormData( + const { fields, fileBuffer, mimetype } = await getUploadFormData( { request: this.request, }, @@ -232,8 +230,6 @@ const customSoundsEndpoints = API.v1 }, ); - const { fields, fileBuffer, mimetype } = sound; - if (fileBuffer && !CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(mimetype)) { return API.v1.failure('MIME type not allowed'); } From 5035b2cd193c1c8b00ed06a238a85838ce219b20 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 12:59:15 -0300 Subject: [PATCH 12/29] remove comments --- .../meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts index 0bc56fd4c3150..d5bd218403f6e 100644 --- a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -17,8 +17,6 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< }); } - // let nameValidation = new RegExp('^[0-9a-zA-Z-_+;.]+$'); - // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) // more practical than allowing specific sets of characters; also allows foreign languages const nameValidation = /[\s,:><&"'\/\\\(\)]/; @@ -55,7 +53,6 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< ).insertedId; } - // update sound if (soundData.newFile) { await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); } From ba3ab6b85d02fd956721b7242263b3d23a5a917b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 13:04:18 -0300 Subject: [PATCH 13/29] move onChange call out of handleSave function --- apps/meteor/client/views/admin/customSounds/EditSound.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 3318bfe51636d..f553cf1fa4291 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -41,6 +41,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/custom-sounds.update', { onSuccess: () => { dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') }); + onChange(); close(); }, }); @@ -77,8 +78,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl formData.append('extension', soundData.extension); } await saveAction(formData); - onChange(); - }, [_id, dispatchToastMessage, name, previousName, previousSound, saveAction, file, t, onChange, data.extension]); + }, [_id, dispatchToastMessage, name, previousName, previousSound, saveAction, file, t, data.extension]); const handleDeleteButtonClick = useCallback(() => { const handleDelete = async (): Promise => { From 8f867047815bc913d61648ea2f298171d9dde5df Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 13:06:54 -0300 Subject: [PATCH 14/29] move return to the end of try block --- apps/meteor/app/api/server/v1/custom-sounds.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 5750efb48b431..df734a077ccdd 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -188,11 +188,11 @@ const customSoundsEndpoints = API.v1 extension: fields.extension, }); await uploadCustomSound(fileBuffer, mimetype, { _id, name: fields.name, extension: fields.extension }); + return API.v1.success({ sound: { _id } }); } catch (error) { SystemLogger.error({ error }); return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); } - return API.v1.success({ sound: { _id } }); }, ) .post( From 24154c295e8303632c373726de1871d49ddba2cf Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Fri, 20 Mar 2026 13:23:38 -0300 Subject: [PATCH 15/29] add listeners to rs and ws errors --- .../custom-sounds/server/lib/uploadCustomSound.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts index 99d92c63a3dfd..db7b9f45f8be1 100644 --- a/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts @@ -13,8 +13,18 @@ export const uploadCustomSound = async ( const rs = RocketChatFile.bufferToStream(buffer); await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType); + + ws.on('error', (err: Error) => { + reject(err); + }); + + rs.on('error', (err: Error) => { + ws.destroy(); + reject(err); + }); + ws.on('end', () => { setTimeout(() => api.broadcast('notify.updateCustomSound', { soundData }), 500); resolve(); From ade016013071dd656fe5f9b3964dd0d51f5a0726 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 23 Mar 2026 15:41:56 -0300 Subject: [PATCH 16/29] impmenet custom-sounds create and update tests --- .../tests/end-to-end/api/custom-sounds.ts | 350 +++++++++++++++--- apps/meteor/tests/mocks/files/audio_mock.mp3 | Bin 0 -> 4386 bytes 2 files changed, 298 insertions(+), 52 deletions(-) create mode 100644 apps/meteor/tests/mocks/files/audio_mock.mp3 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..661f55e72519a 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -1,50 +1,35 @@ import { randomUUID } from 'crypto'; -import { readFileSync } from 'fs'; import path from 'path'; +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { before, describe, it, after } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data'; import { updateSetting } from '../../data/permissions.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; -async function insertOrUpdateSound(fileName: string, fileId?: string): Promise { - fileId = fileId ?? ''; +async function createCustomSound(fileName: string, filePath: string): Promise { + let fileId = ''; await request - .post(api('method.call/insertOrUpdateSound')) + .post(api('custom-sounds.create')) .set(credentials) - .send({ - message: JSON.stringify({ - msg: 'method', - id: '1', - method: 'insertOrUpdateSound', - params: [{ name: fileName, extension: 'mp3', newFile: true }], - }), - }) + .attach('sound', filePath) + .field('name', fileName) + .field('extension', path.extname(filePath).replace('.', '')) .expect(200) .expect((res) => { - fileId = JSON.parse(res.body.message).result; + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound'); + fileId = res.body.sound._id; }); return fileId; } -async function uploadCustomSound(binary: string, fileName: string, fileId: string) { - await request - .post(api('method.call/uploadCustomSound')) - .set(credentials) - .send({ - message: JSON.stringify({ - msg: 'method', - id: '2', - method: 'uploadCustomSound', - params: [binary, 'audio/wav', { name: fileName, extension: 'wav', newFile: true, _id: fileId }], - }), - }) - .expect(200); -} - async function deleteCustomSound(_id: string) { await request .post(api('method.call/deleteCustomSound')) @@ -62,37 +47,299 @@ async function deleteCustomSound(_id: string) { describe('[CustomSounds]', () => { const fileName = `test-file-${randomUUID()}`; + const mockWavAudioPath = path.resolve(__dirname, '../../mocks/files/audio_mock.wav'); + const mockMp3AudioPath = path.resolve(__dirname, '../../mocks/files/audio_mock.mp3'); + let fileId: string; let fileId2: string; let uploadDate: string | undefined; - let binary: string; before((done) => getCredentials(done)); before(async () => { - const data = readFileSync(path.resolve(__dirname, '../../mocks/files/audio_mock.wav')); - binary = data.toString('binary'); + fileId = await createCustomSound(fileName, mockWavAudioPath); + fileId2 = await createCustomSound(`${fileName}-2`, mockWavAudioPath); + }); + + after(async () => { + if (fileId) { + await deleteCustomSound(fileId); + } + if (fileId2) { + await deleteCustomSound(fileId2); + } + }); - fileId = await insertOrUpdateSound(fileName); - fileId2 = await insertOrUpdateSound(`${fileName}-2`); + describe('[/custom-sounds.create]', () => { + let fileId3: string; - await uploadCustomSound(binary, fileName, fileId); - await uploadCustomSound(binary, `${fileName}-2`, fileId2); + after(async () => { + if (fileId3) { + await deleteCustomSound(fileId3); + } + }); + + it('should successfully create a new custom sound and return its _id', async () => { + const response = await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', `happy-path-sound-${randomUUID()}`) + .field('extension', 'mp3') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('_id').and.to.be.a('string'); + }); + fileId3 = response.body.sound._id; + }); + + it('should return unauthorized if not authenticated', async () => { + await request.post(api('custom-sounds.create')).expect(401); + }); + + it('should fail if the file exceeds the 5MB size limit', async () => { + const largeBuffer = Buffer.alloc(6 * 1024 * 1024, 'a'); + + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', largeBuffer, { filename: 'large.wav', contentType: 'audio/wav' }) + .field('name', 'large-sound') + .field('extension', 'wav') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error').and.to.be.equal('[error-file-too-large]'); + }); + }); + + it('should fail if the file is an invalid mime type', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', Buffer.from('this is not audio'), { filename: 'test.txt', contentType: 'text/plain' }) + .field('name', 'invalid-sound') + .field('extension', 'txt') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('MIME type not allowed'); + }); + }); + + it('should reject injection of invalid characters and symbols in name', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', '') + .field('extension', 'wav') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.include('is not a valid name'); + }); + }); + + it('should reject NoSQL regex injection in name', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', '{"$regex":".*"}') + .field('extension', 'wav') + .expect(400); + }); + + describe('without manage-sounds permission', async () => { + let unauthorizedUser: IUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await login(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return forbidden if user does not have the manage-sounds permission', async () => { + await request + .post(api('custom-sounds.create')) + .set(unauthorizedUserCredentials) + .attach('sound', mockWavAudioPath) + .field('name', `forbidden-sound-${randomUUID()}`) + .field('extension', 'mp3') + .expect(403); + }); + }); }); - after(() => - request - .post(api('method.call/deleteCustomSound')) - .set(credentials) - .send({ - message: JSON.stringify({ - msg: 'method', - id: '33', - method: 'deleteCustomSound', - params: [fileId], - }), - }), - ); + describe('[/custom-sounds.update]', () => { + let previousFileName: string; + + before(async () => { + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('sound').and.to.be.an('object'); + previousFileName = res.body.sound.name; + }); + }); + + after(async () => { + await request.post(api('custom-sounds.update')).set(credentials).field('_id', fileId).field('name', previousFileName).expect(200); + }); + + it('should successfully update only the name of the sound without sending the file again', async () => { + const newSoundName = `${fileName}-updated`; + await request + .post(api('custom-sounds.update')) + .set(credentials) + .field('_id', fileId) + .field('name', newSoundName) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('name').and.to.be.equal(newSoundName); + }); + }); + + it('should successfully update the sound file and name', async () => { + const newSoundName = `${fileName}-2-updated`; + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockMp3AudioPath) + .field('_id', fileId) + .field('name', newSoundName) + .field('extension', 'mp3') + .expect(200); + + await request + .get(api('custom-sounds.getOne')) + .set(credentials) + .query({ _id: fileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('sound').and.to.be.an('object'); + expect(res.body.sound).to.have.property('name').and.to.be.equal(newSoundName); + expect(res.body.sound).to.have.property('extension').and.to.be.equal('mp3'); + }); + }); + + it('should return unauthorized if not authenticated', async () => { + await request.post(api('custom-sounds.update')).expect(401); + }); + + it('should reject injection of invalid characters and symbols in name', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', '') + .field('extension', 'wav') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.include('is not a valid name'); + }); + }); + + it('should reject NoSQL regex injection in name', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', '{"$regex":".*"}') + .field('extension', 'wav') + .expect(400); + }); + + it('should fail if the file exceeds the 5MB size limit', async () => { + const largeBuffer = Buffer.alloc(6 * 1024 * 1024, 'a'); + + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', largeBuffer, { filename: 'large.wav', contentType: 'audio/wav' }) + .field('name', 'large-sound') + .field('extension', 'wav') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error').and.to.be.equal('[error-file-too-large]'); + }); + }); + + it('should return an error when trying to update a non-existent sound', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .field('_id', 'invalid-id-123') + .field('name', 'new-name') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('Custom Sound not found.'); + }); + }); + + it('should fail if attempting to update with an invalid mime type file', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', Buffer.from('fake-audio'), { filename: 'test.mp4', contentType: 'video/mp4' }) + .field('_id', fileId) + .field('name', fileName) + .field('extension', 'mp4') + .expect(400) + .expect((res) => { + expect(res.body.error).to.equal('MIME type not allowed'); + }); + }); + + describe('without manage-sounds permission', async () => { + let unauthorizedUser: IUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await login(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return forbidden if user does not have the manage-sounds permission', async () => { + await request + .post(api('custom-sounds.create')) + .set(unauthorizedUserCredentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', `forbidden-case`) + .expect(403); + }); + }); + }); describe('[/custom-sounds.list]', () => { it('should return custom sounds', (done) => { @@ -261,13 +508,12 @@ describe('[CustomSounds]', () => { before(async () => { await updateSetting('CustomSounds_FileSystemPath', '', false); + await updateSetting('CustomSounds_Storage_Type', 'FileSystem'); - fsFileId = await insertOrUpdateSound(`${fileName}-3`); - await uploadCustomSound(binary, `${fileName}-3`, fsFileId); + fsFileId = await createCustomSound(`${fileName}-3`, mockWavAudioPath); await updateSetting('CustomSounds_Storage_Type', 'GridFS'); - gridFsFileId = await insertOrUpdateSound(`${fileName}-4`); - await uploadCustomSound(binary, `${fileName}-4`, gridFsFileId); + gridFsFileId = await createCustomSound(`${fileName}-4`, mockWavAudioPath); }); after(async () => { 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 0000000000000000000000000000000000000000..246100823a66704efd47875ea416fe0655dc5213 GIT binary patch literal 4386 zcmeZtF=k-^0i}@OU{@f`$H2hslUSB!W~67VXJ}vmmV^-he>)sN;zF37d1?7T7C#Vc zFfa&wV-OG!6O)ydRaVy2)YR8EH8r)ic64;~@bL5V3kiveib_aGOH0eiDJm+etgNrE zZ*A@F?w&Yt`t<2@=Pp^YWYww-8#Zj)wr9_tLx)bBIC1XW)vH%;-+uV;;q&Kj-@g6) z`RC7{|Nnz*2D&jB=te_B5C;ZSSZg3mfp3~WN-u)h|9=Y{VPHPMz_m^ z5S839()DP#jfNYjydF&lqv>EY9T1fUK#E4oQKEwiMd4_;p#%rfoY8P2I;c<-j)ogb XaFEBLVZDIjpiz5Jf`e!dy}}IuK+j)H literal 0 HcmV?d00001 From 62f48e98172d7600eba4436ed99b17c3504a70bb Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 23 Mar 2026 15:59:29 -0300 Subject: [PATCH 17/29] tweaks on update tests --- apps/meteor/tests/end-to-end/api/custom-sounds.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 661f55e72519a..cc35fb0f5a219 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -281,6 +281,7 @@ describe('[CustomSounds]', () => { .post(api('custom-sounds.update')) .set(credentials) .attach('sound', largeBuffer, { filename: 'large.wav', contentType: 'audio/wav' }) + .field('_id', fileId) .field('name', 'large-sound') .field('extension', 'wav') .expect(400) @@ -331,7 +332,7 @@ describe('[CustomSounds]', () => { it('should return forbidden if user does not have the manage-sounds permission', async () => { await request - .post(api('custom-sounds.create')) + .post(api('custom-sounds.update')) .set(unauthorizedUserCredentials) .attach('sound', mockWavAudioPath) .field('_id', fileId) From e9b18126dac9c34a4545c178a8f2cb4b47f9ffb4 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 23 Mar 2026 16:04:12 -0300 Subject: [PATCH 18/29] make EditSound data reset more resilient --- .../meteor/client/views/admin/customSounds/EditSound.tsx | 3 ++- apps/meteor/tests/end-to-end/api/custom-sounds.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index f553cf1fa4291..2dfa608d15da6 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -34,7 +34,8 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl useEffect(() => { setName(previousName || ''); - }, [previousName]); + setFile(undefined); + }, [_id, previousName]); const deleteCustomSound = useMethod('deleteCustomSound'); 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 cc35fb0f5a219..3e72fb64546d8 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -193,7 +193,14 @@ describe('[CustomSounds]', () => { }); after(async () => { - await request.post(api('custom-sounds.update')).set(credentials).field('_id', fileId).field('name', previousFileName).expect(200); + await request + .post(api('custom-sounds.update')) + .set(credentials) + .field('_id', fileId) + .field('name', previousFileName) + .attach('sound', mockWavAudioPath) + .field('extension', 'wav') + .expect(200); }); it('should successfully update only the name of the sound without sending the file again', async () => { From b9d73b7476a1960e0e0e962b32f822a344c9a920 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 23 Mar 2026 17:00:08 -0300 Subject: [PATCH 19/29] change sound file label --- apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx | 2 +- apps/meteor/client/views/admin/customSounds/EditSound.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index 0c63b9beaf89c..3c33ae2399d51 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -85,7 +85,7 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr - {t('Sound_File_mp3')} + {t('Sound File')} diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 2dfa608d15da6..aaa8d21f98bd6 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -131,7 +131,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl - {t('Sound_File_mp3')} + {t('Sound File')} From 5b753a56353d6539e4d525915c07169ac2524a3c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 23 Mar 2026 17:36:06 -0300 Subject: [PATCH 20/29] add spanish translation for sound file --- packages/i18n/src/locales/es.i18n.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/i18n/src/locales/es.i18n.json b/packages/i18n/src/locales/es.i18n.json index f16422bcaacc8..85dc6ee327869 100644 --- a/packages/i18n/src/locales/es.i18n.json +++ b/packages/i18n/src/locales/es.i18n.json @@ -3528,6 +3528,7 @@ "Sort_By": "Ordenar por", "Sort_by_activity": "Ordenar por actividad", "Sound": "Sonido", + "Sound File": "Archivo de sonido", "Sound_Beep": "Pitido", "Sound_Call_Ended": "Llamada terminada", "Sound_Chelle": "Chelle", @@ -5024,4 +5025,4 @@ "__username__is_no_longer__role__defined_by__user_by_": "{{username}} ya no es {{role}} (por {{user_by}})", "__username__was_set__role__by__user_by_": "{{username}} se ha establecido como {{role}} por {{user_by}}", "__usersCount__people_will_be_invited": "{{usersCount}} miembros sern invitados" -} \ No newline at end of file +} From e2fb70abc53d796a854b45caf2938b4b3110853b Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Mon, 23 Mar 2026 21:53:40 -0300 Subject: [PATCH 21/29] address linter fix --- packages/i18n/src/locales/es.i18n.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/i18n/src/locales/es.i18n.json b/packages/i18n/src/locales/es.i18n.json index 85dc6ee327869..e6608e8e76892 100644 --- a/packages/i18n/src/locales/es.i18n.json +++ b/packages/i18n/src/locales/es.i18n.json @@ -5025,4 +5025,4 @@ "__username__is_no_longer__role__defined_by__user_by_": "{{username}} ya no es {{role}} (por {{user_by}})", "__username__was_set__role__by__user_by_": "{{username}} se ha establecido como {{role}} por {{user_by}}", "__usersCount__people_will_be_invited": "{{usersCount}} miembros sern invitados" -} +} \ No newline at end of file From bbb81c91441c8cce7245a1645a6f9e3b85ecec5d Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 15:48:15 -0300 Subject: [PATCH 22/29] use const instead of let --- apps/meteor/app/api/server/v1/custom-sounds.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index df734a077ccdd..f2ab9fa6a0a5b 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -180,10 +180,8 @@ const customSoundsEndpoints = API.v1 return API.v1.failure('MIME type not allowed'); } - let _id; - try { - _id = await insertOrUpdateSound({ + const _id = await insertOrUpdateSound({ name: fields.name, extension: fields.extension, }); From e1f3536e17b6f56de73bf366e8287769619c161e Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 15:48:46 -0300 Subject: [PATCH 23/29] add file extension validation on update --- apps/meteor/app/api/server/v1/custom-sounds.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index f2ab9fa6a0a5b..cb01356682927 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -232,6 +232,8 @@ const customSoundsEndpoints = API.v1 return API.v1.failure('MIME type not allowed'); } + if (fileBuffer && !fields.extension) return API.v1.failure('Extension required'); + const soundToUpdate = await CustomSounds.findOneById>(fields._id, { projection: { _id: 1, name: 1, extension: 1 }, }); From df812dec2ceecb0865f33413f55b41803bb80dd7 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 16:05:12 -0300 Subject: [PATCH 24/29] use a single query method for fetching same name sounds --- .../custom-sounds/server/lib/insertOrUpdateSound.ts | 9 +-------- .../model-typings/src/models/ICustomSoundsModel.ts | 3 +-- packages/models/src/models/CustomSounds.ts | 12 ++---------- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts index d5bd218403f6e..498b43a3b31a9 100644 --- a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -29,14 +29,7 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< }); } - let matchingResults; - - if (soundData._id) { - check(soundData._id, String); - matchingResults = await CustomSounds.findByNameExceptId(soundData.name, soundData._id).toArray(); - } else { - matchingResults = await CustomSounds.findByName(soundData.name).toArray(); - } + const matchingResults = await CustomSounds.findByName(soundData.name, soundData._id).toArray(); if (matchingResults.length > 0) { throw new Meteor.Error('Custom_Sound_Error_Name_Already_In_Use', 'The custom sound name is already in use', { diff --git a/packages/model-typings/src/models/ICustomSoundsModel.ts b/packages/model-typings/src/models/ICustomSoundsModel.ts index 2971ec6ceb2b4..f35e9578930e8 100644 --- a/packages/model-typings/src/models/ICustomSoundsModel.ts +++ b/packages/model-typings/src/models/ICustomSoundsModel.ts @@ -4,8 +4,7 @@ import type { FindCursor, FindOptions, InsertOneResult, UpdateResult, WithId } f import type { IBaseModel } from './IBaseModel'; export interface ICustomSoundsModel extends IBaseModel { - findByName(name: string, options?: FindOptions): FindCursor; - findByNameExceptId(name: string, except: string, options?: FindOptions): FindCursor; + findByName(name: string, exceptId?: string, options?: FindOptions): FindCursor; setName(_id: string, name: string): Promise; create(data: Omit): Promise>>; setExtension(_id: string, extension: string): Promise; diff --git a/packages/models/src/models/CustomSounds.ts b/packages/models/src/models/CustomSounds.ts index eb4f3474bfb37..b99052e9a9e98 100644 --- a/packages/models/src/models/CustomSounds.ts +++ b/packages/models/src/models/CustomSounds.ts @@ -14,18 +14,10 @@ export class CustomSoundsRaw extends BaseRaw implements ICustomSou } // find - findByName(name: string, options?: FindOptions): FindCursor { + findByName(name: string, exceptId?: string, options?: FindOptions): FindCursor { const query = { name, - }; - - return this.find(query, options); - } - - findByNameExceptId(name: string, except: string, options?: FindOptions): FindCursor { - const query = { - _id: { $nin: [except] }, - name, + ...(exceptId && { _id: { $nin: [exceptId] } }), }; return this.find(query, options); From 3fa3200cf51a583cf135a78280db3745ab781f4d Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 16:06:50 -0300 Subject: [PATCH 25/29] add further test cases --- .../tests/end-to-end/api/custom-sounds.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 3e72fb64546d8..ee022f01faa3c 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -95,6 +95,20 @@ describe('[CustomSounds]', () => { fileId3 = response.body.sound._id; }); + it('should not be able to create two sounds with the same name', async () => { + await request + .post(api('custom-sounds.create')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('name', fileName) + .field('extension', 'mp3') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The custom sound name is already in use [Custom_Sound_Error_Name_Already_In_Use]'); + }); + }); + it('should return unauthorized if not authenticated', async () => { await request.post(api('custom-sounds.create')).expect(401); }); @@ -251,6 +265,35 @@ describe('[CustomSounds]', () => { }); }); + it('should not be able to update sounds name if the name was already taken ', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', `${fileName}-2`) + .field('extension', 'mp3') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'The custom sound name is already in use [Custom_Sound_Error_Name_Already_In_Use]'); + }); + }); + + it('should not be able to update sound if file was attached but the extension was not', async () => { + await request + .post(api('custom-sounds.update')) + .set(credentials) + .attach('sound', mockWavAudioPath) + .field('_id', fileId) + .field('name', `${fileName}-2`) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'Extension required'); + }); + }); + it('should return unauthorized if not authenticated', async () => { await request.post(api('custom-sounds.update')).expect(401); }); From ebf53c35faf2083ac0aa38c5195e0271958cc20c Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 16:45:22 -0300 Subject: [PATCH 26/29] refactor sound update to use a single query --- .../server/lib/insertOrUpdateSound.ts | 19 ++++++----------- .../src/models/ICustomSoundsModel.ts | 3 +-- packages/models/src/models/CustomSounds.ts | 21 ++----------------- 3 files changed, 9 insertions(+), 34 deletions(-) diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts index 498b43a3b31a9..9f71c8399c039 100644 --- a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -29,6 +29,10 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< }); } + if (soundData._id) { + check(soundData._id, String); + } + const matchingResults = await CustomSounds.findByName(soundData.name, soundData._id).toArray(); if (matchingResults.length > 0) { @@ -50,19 +54,8 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); } - if (soundData.name !== soundData.previousName) { - await CustomSounds.setName(soundData._id, soundData.name); - void api.broadcast('notify.updateCustomSound', { - soundData: { - _id: soundData._id, - name: soundData.name, - extension: soundData.extension, - }, - }); - } - - if (soundData.extension !== soundData.previousExtension) { - await CustomSounds.setExtension(soundData._id, soundData.extension); + if (soundData.name !== soundData.previousName || soundData.extension !== soundData.previousExtension) { + await CustomSounds.updateById(soundData._id, { name: soundData.name, extension: soundData.extension }); void api.broadcast('notify.updateCustomSound', { soundData: { _id: soundData._id, diff --git a/packages/model-typings/src/models/ICustomSoundsModel.ts b/packages/model-typings/src/models/ICustomSoundsModel.ts index f35e9578930e8..40fe409eb4cf9 100644 --- a/packages/model-typings/src/models/ICustomSoundsModel.ts +++ b/packages/model-typings/src/models/ICustomSoundsModel.ts @@ -5,7 +5,6 @@ import type { IBaseModel } from './IBaseModel'; export interface ICustomSoundsModel extends IBaseModel { findByName(name: string, exceptId?: string, options?: FindOptions): FindCursor; - setName(_id: string, name: string): Promise; create(data: Omit): Promise>>; - setExtension(_id: string, extension: string): Promise; + updateById(_id: string, data: Partial>): Promise; } diff --git a/packages/models/src/models/CustomSounds.ts b/packages/models/src/models/CustomSounds.ts index b99052e9a9e98..418753016d346 100644 --- a/packages/models/src/models/CustomSounds.ts +++ b/packages/models/src/models/CustomSounds.ts @@ -23,29 +23,12 @@ export class CustomSoundsRaw extends BaseRaw implements ICustomSou return this.find(query, options); } - // update - setName(_id: string, name: string): Promise { - const update = { - $set: { - name, - }, - }; - - return this.updateOne({ _id }, update); - } - // INSERT create(data: Omit): Promise>> { return this.insertOne(data); } - setExtension(_id: string, extension: string): Promise { - const update = { - $set: { - extension, - }, - }; - - return this.updateOne({ _id }, update); + updateById(_id: string, data: Partial>): Promise { + return this.updateOne({ _id }, { $set: data }); } } From 3608f2d0f879b2f5ebc58291e2378d209aa79a9e Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 17:56:42 -0300 Subject: [PATCH 27/29] use ajv instead of ajvQuery for create and update schemas --- packages/rest-typings/src/v1/customSounds.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index 9a77e41470450..8248f8078d0f0 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -1,6 +1,6 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; -import { ajvQuery } from './Ajv'; +import { ajvQuery, ajv } from './Ajv'; import { type PaginatedRequest } from '../helpers/PaginatedRequest'; type CustomSoundsGetOne = { _id: ICustomSound['_id'] }; @@ -69,7 +69,7 @@ const CustomSoundsCreateSchema = { additionalProperties: false, }; -export const isCustomSoundsCreateProps = ajvQuery.compile(CustomSoundsCreateSchema); +export const isCustomSoundsCreateProps = ajv.compile(CustomSoundsCreateSchema); type CustomSoundsUpdate = Pick & { extension?: ICustomSound['extension']; @@ -95,4 +95,4 @@ const CustomSoundsUpdateSchema = { additionalProperties: false, }; -export const isCustomSoundsUpdateProps = ajvQuery.compile(CustomSoundsUpdateSchema); +export const isCustomSoundsUpdateProps = ajv.compile(CustomSoundsUpdateSchema); From a5d93434c6c05a7729103246083b1a57bb711837 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 22:07:12 -0300 Subject: [PATCH 28/29] move type check to meteor method --- .../app/custom-sounds/server/lib/insertOrUpdateSound.ts | 5 ----- .../app/custom-sounds/server/methods/insertOrUpdateSound.ts | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts index 9f71c8399c039..9170e7fccff43 100644 --- a/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -1,6 +1,5 @@ import { api } from '@rocket.chat/core-services'; import { CustomSounds } from '@rocket.chat/models'; -import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { ICustomSoundData } from '../methods/insertOrUpdateSound'; @@ -29,10 +28,6 @@ export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise< }); } - if (soundData._id) { - check(soundData._id, String); - } - const matchingResults = await CustomSounds.findByName(soundData.name, soundData._id).toArray(); if (matchingResults.length > 0) { diff --git a/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts b/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts index cf4b935774ce5..4f077a5786b3f 100644 --- a/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts @@ -1,4 +1,5 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; @@ -29,6 +30,9 @@ Meteor.methods({ if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { throw new Meteor.Error('not_authorized'); } + if (soundData._id) { + check(soundData._id, String); + } return insertOrUpdateSound(soundData); }, }); From 17633dd0996272898cda4a5b1a02162e37acdd24 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 25 Mar 2026 22:18:58 -0300 Subject: [PATCH 29/29] add i18n package to changeset --- .changeset/mighty-icons-kiss.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/mighty-icons-kiss.md b/.changeset/mighty-icons-kiss.md index 6ccb3ae6f12da..5d62298c8e0c7 100644 --- a/.changeset/mighty-icons-kiss.md +++ b/.changeset/mighty-icons-kiss.md @@ -3,6 +3,7 @@ '@rocket.chat/rest-typings': minor '@rocket.chat/models': minor '@rocket.chat/meteor': minor +'@rocket.chat/i18n': minor --- Adds new API endpoints `custom-sounds.create` and `custom-sounds.update` to manage custom sounds with strict file validation for size and specific MIME types to ensure system compatibility.