diff --git a/.changeset/neat-planets-hope.md b/.changeset/neat-planets-hope.md new file mode 100644 index 0000000000000..250758fd51940 --- /dev/null +++ b/.changeset/neat-planets-hope.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Adds custom-sounds.delete API endpoint. diff --git a/apps/meteor/app/api/server/v1/custom-sounds.ts b/apps/meteor/app/api/server/v1/custom-sounds.ts index 02e97c7f151be..908db755da0da 100644 --- a/apps/meteor/app/api/server/v1/custom-sounds.ts +++ b/apps/meteor/app/api/server/v1/custom-sounds.ts @@ -5,17 +5,20 @@ import { isCustomSoundsGetOneProps, isCustomSoundsListProps, isCustomSoundsCreateProps, + isCustomSoundsDeleteProps, isCustomSoundsUpdateProps, ajv, validateBadRequestErrorResponse, validateNotFoundErrorResponse, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, + validateInternalErrorResponse, } 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 { deleteCustomSound } from '../../../custom-sounds/server/lib/deleteCustomSound'; import { insertOrUpdateSound } from '../../../custom-sounds/server/lib/insertOrUpdateSound'; import { uploadCustomSound } from '../../../custom-sounds/server/lib/uploadCustomSound'; import { getExtension, getMimeTypeFromFileName } from '../../../utils/lib/mimeTypes'; @@ -280,6 +283,49 @@ const customSoundsEndpoints = API.v1 return API.v1.failure(error instanceof Error ? error.message : 'Unknown error'); } }, + ) + .post( + 'custom-sounds.delete', + { + 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, + 404: validateNotFoundErrorResponse, + 500: validateInternalErrorResponse, + }, + authRequired: true, + body: isCustomSoundsDeleteProps, + permissionsRequired: ['manage-sounds'], + }, + async function action() { + const { _id } = this.bodyParams; + + try { + await deleteCustomSound(_id); + + return API.v1.success({}); + } catch (error: any) { + SystemLogger.error({ error }); + + if (error.error === 'Custom_Sound_Error_Invalid_Sound') { + return API.v1.failure('Custom Sound not found.'); + } + + return API.v1.internalError(); + } + }, ); export type CustomSoundEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/custom-sounds/server/lib/deleteCustomSound.ts b/apps/meteor/app/custom-sounds/server/lib/deleteCustomSound.ts new file mode 100644 index 0000000000000..ec5721164b1fe --- /dev/null +++ b/apps/meteor/app/custom-sounds/server/lib/deleteCustomSound.ts @@ -0,0 +1,20 @@ +import { api } from '@rocket.chat/core-services'; +import { CustomSounds } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds'; + +export const deleteCustomSound = async (_id: string): Promise => { + const sound = await CustomSounds.findOneById(_id); + + if (!sound) { + throw new Meteor.Error('Custom_Sound_Error_Invalid_Sound', 'Invalid sound', { + method: 'deleteCustomSound', + }); + } + + await RocketChatFileCustomSoundsInstance.deleteFile(`${sound._id}.${sound.extension}`); + await CustomSounds.removeById(_id); + + void api.broadcast('notify.deleteCustomSound', { soundData: sound }); +}; diff --git a/apps/meteor/app/custom-sounds/server/methods/deleteCustomSound.ts b/apps/meteor/app/custom-sounds/server/methods/deleteCustomSound.ts index 1c63c7a67d9fc..5b122fa5e116f 100644 --- a/apps/meteor/app/custom-sounds/server/methods/deleteCustomSound.ts +++ b/apps/meteor/app/custom-sounds/server/methods/deleteCustomSound.ts @@ -1,11 +1,11 @@ -import { api } from '@rocket.chat/core-services'; import type { ICustomSound } from '@rocket.chat/core-typings'; 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 { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +import { deleteCustomSound } from '../lib/deleteCustomSound'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -16,24 +16,12 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async deleteCustomSound(_id) { - let sound = null; - - if (this.userId && (await hasPermissionAsync(this.userId, 'manage-sounds'))) { - sound = await CustomSounds.findOneById(_id); - } else { + methodDeprecationLogger.method('deleteCustomSound', '9.0.0', '/v1/custom-sounds.delete'); + if (!this.userId || !(await hasPermissionAsync(this.userId, 'manage-sounds'))) { throw new Meteor.Error('not_authorized'); } - - if (sound == null) { - throw new Meteor.Error('Custom_Sound_Error_Invalid_Sound', 'Invalid sound', { - method: 'deleteCustomSound', - }); - } - - await RocketChatFileCustomSoundsInstance.deleteFile(`${sound._id}.${sound.extension}`); - await CustomSounds.removeById(_id); - void api.broadcast('notify.deleteCustomSound', { soundData: sound }); - + check(_id, String); + await deleteCustomSound(_id); return true; }, }); diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 759cb91509d7f..a0ce56a54409d 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -1,6 +1,6 @@ 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 { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; import fileSize from 'filesize'; import type { ReactElement, SyntheticEvent } from 'react'; import { useCallback, useState, useMemo, useEffect } from 'react'; @@ -36,7 +36,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl setFile(undefined); }, [_id, previousName]); - const deleteCustomSound = useMethod('deleteCustomSound'); + const deleteCustomSoundEndpoint = useEndpoint('POST', '/v1/custom-sounds.delete'); const { mutate: saveAction } = useEndpointUploadMutation('/v1/custom-sounds.update', { onSuccess: () => { @@ -76,7 +76,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl const handleDeleteButtonClick = useCallback(() => { const handleDelete = async (): Promise => { try { - await deleteCustomSound(_id); + await deleteCustomSoundEndpoint({ _id }); dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Has_Been_Deleted') }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); @@ -94,7 +94,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl {t('Custom_Sound_Delete_Warning')} , ); - }, [_id, close, deleteCustomSound, dispatchToastMessage, onChange, setModal, t]); + }, [_id, close, deleteCustomSoundEndpoint, dispatchToastMessage, onChange, setModal, t]); const [clickUpload] = useSingleFileInput( handleChangeFile, 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 74d1f59ea3553..de8dcc4722566 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -30,18 +30,7 @@ async function createCustomSound(fileName: string, filePath: string): Promise { @@ -415,6 +404,78 @@ describe('[CustomSounds]', () => { }); }); + describe('[/custom-sounds.delete]', () => { + let soundToDeleteId: string; + let soundDeleted: boolean = false; + + before(async () => { + soundToDeleteId = await createCustomSound(`sound-to-delete-${randomUUID()}`, mockWavAudioPath); + }); + + after(async () => { + if (soundToDeleteId && !soundDeleted) { + await deleteCustomSound(soundToDeleteId); + } + }); + + it('should return unauthorized if the user is not authenticated', async () => { + await request.post(api('custom-sounds.delete')).send({ _id: soundToDeleteId }).expect(401); + }); + + it('should return a 400 if attempting to delete a sound that does not exist', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: 'invalid-non-existent-id' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.equal('Custom Sound not found.'); + }); + }); + + it('should reject requests with invalid parameter types', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: { $ne: null } }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + 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.delete')).set(unauthorizedUserCredentials).send({ _id: soundToDeleteId }).expect(403); + }); + }); + + it('should successfully delete a custom sound when providing a valid _id', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: soundToDeleteId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + soundDeleted = true; + }); + }); + describe('Accessing custom sounds', () => { it('should return forbidden if the there is no fileId on the url', (done) => { void request diff --git a/packages/rest-typings/src/v1/Ajv.ts b/packages/rest-typings/src/v1/Ajv.ts index b11f10d712525..0f948454ad44b 100644 --- a/packages/rest-typings/src/v1/Ajv.ts +++ b/packages/rest-typings/src/v1/Ajv.ts @@ -126,3 +126,20 @@ const NotFoundErrorResponseSchema = { }; export const validateNotFoundErrorResponse = ajv.compile(NotFoundErrorResponseSchema); + +type InternalErrorResponse = { + success: false; + error: string; +}; + +const InternalErrorResponseSchema = { + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + }, + required: ['success', 'error'], + additionalProperties: false, +}; + +export const validateInternalErrorResponse = ajv.compile(InternalErrorResponseSchema); diff --git a/packages/rest-typings/src/v1/customSounds.ts b/packages/rest-typings/src/v1/customSounds.ts index 823004eda4523..68c78f4c9b2ed 100644 --- a/packages/rest-typings/src/v1/customSounds.ts +++ b/packages/rest-typings/src/v1/customSounds.ts @@ -86,3 +86,19 @@ const CustomSoundsUpdateSchema = { }; export const isCustomSoundsUpdateProps = ajv.compile(CustomSoundsUpdateSchema); + +type CustomSoundsDelete = { _id: ICustomSound['_id'] }; + +const CustomSoundsDeleteSchema = { + type: 'object', + properties: { + _id: { + type: 'string', + minLength: 1, + }, + }, + required: ['_id'], + additionalProperties: false, +}; + +export const isCustomSoundsDeleteProps = ajv.compile(CustomSoundsDeleteSchema);