Skip to content
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

feat: Disable slash commands for encrypted rooms #32548

Merged
merged 13 commits into from
Jun 19, 2024
6 changes: 6 additions & 0 deletions .changeset/popular-bulldogs-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/i18n': patch
'@rocket.chat/meteor': patch
---

Disable slash commands in encrypted rooms and show a disabled warning.
10 changes: 9 additions & 1 deletion apps/meteor/client/lib/chats/ChatAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,15 @@ export type ChatAPI = {

readonly flows: {
readonly uploadFiles: (files: readonly File[], resetFileInput?: () => void) => Promise<void>;
readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean; previewUrls?: string[] }) => Promise<boolean>;
readonly sendMessage: ({
text,
tshow,
}: {
text: string;
tshow?: boolean;
previewUrls?: string[];
isSlashCommandAllowed?: boolean;
}) => Promise<boolean>;
readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise<boolean>;
readonly processTooLongMessage: (message: IMessage) => Promise<boolean>;
readonly processMessageEditing: (
Expand Down
13 changes: 9 additions & 4 deletions apps/meteor/client/lib/chats/flows/sendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { processSetReaction } from './processSetReaction';
import { processSlashCommand } from './processSlashCommand';
import { processTooLongMessage } from './processTooLongMessage';

const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[]): Promise<void> => {
const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], isSlashCommandAllowed?: boolean): Promise<void> => {
KonchatNotification.removeRoomNotification(message.rid);

if (await processSetReaction(chat, message)) {
Expand All @@ -25,7 +25,7 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[])
return;
}

if (await processSlashCommand(chat, message)) {
if (isSlashCommandAllowed && (await processSlashCommand(chat, message))) {
return;
}

Expand All @@ -34,7 +34,12 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[])

