Skip to content

Commit

Permalink
feat: adds verification when inviting external matrixIds (#28096)
Browse files Browse the repository at this point in the history
  • Loading branch information
lmauromb committed Aug 4, 2023
1 parent 653987b commit 19aec23
Show file tree
Hide file tree
Showing 19 changed files with 393 additions and 6 deletions.
7 changes: 7 additions & 0 deletions .changeset/serious-garlics-clean.md
@@ -0,0 +1,7 @@
---
'@rocket.chat/core-services': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

New AddUser workflow for Federated Rooms
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/index.ts
Expand Up @@ -46,6 +46,7 @@ import './v1/voip/extensions';
import './v1/voip/queues';
import './v1/voip/omnichannel';
import './v1/voip';
import './v1/federation';
import './v1/moderation';

export { API, APIClass, defaultRateLimiterOptions } from './api';
24 changes: 24 additions & 0 deletions apps/meteor/app/api/server/v1/federation.ts
@@ -0,0 +1,24 @@
import { Federation, FederationEE } from '@rocket.chat/core-services';
import { isFederationVerifyMatrixIdProps } from '@rocket.chat/rest-typings';

import { isEnterprise } from '../../../../ee/app/license/server';
import { API } from '../api';

API.v1.addRoute(
'federation/matrixIds.verify',
{
authRequired: true,
validateParams: isFederationVerifyMatrixIdProps,
},
{
async get() {
const { matrixIds } = this.queryParams;

const federationService = isEnterprise() ? FederationEE : Federation;

const results = await federationService.verifyMatrixIds(matrixIds);

return API.v1.success({ results: Object.fromEntries(results) });
},
},
);
@@ -0,0 +1,83 @@
import { Modal, Button, Box, Icon } from '@rocket.chat/fuselage';
import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';
import { useForm } from 'react-hook-form';

type AddMatrixUsersModalProps = {
matrixIdVerifiedStatus: Map<string, string>;
completeUserList: string[];
onClose: () => void;
onSave: (args_0: any) => Promise<void>;
};

type FormValues = {
usersToInvite: string[];
};

const verificationStatusAsIcon = (verificationStatus: string) => {
if (verificationStatus === 'VERIFIED') {
return 'circle-check';
}

if (verificationStatus === 'UNVERIFIED') {
return 'circle-cross';
}

if (verificationStatus === 'UNABLE_TO_VERIFY') {
return 'circle-exclamation';
}
};

const AddMatrixUsersModal = ({ onClose, matrixIdVerifiedStatus, onSave, completeUserList }: AddMatrixUsersModalProps): ReactElement => {
const dispatchToastMessage = useToastMessageDispatch();
const usersToInvite = completeUserList.filter(
(user) => !(matrixIdVerifiedStatus.has(user) && matrixIdVerifiedStatus.get(user) === 'UNVERIFIED'),
);

const { handleSubmit } = useForm<FormValues>({
defaultValues: {
usersToInvite,
},
});

const onSubmit = (data: FormValues) => {
onSave({ users: data.usersToInvite })
.then(onClose)
.catch((error) => dispatchToastMessage({ type: 'error', message: error as Error }));
};

const t = useTranslation();

return (
<Modal>
<Modal.Header>
<Modal.HeaderText>
<Modal.Title>Sending Invitations</Modal.Title>
</Modal.HeaderText>
<Modal.Close title={t('Close')} onClick={onClose} />
</Modal.Header>
<Modal.Content>
<Box>
<Box is='ul'>
{[...matrixIdVerifiedStatus.entries()].map(([_matrixId, _verificationStatus]) => (
<li key={_matrixId}>
{_matrixId}: <Icon name={verificationStatusAsIcon(_verificationStatus) as ComponentProps<typeof Icon>['name']} size='x20' />
</li>
))}
</Box>
</Box>
</Modal.Content>
<Modal.Footer justifyContent='center'>
<Modal.FooterControllers>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button primary onClick={handleSubmit(onSubmit)} disabled={!(usersToInvite.length > 0)}>
{t('Yes_continue')}
</Button>
</Modal.FooterControllers>
</Modal.Footer>
</Modal>
);
};

export default AddMatrixUsersModal;
@@ -0,0 +1,39 @@
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import React from 'react';

import AddMatrixUsersModal from './AddMatrixUsersModal';

export type useAddMatrixUsersProps = {
handleSave: (args_0: any) => Promise<void>;
users: string[];
};

export const useAddMatrixUsers = () => {
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const handleClose = useMutableCallback(() => setModal(null));
const dispatchVerifyEndpoint = useEndpoint('GET', '/v1/federation/matrixIds.verify');

return useMutation(async ({ users, handleSave }: useAddMatrixUsersProps) => {
try {
let matrixIdVerificationMap = new Map();
const matrixIds = users.filter((user) => user.startsWith('@'));
const matrixIdsVerificationResponse = await dispatchVerifyEndpoint({ matrixIds });
const { results: matrixIdsVerificationResults } = matrixIdsVerificationResponse;
matrixIdVerificationMap = new Map(Object.entries(matrixIdsVerificationResults));

setModal(
<AddMatrixUsersModal
completeUserList={users}
onClose={handleClose}
onSave={handleSave}
matrixIdVerifiedStatus={matrixIdVerificationMap as Map<string, string>}
/>,
);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error as Error });
}
});
};
Expand Up @@ -19,6 +19,7 @@ import UserAutoCompleteMultiple from '../../../../../components/UserAutoComplete
import UserAutoCompleteMultipleFederated from '../../../../../components/UserAutoCompleteMultiple/UserAutoCompleteMultipleFederated';
import { useRoom } from '../../../contexts/RoomContext';
import { useRoomToolbox } from '../../../contexts/RoomToolboxContext';
import { useAddMatrixUsers } from './AddMatrixUsers/useAddMatrixUsers';

