diff --git a/.changeset/mighty-icons-kiss.md b/.changeset/mighty-icons-kiss.md new file mode 100644 index 0000000000000..5d62298c8e0c7 --- /dev/null +++ b/.changeset/mighty-icons-kiss.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': minor +'@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. diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index 5841a5b58c32b..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?.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/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 149a8a20a79e0..cb01356682927 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, 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'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; +import { getUploadFormData } from '../lib/getUploadFormData'; const customSoundsEndpoints = API.v1 .get( @@ -124,6 +131,140 @@ 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 { fields, fileBuffer, mimetype } = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsCreateProps, + }, + ); + + if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(mimetype)) { + return API.v1.failure('MIME type not allowed'); + } + + try { + const _id = await insertOrUpdateSound({ + name: fields.name, + 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'); + } + }, + ) + .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 { fields, fileBuffer, mimetype } = await getUploadFormData( + { + request: this.request, + }, + { + field: 'sound', + fileOptional: true, + sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES, + validate: isCustomSoundsUpdateProps, + }, + ); + + if (fileBuffer && !CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(mimetype)) { + 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 }, + }); + if (!soundToUpdate) { + return API.v1.failure('Custom Sound not found.'); + } + + const nextExtension = fileBuffer ? fields.extension : soundToUpdate.extension; + + try { + if (fileBuffer) { + await uploadCustomSound(fileBuffer, mimetype, { + _id: fields._id, + name: fields.name, + previousExtension: soundToUpdate.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'); + } + 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..9170e7fccff43 --- /dev/null +++ b/apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts @@ -0,0 +1,64 @@ +import { api } from '@rocket.chat/core-services'; +import { CustomSounds } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import type { ICustomSoundData } from '../methods/insertOrUpdateSound'; +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise => { + // 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', + }); + } + + // allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, ) + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[\s,:><&"'\/\\\(\)]/; + + 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', + }); + } + + 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', { + method: 'insertOrUpdateSound', + }); + } + + if (!soundData._id) { + return ( + await CustomSounds.create({ + name: soundData.name, + extension: soundData.extension, + }) + ).insertedId; + } + + if (soundData.newFile) { + await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); + } + + 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, + 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..db7b9f45f8be1 --- /dev/null +++ b/apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts @@ -0,0 +1,35 @@ +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.previousExtension}`); + + 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(); + }); + + 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..4f077a5786b3f 100644 --- a/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/insertOrUpdateSound.ts @@ -1,11 +1,9 @@ -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 +30,9 @@ 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/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/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..3c33ae2399d51 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -1,11 +1,14 @@ 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 { CUSTOM_SOUND_ALLOWED_MIME_TYPES, MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; +import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type AddCustomSoundProps = { @@ -19,71 +22,54 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr 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: ({ sound }) => { + dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') }); + onChange(); + goToNew(sound._id)(); + }, + }); 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) })); + 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' }) }), }); - - try { - const soundId = await insertOrUpdateSound(soundData); - - if (!soundId) { - return undefined; - } - - dispatchToastMessage({ type: 'success', message: t('Uploading_file') }); - - 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], ); 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 soundData = createSoundData(sound, name); + + 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) { + 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 ( <> @@ -99,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 f46ce0e175b61..aaa8d21f98bd6 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -1,11 +1,14 @@ 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 { CUSTOM_SOUND_ALLOWED_MIME_TYPES, MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants'; +import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation'; import { useSingleFileInput } from '../../../hooks/useSingleFileInput'; type EditSoundProps = { @@ -14,7 +17,7 @@ type EditSoundProps = { data: { _id: string; name: string; - extension?: string; + extension: string; }; }; @@ -27,78 +30,56 @@ 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]); + setFile(undefined); + }, [_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') }); + onChange(); + 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); - onChange(); - }, [saveAction, sound, onChange]); + const soundData = createSoundData(file, name, { + previousName, + previousSound, + _id, + extension: data.extension, + }); + + const validation = validate(soundData, file); + 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); + }, [_id, dispatchToastMessage, name, previousName, previousSound, saveAction, file, t, data.extension]); const handleDeleteButtonClick = useCallback(() => { const handleDelete = async (): Promise => { @@ -123,7 +104,18 @@ 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, + 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 ( <> @@ -139,11 +131,11 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl - {t('Sound_File_mp3')} + {t('Sound File')} - {sound?.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 c447bad77bdeb..e612c99bfcf13 100644 --- a/apps/meteor/client/views/admin/customSounds/lib.ts +++ b/apps/meteor/client/views/admin/customSounds/lib.ts @@ -1,13 +1,22 @@ import type { ICustomSoundData } from '../../../../app/custom-sounds/server/methods/insertOrUpdateSound'; +import { CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../../lib/constants'; -type ICustomSoundFile = { - name: string; - type: string; - extension?: string; +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?: 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 +29,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 (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(soundFile.type)) { errors.push('FileType'); } } @@ -30,7 +39,7 @@ export function validate(soundData: ICustomSoundData, soundFile?: ICustomSoundFi } export const createSoundData = ( - soundFile: ICustomSoundFile, + soundFile: File | undefined, name: string, previousData?: { _id: string; @@ -44,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() || '', + name: name.trim(), + 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 4c4b572ca210f..6d258a8876d36 100644 --- a/apps/meteor/lib/constants.ts +++ b/apps/meteor/lib/constants.ts @@ -1,2 +1,4 @@ export const NOTIFICATION_ATTACHMENT_COLOR = '#FD745E'; 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/apps/meteor/tests/end-to-end/api/custom-sounds.ts b/apps/meteor/tests/end-to-end/api/custom-sounds.ts index b035a5d6fec40..ee022f01faa3c 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,350 @@ 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); + }); - fileId = await insertOrUpdateSound(fileName); - fileId2 = await insertOrUpdateSound(`${fileName}-2`); + after(async () => { + if (fileId) { + await deleteCustomSound(fileId); + } + if (fileId2) { + await deleteCustomSound(fileId2); + } + }); + + describe('[/custom-sounds.create]', () => { + let fileId3: string; + + after(async () => { + if (fileId3) { + await deleteCustomSound(fileId3); + } + }); - await uploadCustomSound(binary, fileName, fileId); - await uploadCustomSound(binary, `${fileName}-2`, fileId2); + 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 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); + }); + + 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) + .attach('sound', mockWavAudioPath) + .field('extension', 'wav') + .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 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); + }); + + 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('_id', fileId) + .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.update')) + .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 +559,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 0000000000000..246100823a667 Binary files /dev/null and b/apps/meteor/tests/mocks/files/audio_mock.mp3 differ diff --git a/packages/i18n/src/locales/es.i18n.json b/packages/i18n/src/locales/es.i18n.json index f16422bcaacc8..e6608e8e76892 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", diff --git a/packages/model-typings/src/models/ICustomSoundsModel.ts b/packages/model-typings/src/models/ICustomSoundsModel.ts index 1709f4efe4a98..40fe409eb4cf9 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; - setName(_id: string, name: string): Promise; + findByName(name: string, exceptId?: string, options?: FindOptions): FindCursor; create(data: Omit): Promise>>; + updateById(_id: string, data: Partial>): Promise; } diff --git a/packages/models/src/models/CustomSounds.ts b/packages/models/src/models/CustomSounds.ts index 07c5b9c594a13..418753016d346 100644 --- a/packages/models/src/models/CustomSounds.ts +++ b/packages/models/src/models/CustomSounds.ts @@ -14,36 +14,21 @@ 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, + ...(exceptId && { _id: { $nin: [exceptId] } }), }; return this.find(query, options); } - findByNameExceptId(name: string, except: string, options?: FindOptions): FindCursor { - const query = { - _id: { $nin: [except] }, - name, - }; - - 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); } + + updateById(_id: string, data: Partial>): Promise { + return this.updateOne({ _id }, { $set: data }); + } } diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index 1b15644723a84..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'] }; @@ -50,3 +50,49 @@ const CustomSoundsListSchema = { }; export const isCustomSoundsListProps = ajvQuery.compile(CustomSoundsListSchema); + +type CustomSoundsCreate = Pick; + +const CustomSoundsCreateSchema = { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + }, + extension: { + type: 'string', + minLength: 1, + }, + }, + required: ['name', 'extension'], + additionalProperties: false, +}; + +export const isCustomSoundsCreateProps = ajv.compile(CustomSoundsCreateSchema); + +type CustomSoundsUpdate = Pick & { + extension?: ICustomSound['extension']; +}; + +const CustomSoundsUpdateSchema = { + type: 'object', + properties: { + _id: { + type: 'string', + minLength: 1, + }, + name: { + type: 'string', + minLength: 1, + }, + extension: { + type: 'string', + minLength: 1, + }, + }, + required: ['_id', 'name'], + additionalProperties: false, +}; + +export const isCustomSoundsUpdateProps = ajv.compile(CustomSoundsUpdateSchema);