export const sendMessage = async (
chat: ChatAPI,
{ text, tshow, previewUrls }: { text: string; tshow?: boolean; previewUrls?: string[] },
{
text,
tshow,
previewUrls,
isSlashCommandAllowed,
}: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean },
): Promise<boolean> => {
if (!(await chat.data.isSubscribedToRoom())) {
try {
Expand Down Expand Up @@ -63,7 +68,7 @@ export const sendMessage = async (
});

try {
await process(chat, message, previewUrls);
await process(chat, message, previewUrls, isSlashCommandAllowed);
chat.composer?.dismissAllQuotedMessages();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
Expand Down
11 changes: 10 additions & 1 deletion apps/meteor/client/views/room/composer/ComposerBoxPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type ComposerBoxPopupProps<
T extends {
_id: string;
sort?: number;
disabled?: boolean;
},
> = {
title?: string;
Expand All @@ -22,6 +23,7 @@ function ComposerBoxPopup<
T extends {
_id: string;
sort?: number;
disabled?: boolean;
},
>({
title,
Expand All @@ -37,7 +39,9 @@ function ComposerBoxPopup<

const variant = popupSizes && popupSizes.inlineSize < 480 ? 'small' : 'large';

const getOptionTitle = <T extends { _id: string; sort?: number; outside?: boolean; suggestion?: boolean }>(item: T) => {
const getOptionTitle = <T extends { _id: string; sort?: number; outside?: boolean; suggestion?: boolean; disabled?: boolean }>(
item: T,
) => {
if (variant !== 'small') {
return undefined;
}
Expand All @@ -49,6 +53,10 @@ function ComposerBoxPopup<
if (item.suggestion) {
return t('Suggestion_from_recent_messages');
}

if (item.disabled) {
return t('Unavailable_in_encrypted_channels');
}
};

const itemsFlat = useMemo(
Expand Down Expand Up @@ -96,6 +104,7 @@ function ComposerBoxPopup<
id={`popup-item-${item._id}`}
tabIndex={item === focused ? 0 : -1}
aria-selected={item === focused}
disabled={item.disabled}
>
{renderItem({ item: { ...item, variant } })}
</Option>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { OptionColumn, OptionContent, OptionDescription, OptionInput } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';

export type ComposerBoxPopupSlashCommandProps = {
_id: string;
description?: string;
params?: string;
disabled?: boolean;
};

function ComposerBoxPopupSlashCommand({ _id, description, params }: ComposerBoxPopupSlashCommandProps) {
function ComposerBoxPopupSlashCommand({ _id, description, params, disabled }: ComposerBoxPopupSlashCommandProps) {
const t = useTranslation();

return (
<>
<OptionContent>
{_id} <OptionDescription>{params}</OptionDescription>
</OptionContent>
<OptionColumn>
<OptionInput>{description}</OptionInput>
<OptionInput>{disabled ? t('Unavailable_in_encrypted_channels') : description}</OptionInput>
</OptionColumn>
</>
);
Expand Down
13 changes: 12 additions & 1 deletion apps/meteor/client/views/room/composer/ComposerMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,24 @@ const ComposerMessage = ({ tmid, readOnly, onSend, ...props }: ComposerMessagePr
}
},

onSend: async ({ value: text, tshow, previewUrls }: { value: string; tshow?: boolean; previewUrls?: string[] }): Promise<void> => {
onSend: async ({
value: text,
tshow,
previewUrls,
isSlashCommandAllowed,
}: {
value: string;
tshow?: boolean;
previewUrls?: string[];
isSlashCommandAllowed?: boolean;
}): Promise<void> => {
try {
await chat?.action.stop('typing');
const newMessageSent = await chat?.flows.sendMessage({
text,
tshow,
previewUrls,
isSlashCommandAllowed,
});
if (newMessageSent) onSend?.();
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
MessageComposerHint,
MessageComposerButton,
} from '@rocket.chat/ui-composer';
import { useTranslation, useUserPreference, useLayout } from '@rocket.chat/ui-contexts';
import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import type {
ReactElement,
Expand Down Expand Up @@ -92,7 +92,7 @@ const getEmptyArray = () => a;
type MessageBoxProps = {
tmid?: IMessage['_id'];
readOnly: boolean;
onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[] }) => Promise<void>;
onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }) => Promise<void>;
onJoin?: () => Promise<void>;
onResize?: () => void;
onTyping?: () => void;
Expand Down Expand Up @@ -123,6 +123,9 @@ const MessageBox = ({
const chat = useChat();
const room = useRoom();
const t = useTranslation();
const e2eEnabled = useSetting<boolean>('E2E_Enable');
const unencryptedMessagesAllowed = useSetting<boolean>('E2E_Allow_Unencrypted_Messages');
const isSlashCommandAllowed = !e2eEnabled || !room.encrypted || unencryptedMessagesAllowed;
const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room);

const [typing, setTyping] = useReducer(reducer, false);
Expand Down Expand Up @@ -176,6 +179,7 @@ const MessageBox = ({
value: text,
tshow,
previewUrls,
isSlashCommandAllowed,
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type ComposerPopupOption<T extends { _id: string; sort?: number } = { _id
getValue: (item: T) => string;

renderItem?: ({ item }: { item: T }) => ReactElement;
disabled?: boolean;
};

export type ComposerPopupContextValue = ComposerPopupOption[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,17 @@ import type { ComposerPopupContextValue } from '../contexts/ComposerPopupContext
import { ComposerPopupContext, createMessageBoxPopupConfig } from '../contexts/ComposerPopupContext';

const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room: IRoom }) => {
const { _id: rid } = room;
const { _id: rid, encrypted: isRoomEncrypted } = room;
const userSpotlight = useMethod('spotlight');
const suggestionsCount = useSetting<number>('Number_of_users_autocomplete_suggestions');
const cannedResponseEnabled = useSetting<boolean>('Canned_Responses_Enable');
const [recentEmojis] = useLocalStorage('emoji.recent', []);
const isOmnichannel = isOmnichannelRoom(room);
const useEmoji = useUserPreference('useEmojis');
const t = useTranslation();
const e2eEnabled = useSetting<boolean>('E2E_Enable');
const unencryptedMessagesAllowed = useSetting<boolean>('E2E_Allow_Unencrypted_Messages');
const encrypted = isRoomEncrypted && e2eEnabled && !unencryptedMessagesAllowed;

const call = useMethod('getSlashCommandPreviews');
const value: ComposerPopupContextValue = useMemo(() => {
Expand Down Expand Up @@ -278,6 +281,7 @@ const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room:
trigger: '/',
suffix: ' ',
triggerAnywhere: false,
disabled: encrypted,
renderItem: ({ item }) => <ComposerBoxPopupSlashCommand {...item} />,
getItemsFromLocal: async (filter: string) => {
return Object.keys(slashCommands.commands)
Expand All @@ -288,6 +292,7 @@ const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room:
params: item.params && t.has(item.params) ? t(item.params) : item.params ?? '',
description: item.description && t.has(item.description) ? t(item.description) : item.description,
permission: item.permission,
...(encrypted && { disabled: encrypted }),
};
})
.filter((command) => {
Expand Down Expand Up @@ -360,7 +365,7 @@ const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room:
},
}),
].filter(Boolean);
}, [t, cannedResponseEnabled, isOmnichannel, recentEmojis, suggestionsCount, userSpotlight, rid, call, useEmoji]);
}, [t, cannedResponseEnabled, isOmnichannel, recentEmojis, suggestionsCount, userSpotlight, rid, call, useEmoji, encrypted]);

return <ComposerPopupContext.Provider value={value} children={children} />;
};
Expand Down
63 changes: 63 additions & 0 deletions apps/meteor/tests/e2e/e2e-encryption.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,69 @@ test.describe.serial('e2e-encryption', () => {
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});

test('expect slash commands to be enabled in an e2ee room', async ({ page }) => {
const channelName = faker.string.uuid();

await poHomeChannel.sidenav.createEncryptedChannel(channelName);

await expect(page).toHaveURL(`/group/${channelName}`);

await poHomeChannel.dismissToast();

await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();

await poHomeChannel.content.sendMessage('This is an encrypted message.');

await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();

await page.locator('[name="msg"]').type('/');
await expect(page.locator('#popup-item-contextualbar')).not.toHaveClass(/disabled/);
await page.locator('[name="msg"]').clear();

await poHomeChannel.content.dispatchSlashCommand('/contextualbar');
await expect(poHomeChannel.btnContextualbarClose).toBeVisible();

await poHomeChannel.btnContextualbarClose.click();
await expect(poHomeChannel.btnContextualbarClose).toBeHidden();
});

test.describe('un-encrypted messages not allowed in e2ee rooms', () => {
let poHomeChannel: HomeChannel;

test.beforeEach(async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await page.goto('/home');
});
test.beforeAll(async ({ api }) => {
expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false })).status()).toBe(200);
});

test.afterAll(async ({ api }) => {
expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true })).status()).toBe(200);
});

test('expect slash commands to be disabled in an e2ee room', async ({ page }) => {
const channelName = faker.string.uuid();

await poHomeChannel.sidenav.createEncryptedChannel(channelName);

await expect(page).toHaveURL(`/group/${channelName}`);

await poHomeChannel.dismissToast();

await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();

await poHomeChannel.content.sendMessage('This is an encrypted message.');

await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();

await page.locator('[name="msg"]').type('/');
await expect(page.locator('#popup-item-contextualbar')).toHaveClass(/disabled/);
});
});

test('expect create a private channel, send unecrypted messages, encrypt the channel and delete the last message and check the last message in the sidebar', async ({
page,
}) => {
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -5475,6 +5475,7 @@
"Unassigned": "Unassigned",
"unauthorized": "Not authorized",
"Unavailable": "Unavailable",
"Unavailable_in_encrypted_channels": "Unavailable in encrypted channels",
"Unblock": "Unblock",
"Unblock_User": "Unblock User",
"Uncheck_All": "Uncheck All",
Expand Down
Loading