Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
114 changes: 4 additions & 110 deletions ee/packages/federation-matrix/src/FederationMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import {
} from '@rocket.chat/core-typings';
import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings';
import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK, FederationRequestError } from '@rocket.chat/federation-sdk';
import type { EventID, UserID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk';
import type { EventID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk';
import { Logger } from '@rocket.chat/logger';
import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models';
import emojione from 'emojione';

import { createOrUpdateFederatedUser } from './helpers/createOrUpdateFederatedUser';
import { extractDomainFromMatrixUserId } from './helpers/extractDomainFromMatrixUserId';
import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers';
import { validateFederatedUsername } from './helpers/validateFederatedUsername';
import { MatrixMediaService } from './services/MatrixMediaService';

export const fileTypes: Record<string, FileMessageType> = {
Expand All @@ -24,115 +27,6 @@ export const fileTypes: Record<string, FileMessageType> = {
file: 'm.file',
};

/** helper to validate the username format */
export function validateFederatedUsername(mxid: string): mxid is UserID {
if (!mxid.startsWith('@')) return false;

const parts = mxid.substring(1).split(':');
if (parts.length < 2) return false;

const localpart = parts[0];
const domainAndPort = parts.slice(1).join(':');

const localpartRegex = /^(?:[a-z0-9._\-]|=[0-9a-fA-F]{2}){1,255}$/;
if (!localpartRegex.test(localpart)) return false;

const [domain, port] = domainAndPort.split(':');

const hostnameRegex = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*$/i;
const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/;
const ipv6Regex = /^\[([0-9a-f:.]+)\]$/i;

if (!(hostnameRegex.test(domain) || ipv4Regex.test(domain) || ipv6Regex.test(domain))) {
return false;
}

if (port !== undefined) {
const portNum = Number(port);
if (!/^[0-9]+$/.test(port) || portNum < 1 || portNum > 65535) {
return false;
}
}

return true;
}
export const extractDomainFromMatrixUserId = (mxid: string): string => {
const separatorIndex = mxid.indexOf(':', 1);
if (separatorIndex === -1) {
throw new Error(`Invalid federated username: ${mxid}`);
}
return mxid.substring(separatorIndex + 1);
};

/**
* Extract the username and the servername from a matrix user id
* if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
* otherwise, return the full mxid and the servername
*/
export const getUsernameServername = (mxid: string, serverName: string): [mxid: string, serverName: string, isLocal: boolean] => {
const senderServerName = extractDomainFromMatrixUserId(mxid);
// if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
if (serverName === senderServerName) {
const separatorIndex = mxid.indexOf(':', 1);
if (separatorIndex === -1) {
throw new Error(`Invalid federated username: ${mxid}`);
}
return [mxid.substring(1, separatorIndex), senderServerName, true]; // removers also the @
}

return [mxid, senderServerName, false];
};
/**
* Helper function to create a federated user
*
* Because of historical reasons, we can have users only with federated flag but no federation object
* So we need to upsert the user with the federation object
*/
export async function createOrUpdateFederatedUser(options: { username: string; name?: string; origin: string }): Promise<IUser> {
const { username, name = username, origin } = options;

// TODO: Have a specific method to handle this upsert
const user = await Users.findOneAndUpdate(
{
username,
},
{
$set: {
username,
name: name || username,
type: 'user' as const,
status: UserStatus.OFFLINE,
active: true,
roles: ['user'],
requirePasswordChange: false,
federated: true,
federation: {
version: 1,
mui: username,
origin,
},
_updatedAt: new Date(),
},
$setOnInsert: {
createdAt: new Date(),
},
},
{
upsert: true,
projection: { _id: 1, username: 1 },
returnDocument: 'after',
},
);

if (!user) {
throw new Error(`Failed to create or update federated user: ${username}`);
}

return user;
}

export { generateEd25519RandomSecretKey } from '@rocket.chat/federation-sdk';

export class FederationMatrix extends ServiceClass implements IFederationMatrixService {
protected name = 'federation-matrix';

Expand Down
3 changes: 2 additions & 1 deletion ee/packages/federation-matrix/src/events/member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { federationSDK, type HomeserverEventSignatures, type PduForType } from '
import { Logger } from '@rocket.chat/logger';
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';

import { createOrUpdateFederatedUser, getUsernameServername } from '../FederationMatrix';
import { createOrUpdateFederatedUser } from '../helpers/createOrUpdateFederatedUser';
import { getUsernameServername } from '../helpers/getUsernameServername';

const logger = new Logger('federation-matrix:member');

Expand Down
2 changes: 1 addition & 1 deletion ee/packages/federation-matrix/src/events/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Room } from '@rocket.chat/core-services';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Rooms, Users } from '@rocket.chat/models';

import { getUsernameServername } from '../FederationMatrix';
import { getUsernameServername } from '../helpers/getUsernameServername';

export function room() {
federationSDK.eventEmitterService.on('homeserver.matrix.room.name', async ({ event }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type IUser, UserStatus } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';

/**
* Helper function to create a federated user
*
* Because of historical reasons, we can have users only with federated flag but no federation object
* So we need to upsert the user with the federation object
*/

export async function createOrUpdateFederatedUser(options: { username: string; name?: string; origin: string }): Promise<IUser> {
const { username, name = username, origin } = options;

console.log('createOrUpdateFederatedUser ->', options);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove debug console.log statement.

The console.log statement should be removed before merging to production. Use a proper logger if debugging information is needed.

🔎 Proposed fix
-	console.log('createOrUpdateFederatedUser ->', options);
-
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log('createOrUpdateFederatedUser ->', options);
🤖 Prompt for AI Agents
In ee/packages/federation-matrix/src/helpers/createOrUpdateFederatedUser.ts
around line 14, remove the debug console.log('createOrUpdateFederatedUser ->',
options); statement; if runtime debugging is required replace it with the
project logger (e.g., logger.debug/trace) and ensure sensitive data in options
is not logged, or simply delete the line to avoid console output in production.


// TODO: Have a specific method to handle this upsert
const user = await Users.findOneAndUpdate(
{
username,
},
{
$set: {
username,
name: name || username,
type: 'user' as const,
status: UserStatus.OFFLINE,
active: true,
roles: ['user'],
requirePasswordChange: false,
federated: true,
federation: {
version: 1,
mui: username,
origin,
},
_updatedAt: new Date(),
},
$setOnInsert: {
createdAt: new Date(),
},
},
{
upsert: true,
projection: { _id: 1, username: 1 },
returnDocument: 'after',
},
);

if (!user) {
throw new Error(`Failed to create or update federated user: ${username}`);
}

return user;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const extractDomainFromMatrixUserId = (mxid: string): string => {
const separatorIndex = mxid.indexOf(':', 1);
if (separatorIndex === -1) {
throw new Error(`Invalid federated username: ${mxid}`);
}
return mxid.substring(separatorIndex + 1);
};
21 changes: 21 additions & 0 deletions ee/packages/federation-matrix/src/helpers/getUsernameServername.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { extractDomainFromMatrixUserId } from './extractDomainFromMatrixUserId';

/**
* Extract the username and the servername from a matrix user id
* if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
* otherwise, return the full mxid and the servername
*/

export const getUsernameServername = (mxid: string, serverName: string): [mxid: string, serverName: string, isLocal: boolean] => {
const senderServerName = extractDomainFromMatrixUserId(mxid);
// if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
if (serverName === senderServerName) {
const separatorIndex = mxid.indexOf(':', 1);
if (separatorIndex === -1) {
throw new Error(`Invalid federated username: ${mxid}`);
}
return [mxid.substring(1, separatorIndex), senderServerName, true]; // removers also the @
}

return [mxid, senderServerName, false];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { validateFederatedUsername } from './validateFederatedUsername';

describe('validateFederatedUsername', () => {
describe('invalid formats', () => {
it('should return false when mxid does not start with @', () => {
expect(validateFederatedUsername('user:example.com')).toBe(false);
});

it('should return false when mxid has no colon separator', () => {
expect(validateFederatedUsername('@user')).toBe(false);
});

it('should return false when mxid has empty localpart', () => {
expect(validateFederatedUsername('@:example.com')).toBe(false);
});

it('should return false when localpart contains invalid characters', () => {
expect(validateFederatedUsername('@user@name:example.com')).toBe(false);
expect(validateFederatedUsername('@user#name:example.com')).toBe(false);
});

it('should return false when localpart exceeds 255 characters', () => {
const longLocalpart = 'a'.repeat(256);
expect(validateFederatedUsername(`@${longLocalpart}:example.com`)).toBe(false);
});

it('should return false when domain is invalid', () => {
expect(validateFederatedUsername('@user:invalid_domain')).toBe(false);
expect(validateFederatedUsername('@user:-example.com')).toBe(false);
});

it('should return false when port is invalid', () => {
expect(validateFederatedUsername('@user:example.com:0')).toBe(false);
expect(validateFederatedUsername('@user:example.com:65536')).toBe(false);
expect(validateFederatedUsername('@user:example.com:abc')).toBe(false);
expect(validateFederatedUsername('@user:example.com:-1')).toBe(false);
});
});

describe('valid formats', () => {
it('should return true for basic valid mxid', () => {
expect(validateFederatedUsername('@user:example.com')).toBe(true);
});

it('should return true when localpart contains uppercase letters', () => {
expect(validateFederatedUsername('@User:example.com')).toBe(true);
});

it('should return true for mxid with dots and hyphens in localpart', () => {
expect(validateFederatedUsername('@user.name:example.com')).toBe(true);
expect(validateFederatedUsername('@user-name:example.com')).toBe(true);
expect(validateFederatedUsername('@user_name:example.com')).toBe(true);
});

it('should return true for mxid with encoded characters in localpart', () => {
expect(validateFederatedUsername('@user=2dname:example.com')).toBe(true);
expect(validateFederatedUsername('@user=2Dname:example.com')).toBe(true);
});

it('should return true for mxid with subdomain', () => {
expect(validateFederatedUsername('@user:subdomain.example.com')).toBe(true);
});

it('should return true for mxid with valid port', () => {
expect(validateFederatedUsername('@user:example.com:8008')).toBe(true);
expect(validateFederatedUsername('@user:example.com:1')).toBe(true);
expect(validateFederatedUsername('@user:example.com:65535')).toBe(true);
});

it('should return true for mxid with IPv4 address', () => {
expect(validateFederatedUsername('@user:192.168.1.1')).toBe(true);
expect(validateFederatedUsername('@user:192.168.1.1:8008')).toBe(true);
});

it('should return true for mxid with IPv6 address', () => {
expect(validateFederatedUsername('@user:[::1]')).toBe(true);
expect(validateFederatedUsername('@user:[2001:db8::1]')).toBe(true);
});

it('should return true for mxid with numbers in localpart', () => {
expect(validateFederatedUsername('@user123:example.com')).toBe(true);
expect(validateFederatedUsername('@123user:example.com')).toBe(true);
});

it('should return true for mxid with single character localpart', () => {
expect(validateFederatedUsername('@a:example.com')).toBe(true);
});

it('should return true for mxid with 255 character localpart', () => {
const maxLocalpart = 'a'.repeat(255);
expect(validateFederatedUsername(`@${maxLocalpart}:example.com`)).toBe(true);
});
});
});
Loading
Loading