type AddUsersProps = {
rid: IRoom['_id'];
Expand All @@ -37,6 +38,7 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement =>
const {
handleSubmit,
control,
getValues,
formState: { isDirty },
} = useForm({ defaultValues: { users: [] } });

Expand All @@ -51,6 +53,8 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement =>
}
});

const addClickHandler = useAddMatrixUsers();

return (
<>
<ContextualbarHeader>
Expand Down Expand Up @@ -80,9 +84,24 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement =>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSave)}>
{t('Add_users')}
</Button>
{isRoomFederated(room) ? (
<Button
primary
disabled={addClickHandler.isLoading}
onClick={() =>
addClickHandler.mutate({
users: getValues('users'),
handleSave,
})
}
>
{t('Add_users')}
</Button>
) : (
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSave)}>
{t('Add_users')}
</Button>
)}
</ButtonGroup>
</ContextualbarFooter>
</>
Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/ee/server/local-services/federation/service.ts
Expand Up @@ -198,6 +198,10 @@ export class FederationServiceEE extends AbstractBaseFederationServiceEE impleme
);
}

public async verifyMatrixIds(matrixIds: string[]): Promise<Map<string, string>> {
return super.verifyMatrixIds(matrixIds);
}

static async createFederationService(): Promise<FederationServiceEE> {
const federationService = new FederationServiceEE();
await federationService.initialize();
Expand Down
Expand Up @@ -81,6 +81,7 @@ export interface IFederationBridge {
getRoomTopic(externalRoomId: string, externalUserId: string): Promise<string | undefined>;
setRoomName(externalRoomId: string, externalUserId: string, roomName: string): Promise<void>;
setRoomTopic(externalRoomId: string, externalUserId: string, roomTopic: string): Promise<void>;
verifyInviteeIds(matrixIds: string[]): Promise<Map<string, string>>;
getRoomData(
externalUserId: string,
externalRoomId: string,
Expand Down
Expand Up @@ -16,6 +16,9 @@ import { RoomMembershipChangedEventType } from './definitions/events/RoomMembers
import { MatrixEnumRelatesToRelType, MatrixEnumSendMessageType } from './definitions/events/RoomMessageSent';
import type { MatrixEventRoomNameChanged } from './definitions/events/RoomNameChanged';
import type { MatrixEventRoomTopicChanged } from './definitions/events/RoomTopicChanged';
import { HttpStatusCodes } from './helpers/HtttpStatusCodes';
import { extractUserIdAndHomeserverFromMatrixId } from './helpers/MatrixIdStringTools';
import { VerificationStatus, MATRIX_USER_IN_USE } from './helpers/MatrixIdVerificationTypes';

let MatrixUserInstance: any;

Expand Down Expand Up @@ -166,6 +169,41 @@ export class MatrixBridge implements IFederationBridge {
}
}

public async verifyInviteeIds(matrixIds: string[]): Promise<Map<string, string>> {
const matrixIdVerificationMap = new Map();
const matrixIdsVerificationPromises = matrixIds.map((matrixId) => this.verifyInviteeId(matrixId));
const matrixIdsVerificationPromiseResponse = await Promise.allSettled(matrixIdsVerificationPromises);
const matrixIdsVerificationFulfilledResults = matrixIdsVerificationPromiseResponse
.filter((result): result is PromiseFulfilledResult<VerificationStatus> => result.status === 'fulfilled')
.map((result) => result.value);

matrixIds.forEach((matrixId, idx) => matrixIdVerificationMap.set(matrixId, matrixIdsVerificationFulfilledResults[idx]));
return matrixIdVerificationMap;
}

private async verifyInviteeId(externalInviteeId: string): Promise<VerificationStatus> {
const [userId, homeserverUrl] = extractUserIdAndHomeserverFromMatrixId(externalInviteeId);
try {
const response = await fetch(`https://${homeserverUrl}/_matrix/client/v3/register/available`, { params: { username: userId } });

if (response.status === HttpStatusCodes.BAD_REQUEST) {
const responseBody = await response.json();

if (responseBody.errcode === MATRIX_USER_IN_USE) {
return VerificationStatus.VERIFIED;
}
}

if (response.status === HttpStatusCodes.OK) {
return VerificationStatus.UNVERIFIED;
}
} catch (e) {
return VerificationStatus.UNABLE_TO_VERIFY;
}

return VerificationStatus.UNABLE_TO_VERIFY;
}

public async createUser(username: string, name: string, domain: string, avatarUrl?: string): Promise<string> {
if (!MatrixUserInstance) {
throw new Error('Error loading the Matrix User instance from the external library');
Expand Down
Expand Up @@ -32,18 +32,22 @@ import type {
} from '../../definitions/events/RoomPowerLevelsChanged';
import type { MatrixEventRoomTopicChanged } from '../../definitions/events/RoomTopicChanged';

/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const removeExternalSpecificCharsFromExternalIdentifier = (matrixIdentifier = ''): string => {
return matrixIdentifier.replace('@', '').replace('!', '').replace('#', '');
};

/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const formatExternalUserIdToInternalUsernameFormat = (matrixUserId = ''): string => {
return matrixUserId.split(':')[0]?.replace('@', '');
};

export const isAnExternalIdentifierFormat = (identifier: string): boolean => identifier.includes(':');

/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const isAnExternalUserIdFormat = (userId: string): boolean => isAnExternalIdentifierFormat(userId) && userId.includes('@');

/** @deprecated export from {@link ../../helpers/MatrixIdStringTools} instead */
export const extractServerNameFromExternalIdentifier = (identifier = ''): string => {
const splitted = identifier.split(':');

Expand Down
@@ -0,0 +1,58 @@
export const enum HttpStatusCodes {
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
MULTIPLE_CHOICES = 300,
MOVED_PERMANENTLY = 301,
MOVED_TEMPORARILY = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
USE_PROXY = 305,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
PAYMENT_REQUIRED = 402,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
CONFLICT = 409,
GONE = 410,
LENGTH_REQUIRED = 411,
PRECONDITION_FAILED = 412,
REQUEST_TOO_LONG = 413,
REQUEST_URI_TOO_LONG = 414,
UNSUPPORTED_MEDIA_TYPE = 415,
REQUESTED_RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
IM_A_TEAPOT = 418,
INSUFFICIENT_SPACE_ON_RESOURCE = 419,
METHOD_FAILURE = 420,
MISDIRECTED_REQUEST = 421,
UNPROCESSABLE_ENTITY = 422,
LOCKED = 423,
FAILED_DEPENDENCY = 424,
PRECONDITION_REQUIRED = 428,
TOO_MANY_REQUESTS = 429,
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
HTTP_VERSION_NOT_SUPPORTED = 505,
INSUFFICIENT_STORAGE = 507,
NETWORK_AUTHENTICATION_REQUIRED = 511,
}
@@ -0,0 +1,25 @@
export const removeExternalSpecificCharsFromExternalIdentifier = (matrixId = ''): string => {
return matrixId.replace('@', '').replace('!', '').replace('#', '');
};

export const formatExternalUserIdToInternalUsernameFormat = (matrixId = ''): string => {
return matrixId.split(':')[0]?.replace('@', '');
};

export const formatExternalAliasIdToInternalFormat = (alias = ''): string => {
return alias.split(':')[0]?.replace('#', '');
};

export const isAnExternalIdentifierFormat = (identifier: string): boolean => identifier.includes(':');

export const isAnExternalUserIdFormat = (userId: string): boolean => isAnExternalIdentifierFormat(userId) && userId.includes('@');

export const extractServerNameFromExternalIdentifier = (identifier = ''): string => {
const splitted = identifier.split(':');

return splitted.length > 1 ? splitted[1] : '';
};

export const extractUserIdAndHomeserverFromMatrixId = (matrixId = ''): string[] => {
return matrixId.replace('@', '').split(':');
};

0 comments on commit 19aec23

Please sign in to comment.