-
Notifications
You must be signed in to change notification settings - Fork 13.5k
feat: custom sounds create/update API endpoints with file validations #39617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
a0e1ac0
35be16f
9c46b02
b5bbb15
7596a73
fb96fab
9fd5cd8
8478c7a
34752b1
ef61a8e
d1c328e
8be2938
b7e231a
eb7fedf
5035b2c
ba3ab6b
8f86704
24154c2
ade0160
5a0ac2f
62f48e9
e9b1812
b9d73b7
5b753a5
e2fb70a
bbb81c9
e1f3536
df812de
3fa3200
ebf53c3
2460231
3608f2d
a5d9343
17633dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ICustomSound, '_id'>; success: boolean }>({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick: could be defined elsewhere |
||
| 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)) { | ||
nazabucciarelli marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return API.v1.failure('MIME type not allowed'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can u create a task for the future to evaluate if we can include this validation inside grtUploadFormData? |
||
| } | ||
|
|
||
| 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) { | ||
nazabucciarelli marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, | ||
| }, | ||
| ); | ||
|
|
||
ricardogarim marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We prefer long ifs on our code, with {} |
||
|
|
||
| const soundToUpdate = await CustomSounds.findOneById<Pick<ICustomSound, '_id' | 'name' | 'extension'>>(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({ | ||
|
Comment on lines
+246
to
+255
|
||
| _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({}); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be inside the try block |
||
| }, | ||
| ); | ||
|
|
||
| export type CustomSoundEndpoints = ExtractRoutesFromAPI<typeof customSoundsEndpoints>; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> => { | ||
| // 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; | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<ICustomSoundData, '_id'>, | ||||||||||||
| ): Promise<void> => { | ||||||||||||
| const rs = RocketChatFile.bufferToStream(buffer); | ||||||||||||
| await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+14
to
+15
|
||||||||||||
| await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); | |
| if (soundData.previousExtension) { | |
| await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`); | |
| } |
Uh oh!
There was an error while loading. Please reload this page.