Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a0e1ac0
add update and create custom-sounds endpoints
nazabucciarelli Mar 12, 2026
35be16f
add custom-sounds validation types for create and update
nazabucciarelli Mar 12, 2026
9c46b02
adapt client to new create custom sound endpoint
nazabucciarelli Mar 13, 2026
b5bbb15
Merge branch 'develop' into feat/custom-sound-create-endpoint
nazabucciarelli Mar 16, 2026
7596a73
EditSound update wip
nazabucciarelli Mar 13, 2026
fb96fab
Merge branch 'feat/custom-sound-create-endpoint' of github.com:Rocket…
nazabucciarelli Mar 17, 2026
9fd5cd8
fix linter error
nazabucciarelli Mar 17, 2026
8478c7a
fix update and add setExtension method for custom sounds
nazabucciarelli Mar 17, 2026
34752b1
address ai comments
nazabucciarelli Mar 17, 2026
ef61a8e
solve conflict with develop
nazabucciarelli Mar 18, 2026
d1c328e
fix goToNew
nazabucciarelli Mar 19, 2026
8be2938
add changeset
nazabucciarelli Mar 19, 2026
b7e231a
address ai comments
nazabucciarelli Mar 19, 2026
eb7fedf
destructure properties directly on function call
nazabucciarelli Mar 20, 2026
5035b2c
remove comments
nazabucciarelli Mar 20, 2026
ba3ab6b
move onChange call out of handleSave function
nazabucciarelli Mar 20, 2026
8f86704
move return to the end of try block
nazabucciarelli Mar 20, 2026
24154c2
add listeners to rs and ws errors
nazabucciarelli Mar 20, 2026
ade0160
impmenet custom-sounds create and update tests
nazabucciarelli Mar 23, 2026
5a0ac2f
Merge branch 'develop' into feat/custom-sound-create-endpoint
nazabucciarelli Mar 23, 2026
62f48e9
tweaks on update tests
nazabucciarelli Mar 23, 2026
e9b1812
make EditSound data reset more resilient
nazabucciarelli Mar 23, 2026
b9d73b7
change sound file label
nazabucciarelli Mar 23, 2026
5b753a5
add spanish translation for sound file
nazabucciarelli Mar 23, 2026
e2fb70a
address linter fix
nazabucciarelli Mar 24, 2026
bbb81c9
use const instead of let
nazabucciarelli Mar 25, 2026
e1f3536
add file extension validation on update
nazabucciarelli Mar 25, 2026
df812de
use a single query method for fetching same name sounds
nazabucciarelli Mar 25, 2026
3fa3200
add further test cases
nazabucciarelli Mar 25, 2026
ebf53c3
refactor sound update to use a single query
nazabucciarelli Mar 25, 2026
2460231
Merge branch 'develop' into feat/custom-sound-create-endpoint
nazabucciarelli Mar 25, 2026
3608f2d
use ajv instead of ajvQuery for create and update schemas
nazabucciarelli Mar 25, 2026
a5d9343
move type check to meteor method
nazabucciarelli Mar 26, 2026
17633dd
add i18n package to changeset
nazabucciarelli Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/mighty-icons-kiss.md
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.
4 changes: 3 additions & 1 deletion apps/meteor/app/api/server/lib/getUploadFormData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
141 changes: 141 additions & 0 deletions apps/meteor/app/api/server/v1/custom-sounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { PaginatedResult } from '@rocket.chat/rest-typings';
import {
isCustomSoundsGetOneProps,
isCustomSoundsListProps,
isCustomSoundsCreateProps,
isCustomSoundsUpdateProps,
ajv,
validateBadRequestErrorResponse,
validateNotFoundErrorResponse,
Expand All @@ -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(
Expand Down Expand Up @@ -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 }>({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)) {
return API.v1.failure('MIME type not allowed');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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) {
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');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

In custom-sounds.update, the new file is uploaded (and the previous file deleted inside uploadCustomSound) before insertOrUpdateSound runs validations/uniqueness checks. If insertOrUpdateSound throws (e.g., duplicate/invalid name), the file may already have been replaced, leaving DB metadata and stored file out of sync. Consider validating/updating the DB first (or performing a pre-validation step) before modifying stored files, and handle rollback/cleanup on failures.

Copilot uses AI. Check for mistakes.
_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({});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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>;
Expand Down
64 changes: 64 additions & 0 deletions apps/meteor/app/custom-sounds/server/lib/insertOrUpdateSound.ts
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;
};
35 changes: 35 additions & 0 deletions apps/meteor/app/custom-sounds/server/lib/uploadCustomSound.ts
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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

uploadCustomSound unconditionally deletes ${soundData._id}.${soundData.previousExtension}. On create flows previousExtension is not set (and on some update flows it can be undefined), which leads to deletion attempts like id.undefined and noisy error logs (FileSystem store) / unnecessary DB lookups (GridFS). Make the delete conditional on previousExtension being present, or delete ${_id}.${extension} when overwriting same-extension files.

Suggested change
await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`);
if (soundData.previousExtension) {
await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`);
}

Copilot uses AI. Check for mistakes.
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);
});
};
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -32,71 +30,9 @@ Meteor.methods<ServerMethods>({
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);
},
});
Loading
Loading