snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import type { Router } from '@rocket.chat/http-router';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import { logger } from '../../logger';
export type ClientRouter = Router<'/client', any>;
// Logs an error and returns the matching Matrix 500 response. Use inside handler-local
// catch blocks that swallow the error. The same message is used for both the log and
// the response body, avoiding duplication.
export const internalError = (msg: string, err?: unknown, context?: Record<string, unknown>) => {
logger.error({ msg, err, ...context });
return {
statusCode: 500 as const,
body: { errcode: 'M_UNKNOWN', error: msg },
};
};
// The federation SDK throws an Error with name 'UnknownRoomError' (message `Room <id> does not exist`)
// when a room isn't known to this homeserver. The class isn't exported for `instanceof`, so we match on
// `.name` — the same check the SDK uses internally.
export const isUnknownRoomError = (err: unknown): err is Error => err instanceof Error && err.name === 'UnknownRoomError';
// Matrix response for a room this homeserver doesn't know about. Mirrors Synapse: it must be a 4xx (never
// 5xx) so bridges like matrix-bifrost treat it as terminal and stop retrying, instead of hammering the
// endpoint every 100ms as if it were a transient server fault.
export const roomNotFound = () =>
({
statusCode: 403 as const,
body: {
errcode: 'M_FORBIDDEN',
error: "You aren't a member of the room and weren't previously a member of the room.",
},
}) as const;
// Logs a warning and returns the matching Matrix 501 response. Use for endpoints/branches
// that are deliberately not implemented yet, so hits on those paths stay visible in the logs.
export const notImplemented = (msg: string, context?: Record<string, unknown>) => {
logger.warn({ msg, ...context });
return {
statusCode: 501 as const,
body: { errcode: 'M_UNRECOGNIZED', error: msg },
};
};
export const tags = ['Federation'];
export const license: ['federation'] = ['federation'];
export const MATRIX_USER_ID_PATTERN = '^@[A-Za-z0-9_=\\/.+-]+:(.+)$';
export const MATRIX_ROOM_ID_PATTERN = '^![A-Za-z0-9_=\\/.+-]+:(.+)$';
const MatrixErrorSchema = {
type: 'object',
properties: {
errcode: { type: 'string' },
error: { type: 'string' },
},
required: ['errcode', 'error'],
};
export const isMatrixErrorProps = ajv.compile(MatrixErrorSchema);
// Matches only the literal `{}` these endpoints return per spec.
const EmptyObjectResponseSchema = {
type: 'object',
additionalProperties: false,
};
export const isEmptyObjectResponseProps = ajv.compile(EmptyObjectResponseSchema);
const ImpersonationQuerySchema = {
type: 'object',
properties: {
user_id: {
type: 'string',
pattern: MATRIX_USER_ID_PATTERN,
description: 'Matrix user ID to impersonate; must be in the AS user namespace',
},
},
required: [],
};
export const isImpersonationQueryProps = ajvQuery.compile<{ user_id?: string }>(ImpersonationQuerySchema);
const RoomIdParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['roomId'],
};
export const isRoomIdParamsProps = ajv.compile(RoomIdParamsSchema);
const UserIdParamsSchema = {
type: 'object',
properties: {
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
},
required: ['userId'],
};
export const isUserIdParamsProps = ajv.compile(UserIdParamsSchema);
const ProfileFieldParamsSchema = {
type: 'object',
properties: {
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
field: { type: 'string' },
},
required: ['userId', 'field'],
};
export const isProfileFieldParamsProps = ajv.compile(ProfileFieldParamsSchema);

View File

@@ -0,0 +1,218 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { isMatrixErrorProps, license, tags } from './_shared';
import { createOrUpdateFederatedUser } from '../../../helpers/createOrUpdateFederatedUser';
import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from '../../../helpers/parseXmppUserId';
import { validateFederatedUsername } from '../../../helpers/validateFederatedUsername';
import { logger } from '../../logger';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const RegisterBodySchema = {
type: 'object',
properties: {
type: { type: 'string' },
username: { type: 'string' },
},
required: ['type', 'username'],
additionalProperties: true,
};
const isRegisterBodyProps = ajv.compile(RegisterBodySchema);
const RegisterResponseSchema = {
type: 'object',
properties: {
user_id: { type: 'string' },
home_server: { type: 'string' },
access_token: { type: 'string' },
},
required: ['user_id'],
};
const isRegisterResponseProps = ajv.compile(RegisterResponseSchema);
const WhoamiResponseSchema = {
type: 'object',
properties: {
user_id: { type: 'string' },
device_id: { type: 'string' },
is_guest: { type: 'boolean' },
},
required: ['user_id'],
additionalProperties: true,
};
const isWhoamiResponseProps = ajv.compile(WhoamiResponseSchema);
export const addAccountRoutes = (router: ClientRouter) => {
router
// POST /_matrix/client/v3/register
.post(
'/v3/register',
{
body: isRegisterBodyProps,
response: {
200: isRegisterResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const body = await c.req.json();
if (body.type !== 'm.login.application_service') {
return {
statusCode: 400,
body: {
errcode: 'M_FORBIDDEN',
error: 'AS registration requires auth.type=m.login.application_service',
},
};
}
const serverName = federationSDK.getConfig('serverName');
// An application service may only register users that no *other* bridge exclusively claims.
// Registering within its own exclusive namespace is fine; another bridge's is M_EXCLUSIVE.
const appService = c.get('appService') as ReturnType<typeof federationSDK.getRegistrationByAsToken>;
const isReservedByAnotherAppService = (candidate: string): boolean => {
const mxid = candidate.startsWith('@') ? candidate : `@${candidate}:${serverName}`;
const owner = federationSDK.isExclusiveNamespace('users', mxid);
return Boolean(owner && owner.registration._id !== appService?.registration._id);
};
const decoded = decodeXmppUserId(body.username);
if (!isFullXmppUserId(decoded)) {
// The spec defines `username` as the desired localpart; normalize either form to the
// fully-qualified MXID, which is what gets stored and what `user_id` must carry.
const withSigil = body.username.startsWith('@') ? body.username : `@${body.username}`;
const userId: string = withSigil.includes(':') ? withSigil : `${withSigil}:${serverName}`;
if (!validateFederatedUsername(userId)) {
logger.warn({ msg: 'Malformed user id during AS registration', username: body.username });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided user id',
},
};
}
if (isReservedByAnotherAppService(userId)) {
return {
statusCode: 400,
body: {
errcode: 'M_EXCLUSIVE',
error: 'Username is in an exclusive namespace of another application service',
},
};
}
await createOrUpdateFederatedUser({
username: userId,
origin: serverName,
asId: appService?.registration._id,
});
return {
statusCode: 200,
body: {
user_id: userId,
},
};
}
let decodedUsername;
try {
decodedUsername = parseXmppUserId(decoded);
} catch (error) {
logger.warn({ msg: 'Malformed XMPP user id during AS registration', username: body.username, err: error });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided XMPP user id',
},
};
}
if (!decodedUsername.resource) {
logger.warn({ msg: 'Could not derive resource from full XMPP user id during AS registration', username: body.username });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided XMPP user id',
},
};
}
const username = `@${decodedUsername.resource}:${serverName}`;
if (!validateFederatedUsername(username)) {
logger.warn({ msg: 'Malformed user id derived from XMPP user id during AS registration', username: body.username });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided XMPP user id',
},
};
}
if (isReservedByAnotherAppService(username)) {
return {
statusCode: 400,
body: {
errcode: 'M_EXCLUSIVE',
error: 'Username is in an exclusive namespace of another application service',
},
};
}
await createOrUpdateFederatedUser({
username,
origin: serverName,
});
return {
statusCode: 200,
body: {
user_id: username,
},
};
},
)
// GET /_matrix/client/v3/account/whoami
.get(
'/v3/account/whoami',
{
response: {
200: isWhoamiResponseProps,
401: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const username = c.get('impersonatedUserId') as string;
return {
statusCode: 200,
body: {
user_id: username,
},
};
},
);
};

View File

@@ -0,0 +1,60 @@
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
notImplemented,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const RoomAliasParamsSchema = {
type: 'object',
properties: {
roomAlias: { type: 'string' },
},
required: ['roomAlias'],
};
const isRoomAliasParamsProps = ajv.compile(RoomAliasParamsSchema);
const DirectoryPutBodySchema = {
type: 'object',
properties: {
room_id: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['room_id'],
additionalProperties: true,
};
const isDirectoryPutBodyProps = ajv.compile(DirectoryPutBodySchema);
export const addDirectoryRoutes = (router: ClientRouter) => {
router
// PUT /_matrix/client/v3/directory/room/:roomAlias
.put(
'/v3/directory/room/:roomAlias',
{
params: isRoomAliasParamsProps,
query: isImpersonationQueryProps,
body: isDirectoryPutBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async () => {
// TODO(federation-sdk): createAlias(alias, roomId, sender)
return notImplemented('Room alias creation not yet implemented');
},
);
};

View File

@@ -0,0 +1,29 @@
import { Router } from '@rocket.chat/http-router';
import { addAccountRoutes } from './account';
import { addDirectoryRoutes } from './directory';
import { addClientMediaRoutes } from './media';
import { addPresenceRoutes } from './presence';
import { addProfileRoutes } from './profile';
import { addRoomsLifecycleRoutes } from './rooms-lifecycle';
import { addRoomsMessagingRoutes } from './rooms-messaging';
import { addRoomsStateRoutes } from './rooms-state';
import { addUserRoutes } from './user';
import { addVersionsRoutes } from './versions';
export const getClientRoutes = () => {
const router = new Router('/client');
addVersionsRoutes(router);
addAccountRoutes(router);
addProfileRoutes(router);
addPresenceRoutes(router);
addDirectoryRoutes(router);
addRoomsLifecycleRoutes(router);
addRoomsStateRoutes(router);
addRoomsMessagingRoutes(router);
addUserRoutes(router);
addClientMediaRoutes(router);
return router;
};

View File

@@ -0,0 +1,238 @@
import { Readable } from 'node:stream';
import { Upload } from '@rocket.chat/core-services';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { internalError, isMatrixErrorProps, license, tags } from './_shared';
import { MatrixMediaService } from '../../../services/MatrixMediaService';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const MediaParamsSchema = {
type: 'object',
properties: {
serverName: { type: 'string' },
mediaId: { type: 'string' },
},
required: ['serverName', 'mediaId'],
};
const isMediaParamsProps = ajv.compile(MediaParamsSchema);
const ThumbnailQuerySchema = {
type: 'object',
properties: {
// union type lists rather than `oneOf`: ajvQuery coerces between number and string, so both
// `oneOf` branches would match a numeric value and fail validation
width: { type: ['number', 'string'] },
height: { type: ['number', 'string'] },
method: { type: 'string', enum: ['crop', 'scale'] },
timeout_ms: { type: ['number', 'string'] },
},
};
const isThumbnailQueryProps = ajvQuery.compile<{
width?: number | string;
height?: number | string;
method?: 'crop' | 'scale';
timeout_ms?: number | string;
}>(ThumbnailQuerySchema);
const BufferResponseSchema = {
type: 'object',
description: 'multipart/mixed response',
additionalProperties: true,
};
const isBufferResponseProps = ajv.compile(BufferResponseSchema);
const ConfigResponseSchema = {
type: 'object',
properties: {
'm.upload.size': { type: 'number' },
},
additionalProperties: true,
};
const isConfigResponseProps = ajv.compile(ConfigResponseSchema);
// The resize uses sharp with fit 'contain', which upscales past the original size,
// so unbounded dimensions would let a client force arbitrarily large allocations.
const MAX_THUMBNAIL_DIMENSION = 2048;
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'",
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
};
// Builds an RFC 5987-compliant Content-Disposition header. Always emits the
// ASCII-safe `filename=` for legacy clients and additionally emits
// `filename*=UTF-8''…` when the name contains non-ASCII characters.
function contentDispositionHeader(disposition: 'inline' | 'attachment', fileName: string): string {
const asciiFallback = fileName.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '_');
const isAscii = asciiFallback === fileName;
if (isAscii) {
return `${disposition}; filename="${asciiFallback}"`;
}
// RFC 5987 requires percent-encoding `*'()` too, which encodeURIComponent leaves raw;
// a stray `'` is especially bad since it's the delimiter of the charset'lang'value syntax.
const rfc5987Value = encodeURIComponent(fileName).replace(/[*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${rfc5987Value}`;
}
// MSC3916 says authenticated media downloads should be multipart/mixed, but the
// matrix-bot-sdk used by appservice bridges (e.g. matrix-bifrost) doesn't parse
// that envelope — it just streams the response body straight through to the
// downstream client, which then sees raw multipart text. To stay compatible
// with those bridges, we serve raw bytes here, same as the legacy
// /_matrix/media/v3/download endpoint.
export const addClientMediaRoutes = (router: ClientRouter) => {
router
// GET /_matrix/client/v1/media/download/:serverName/:mediaId
.get(
'/v1/media/download/:serverName/:mediaId',
{
params: isMediaParamsProps,
response: {
200: isBufferResponseProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
try {
const serverName = c.req.param('serverName') as string;
const mediaId = c.req.param('mediaId') as string;
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
if (!file) {
return {
statusCode: 404,
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
};
}
const buffer = await MatrixMediaService.getLocalFileBuffer(file);
const mimeType = file.type || 'application/octet-stream';
const fileName = file.name || mediaId;
return {
statusCode: 200,
headers: {
...SECURITY_HEADERS,
'content-type': mimeType,
'content-length': String(buffer.length),
'content-disposition': contentDispositionHeader('attachment', fileName),
},
body: buffer,
};
} catch (error) {
return internalError('Failed to download media', error);
}
},
)
// GET /_matrix/client/v1/media/thumbnail/:serverName/:mediaId
.get(
'/v1/media/thumbnail/:serverName/:mediaId',
{
params: isMediaParamsProps,
query: isThumbnailQueryProps,
response: {
200: isBufferResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
try {
const serverName = c.req.param('serverName') as string;
const mediaId = c.req.param('mediaId') as string;
const width = Number(c.req.query('width'));
const height = Number(c.req.query('height'));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return {
statusCode: 400,
body: { errcode: 'M_BAD_REQUEST', error: 'Invalid width or height' },
};
}
if (width > MAX_THUMBNAIL_DIMENSION || height > MAX_THUMBNAIL_DIMENSION) {
return {
statusCode: 400,
body: { errcode: 'M_BAD_REQUEST', error: 'Requested dimensions exceed maximum allowed' },
};
}
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
if (!file) {
return {
statusCode: 404,
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
};
}
if (!file.type?.startsWith('image/')) {
return {
statusCode: 400,
body: { errcode: 'M_BAD_REQUEST', error: 'Thumbnails are only supported for images' },
};
}
// `method` is validated but not honored: thumbnails are always scaled (the spec
// default), never cropped. Spec-wise thumbnailing is best-effort, so serving a
// scaled image for a `crop` request is acceptable.
const stream = await Upload.streamUploadedFile({ file, imageResizeOpts: { width, height } });
const mimeType = file.type || 'image/jpeg';
const fileName = file.name || mediaId;
return {
statusCode: 200,
headers: {
...SECURITY_HEADERS,
'content-type': mimeType,
'content-disposition': contentDispositionHeader('inline', fileName),
},
body: Readable.toWeb(stream),
};
} catch (error) {
return internalError('Failed to generate media thumbnail', error);
}
},
)
// GET /_matrix/client/v1/media/config
.get(
'/v1/media/config',
{
response: {
200: isConfigResponseProps,
401: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async () => {
return {
statusCode: 200,
body: {
'm.upload.size': 50 * 1024 * 1024,
},
};
},
);
};

View File

@@ -0,0 +1,45 @@
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { isMatrixErrorProps, isUserIdParamsProps, license, tags } from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const PresenceResponseSchema = {
type: 'object',
properties: {
presence: { type: 'string', enum: ['online', 'offline', 'unavailable'] },
last_active_ago: { type: 'number' },
status_msg: { type: 'string' },
currently_active: { type: 'boolean' },
},
required: ['presence'],
additionalProperties: true,
};
const isPresenceResponseProps = ajv.compile(PresenceResponseSchema);
export const addPresenceRoutes = (router: ClientRouter) => {
router.get(
'/v3/presence/:userId/status',
{
params: isUserIdParamsProps,
response: {
200: isPresenceResponseProps,
401: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async () => {
// TODO(federation-sdk): expose presence service via federationSDK.getPresence(userId)
return {
statusCode: 200,
body: {
presence: 'offline',
},
};
},
);
};

View File

@@ -0,0 +1,238 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Users } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
internalError,
notImplemented,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
isProfileFieldParamsProps,
isUserIdParamsProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const ProfileGetResponseSchema = {
type: 'object',
properties: {
displayname: { type: 'string', nullable: true },
avatar_url: { type: 'string', nullable: true },
},
additionalProperties: true,
};
const isProfileGetResponseProps = ajv.compile(ProfileGetResponseSchema);
const DisplaynameBodySchema = {
type: 'object',
properties: {
displayname: { type: 'string', nullable: true },
},
required: ['displayname'],
};
const isDisplaynameBodyProps = ajv.compile(DisplaynameBodySchema);
const AvatarUrlBodySchema = {
type: 'object',
properties: {
avatar_url: { type: 'string', nullable: true },
},
required: ['avatar_url'],
};
const isAvatarUrlBodyProps = ajv.compile(AvatarUrlBodySchema);
const ALLOWED_PROFILE_FIELDS = ['displayname', 'avatar_url'];
export const addProfileRoutes = (router: ClientRouter) => {
router
// GET /_matrix/client/v3/profile/:userId
.get(
'/v3/profile/:userId',
{
params: isUserIdParamsProps,
response: {
200: isProfileGetResponseProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
try {
// TODO maybe this can be a query to our models instead of going through the federation-sdk
const profile = await federationSDK.queryProfile(userId);
if (!profile) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Profile not found',
},
};
}
return {
statusCode: 200,
body: {
displayname: profile.displayname,
...(profile.avatar_url ? { avatar_url: profile.avatar_url } : {}),
},
};
} catch (error) {
return internalError('Failed to fetch profile', error, { userId });
}
},
)
// GET /_matrix/client/v3/profile/:userId/:field
.get(
'/v3/profile/:userId/:field',
{
params: isProfileFieldParamsProps,
response: {
200: isProfileGetResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
const field = c.req.param('field');
if (!field || !ALLOWED_PROFILE_FIELDS.includes(field)) {
return {
statusCode: 400,
body: { errcode: 'M_INVALID_PARAM', error: 'Unknown profile field' },
};
}
try {
// TODO maybe this can be a query to our models instead of going through the federation-sdk
const profile = await federationSDK.queryProfile(userId);
if (!profile) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Profile not found',
},
};
}
return {
statusCode: 200,
body: {
[field]: profile[field as keyof typeof profile],
},
};
} catch (error) {
return internalError('Failed to fetch profile', error, { userId, field });
}
},
)
// PUT /_matrix/client/v3/profile/:userId/displayname
.put(
'/v3/profile/:userId/displayname',
{
params: isUserIdParamsProps,
query: isImpersonationQueryProps,
body: isDisplaynameBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
const username = c.get('impersonatedUserId') as string;
// A bridge always targets the user it impersonates, so user_id mirrors the path
// param (both in packed form for XMPP puppets). The exception is the bot setting
// its own displayname, where user_id is omitted and impersonatedUserId holds the
// bot's MXID verbatim.
if (userId !== (c.req.query('user_id') ?? username)) {
return {
statusCode: 403,
body: { errcode: 'M_FORBIDDEN', error: 'Cannot set the displayname of another user' },
};
}
const body = await c.req.json();
const user = await Users.findOneByUsername(username);
if (!user) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
await Users.setName(user._id, body.displayname);
return {
statusCode: 200,
body: {},
};
},
)
// PUT /_matrix/client/v3/profile/:userId/avatar_url
.put(
'/v3/profile/:userId/avatar_url',
{
params: isUserIdParamsProps,
query: isImpersonationQueryProps,
body: isAvatarUrlBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
const username = c.get('impersonatedUserId') as string;
const user = await Users.findOneByUsername(username);
if (!user) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
// TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms
return notImplemented('Global profile update not yet implemented', { userId });
},
);
};

View File

@@ -0,0 +1,378 @@
import { Room } from '@rocket.chat/core-services';
import type { IRoomNativeFederated } from '@rocket.chat/core-typings';
import type { RoomID, UserID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Rooms, Users } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
MATRIX_USER_ID_PATTERN,
internalError,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
isRoomIdParamsProps,
license,
notImplemented,
tags,
} from './_shared';
import { getFederatedRoomName } from '../../../helpers/getFederatedRoomName';
import { logger } from '../../logger';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const CreateRoomBodySchema = {
type: 'object',
properties: {
room_alias_name: { type: 'string' },
name: { type: 'string' },
topic: { type: 'string' },
visibility: { type: 'string', enum: ['public', 'private'] },
preset: { type: 'string', enum: ['private_chat', 'trusted_private_chat', 'public_chat'] },
invite: {
type: 'array',
items: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
},
is_direct: { type: 'boolean' },
initial_state: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string' },
content: { type: 'object' },
},
required: ['type', 'content'],
additionalProperties: true,
},
},
},
additionalProperties: true,
};
const isCreateRoomBodyProps = ajv.compile(CreateRoomBodySchema);
const CreateRoomResponseSchema = {
type: 'object',
properties: {
room_id: { type: 'string' },
room_alias: { type: 'string' },
},
required: ['room_id'],
};
const isCreateRoomResponseProps = ajv.compile(CreateRoomResponseSchema);
const JoinParamsSchema = {
type: 'object',
properties: {
roomIdOrAlias: { type: 'string' },
},
required: ['roomIdOrAlias'],
};
const isJoinParamsProps = ajv.compile(JoinParamsSchema);
const JoinResponseSchema = {
type: 'object',
properties: {
room_id: { type: 'string' },
},
required: ['room_id'],
};
const isJoinResponseProps = ajv.compile(JoinResponseSchema);
const RoomLeaveBodySchema = {
type: 'object',
properties: {
reason: { type: 'string' },
},
additionalProperties: true,
};
const isRoomLeaveBodyProps = ajv.compile(RoomLeaveBodySchema);
const InviteBodySchema = {
type: 'object',
properties: {
user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
reason: { type: 'string' },
},
required: ['user_id'],
additionalProperties: true,
};
const isInviteBodyProps = ajv.compile(InviteBodySchema);
const KickBodySchema = {
type: 'object',
properties: {
user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
reason: { type: 'string' },
},
required: ['user_id'],
additionalProperties: true,
};
const isKickBodyProps = ajv.compile(KickBodySchema);
const RoomLeaveParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['roomId'],
};
const isRoomLeaveParamsProps = ajv.compile(RoomLeaveParamsSchema);
export const addRoomsLifecycleRoutes = (router: ClientRouter) => {
router
// POST /_matrix/client/v3/createRoom
.post(
'/v3/createRoom',
{
query: isImpersonationQueryProps,
body: isCreateRoomBodyProps,
response: {
200: isCreateRoomResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
const serverName = federationSDK.getConfig('serverName');
const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } });
if (!user) {
// Mirrors Synapse: 4xx so bridges treat it as terminal (register the user and retry)
// instead of hammering a 5xx as a transient fault.
return {
statusCode: 403,
body: { errcode: 'M_FORBIDDEN', error: 'Application service has not registered this user' },
};
}
// The human-facing name supplied by the Matrix client, which may be empty or
// contain characters RC does not allow in a room slug.
const displayName = body.name || body.room_alias_name || '';
// get join room from initial_state (for now since this is what bifrost sends)
const joinRule =
body.initial_state?.find((e: any) => e.type === 'm.room.join_rules')?.content?.join_rule === 'public' ? 'public' : 'invite';
try {
const result = await federationSDK.createRoomV2({
name: displayName,
alias: body.room_alias_name,
owner: senderUsername,
joinRule,
});
// TODO after creating the federated room we must create the room for rocket.chat as well
const room = await Rooms.findOne({ 'federation.mrid': result.room_id });
if (!room) {
// Derive the RC name (slug) from the Matrix room id rather than the supplied
// name, which may be empty or contain characters RC rejects. Mirrors the invite
// flow in events/member.ts and keeps the human-facing name in `fname`.
const name = getFederatedRoomName(result.room_id);
await Room.create<IRoomNativeFederated>(user._id, {
type: joinRule === 'public' ? 'c' : 'p',
name,
members: [senderUsername],
options: {
forceNew: true, // an invite means the room does not exist yet
creator: user._id,
},
extraData: {
federated: true,
federation: {
version: 1,
mrid: result.room_id,
origin: serverName,
},
fname: displayName || name,
},
});
}
for (const invitee of (body.invite ?? []) as string[]) {
try {
await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderUsername, body.is_direct);
} catch (err) {
// The room already exists at this point; failing the whole request would make
// the caller believe creation failed and retry, duplicating the room.
logger.error({ msg: 'Failed to invite user to newly created room', invitee, roomId: result.room_id, err });
}
}
return {
statusCode: 200,
body: {
room_id: result.room_id,
},
};
} catch (error) {
return internalError('Failed to create room', error);
}
},
)
// POST /_matrix/client/v3/join/:roomIdOrAlias
.post(
'/v3/join/:roomIdOrAlias',
{
params: isJoinParamsProps,
query: isImpersonationQueryProps,
response: {
200: isJoinResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomIdOrAlias = c.req.param('roomIdOrAlias') as string;
// TODO(federation-sdk): expose alias resolution so this endpoint can also accept room aliases
if (!roomIdOrAlias.startsWith('!')) {
return notImplemented('Joining a room by alias is not yet implemented', { roomIdOrAlias });
}
const roomId = roomIdOrAlias as RoomID;
await federationSDK.joinUser(roomId, c.get('impersonatedUserId'));
return {
statusCode: 200,
body: {
room_id: roomId,
},
};
},
)
// POST /_matrix/client/v3/rooms/:roomId/leave
.post(
'/v3/rooms/:roomId/leave',
{
params: isRoomLeaveParamsProps,
query: isImpersonationQueryProps,
body: isRoomLeaveBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as UserID;
try {
await federationSDK.leaveRoom(roomId, senderUsername);
return {
statusCode: 200,
body: {},
};
} catch (error: any) {
if (error?.message?.toLowerCase?.().includes('not found')) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Room not found',
},
};
}
return internalError('Failed to leave room', error, { roomId, senderId: senderUsername });
}
},
)
// POST /_matrix/client/v3/rooms/:roomId/invite
.post(
'/v3/rooms/:roomId/invite',
{
params: isRoomIdParamsProps,
query: isImpersonationQueryProps,
body: isInviteBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
try {
await federationSDK.inviteUserToRoom(body.user_id as UserID, roomId, senderUsername);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to invite user', error, { roomId, senderUsername });
}
},
)
// POST /_matrix/client/v3/rooms/:roomId/kick
.post(
'/v3/rooms/:roomId/kick',
{
params: isRoomIdParamsProps,
query: isImpersonationQueryProps,
body: isKickBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
try {
await federationSDK.kickUser(roomId, body.user_id as UserID, senderUsername, body.reason);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to kick user', error, { roomId, senderUsername });
}
},
);
};

View File

@@ -0,0 +1,418 @@
import { api, FederationMatrix, Room } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { isUserNativeFederated } from '@rocket.chat/core-typings';
import type { EventID, FileMessageContent, FileMessageType, PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Rooms, Users } from '@rocket.chat/models';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
MATRIX_USER_ID_PATTERN,
internalError,
notImplemented,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
isRoomIdParamsProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const SendEventParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
eventType: { type: 'string' },
txnId: { type: 'string' },
},
required: ['roomId', 'eventType', 'txnId'],
};
const isSendEventParamsProps = ajv.compile(SendEventParamsSchema);
const SendEventBodySchema = {
type: 'object',
additionalProperties: true,
};
const isSendEventBodyProps = ajv.compile(SendEventBodySchema);
const SendEventResponseSchema = {
type: 'object',
properties: {
event_id: { type: 'string' },
},
required: ['event_id'],
};
const isSendEventResponseProps = ajv.compile(SendEventResponseSchema);
const MessagesQuerySchema = {
type: 'object',
properties: {
user_id: { type: 'string' },
from: { type: 'string' },
to: { type: 'string' },
dir: { type: 'string', enum: ['b', 'f'] },
// a union type list rather than `oneOf`: ajvQuery coerces between number and string, so both
// `oneOf` branches would match a numeric value and fail validation
limit: { type: ['number', 'string'] },
filter: { type: 'string' },
},
};
const isMessagesQueryProps = ajvQuery.compile<{
user_id?: string;
from?: string;
to?: string;
dir?: 'b' | 'f';
limit?: number | string;
filter?: string;
}>(MessagesQuerySchema);
const MessagesResponseSchema = {
type: 'object',
properties: {
chunk: { type: 'array', items: { type: 'object', additionalProperties: true } },
start: { type: 'string' },
end: { type: 'string' },
},
additionalProperties: true,
};
const isMessagesResponseProps = ajv.compile(MessagesResponseSchema);
const TypingParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
},
required: ['roomId', 'userId'],
};
const isTypingParamsProps = ajv.compile(TypingParamsSchema);
const TypingBodySchema = {
type: 'object',
properties: {
typing: { type: 'boolean' },
timeout: { type: 'number' },
},
required: ['typing'],
additionalProperties: true,
};
const isTypingBodyProps = ajv.compile(TypingBodySchema);
const ReceiptParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
eventId: { type: 'string' },
},
required: ['roomId', 'eventId'],
};
const isReceiptParamsProps = ajv.compile(ReceiptParamsSchema);
const ReceiptBodySchema = {
type: 'object',
additionalProperties: true,
};
const isReceiptBodyProps = ajv.compile(ReceiptBodySchema);
export const addRoomsMessagingRoutes = (router: ClientRouter) => {
router
// PUT /_matrix/client/v3/rooms/:roomId/send/:eventType/:txnId
.put(
'/v3/rooms/:roomId/send/:eventType/:txnId',
{
params: isSendEventParamsProps,
query: isImpersonationQueryProps,
body: isSendEventBodyProps,
response: {
200: isSendEventResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType');
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
if (eventType === 'org.matrix.bridge.ping') {
try {
const event = await federationSDK.sendCustomEvent(roomId, eventType, body, senderUsername);
return {
statusCode: 200,
body: {
event_id: event.eventId,
},
};
} catch (error) {
return internalError('Failed to send ping event', error, { roomId, senderUsername });
}
}
if (eventType !== 'm.room.message') {
// TODO: support additional event types (m.reaction, m.room.redaction, etc.)
return notImplemented('Only m.room.message is supported in v1', { eventType });
}
if (typeof body.body !== 'string' || typeof body.msgtype !== 'string') {
return {
statusCode: 400,
body: {
errcode: 'M_BAD_JSON',
error: 'm.room.message requires string fields body and msgtype',
},
};
}
const fileMsgtypes: FileMessageType[] = ['m.image', 'm.file', 'm.audio', 'm.video'];
const isFileMessage = fileMsgtypes.includes(body.msgtype);
if (isFileMessage && typeof body.url !== 'string') {
return {
statusCode: 400,
body: {
errcode: 'M_BAD_JSON',
error: `${body.msgtype} requires a string url field`,
},
};
}
// TODO: deduplicate by txnId to handle bridge retries
try {
if (isFileMessage) {
const fileContent: FileMessageContent = {
body: body.body,
msgtype: body.msgtype,
url: body.url,
info: body.info,
};
const event = await federationSDK.sendFileMessage(roomId, fileContent, senderUsername);
await FederationMatrix.saveFederationMessage({
event: event.event as PduForType<'m.room.message'>,
event_id: event.eventId,
});
return {
statusCode: 200,
body: {
event_id: event.eventId,
},
};
}
const event = await federationSDK.sendMessage(roomId, body.body, body.formatted_body ?? body.body, senderUsername);
await FederationMatrix.saveFederationMessage({ event: event.event as PduForType<'m.room.message'>, event_id: event.eventId });
return {
statusCode: 200,
body: {
event_id: event.eventId,
},
};
} catch (error) {
return internalError('Failed to send message', error, { roomId });
}
},
)
// GET /_matrix/client/v3/rooms/:roomId/messages
.get(
'/v3/rooms/:roomId/messages',
{
params: isRoomIdParamsProps,
query: isMessagesQueryProps,
response: {
200: isMessagesResponseProps,
401: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const fromParam = c.req.query('from');
const limitParam = c.req.query('limit');
const limit = limitParam ? Number(limitParam) || 10 : 10;
try {
if (!fromParam || limit <= 0) {
return {
statusCode: 200,
body: {
chunk: [],
start: '',
end: '',
},
};
}
const result = await federationSDK.getBackfillEvents(roomId, [fromParam] as EventID[], limit);
return {
statusCode: 200,
body: {
chunk: result.pdus,
start: fromParam,
end: '',
},
};
} catch (error) {
return internalError('Failed to fetch messages', error, { roomId });
}
},
)
// PUT /_matrix/client/v3/rooms/:roomId/typing/:userId
.put(
'/v3/rooms/:roomId/typing/:userId',
{
params: isTypingParamsProps,
query: isImpersonationQueryProps,
body: isTypingBodyProps,
response: {
200: isEmptyObjectResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const username = c.get('impersonatedUserId');
const body = await c.req.json();
if (!username) {
return {
statusCode: 400,
body: {
errcode: 'M_BAD_REQUEST',
error: 'Missing userId parameter',
},
};
}
try {
const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
if (!matrixRoom) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Room not found',
},
};
}
const user = await Users.findOneByUsername<Pick<IUser, 'name' | 'username' | 'federated' | 'federation'>>(username, {
projection: { name: 1, username: 1, federated: 1, federation: 1 },
});
if (!user || !isUserNativeFederated(user)) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
void api.broadcast('user.activity', {
user: user.name || user.username,
isTyping: body.typing,
roomId: matrixRoom._id,
});
await federationSDK.sendTypingNotification(roomId, username, body.typing === true);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to send typing notification', error, { roomId, userId: username });
}
},
)
// POST /_matrix/client/v3/rooms/:roomId/receipt/m.read/:eventId
.post(
'/v3/rooms/:roomId/receipt/m.read/:eventId',
{
params: isReceiptParamsProps,
query: isImpersonationQueryProps,
body: isReceiptBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as string;
try {
const matrixUser = await Users.findOneByUsername(senderUsername);
if (!matrixUser) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId });
if (!matrixRoom) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Room not found',
},
};
}
await Room.markAsRead(matrixRoom, matrixUser._id);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to send read receipt', error, { roomId, senderUsername });
}
},
);
};

View File

@@ -0,0 +1,348 @@
import type { PersistentEventBase, RoomID, UserID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
internalError,
isUnknownRoomError,
notImplemented,
roomNotFound,
isImpersonationQueryProps,
isMatrixErrorProps,
isRoomIdParamsProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const JoinedMembersResponseSchema = {
type: 'object',
properties: {
joined: {
type: 'object',
additionalProperties: {
type: 'object',
properties: {
display_name: { type: 'string', nullable: true },
avatar_url: { type: 'string', nullable: true },
},
additionalProperties: true,
},
},
},
required: ['joined'],
};
const isJoinedMembersResponseProps = ajv.compile(JoinedMembersResponseSchema);
const StateArrayResponseSchema = {
type: 'array',
items: { type: 'object', additionalProperties: true },
};
const isStateArrayResponseProps = ajv.compile(StateArrayResponseSchema);
const StateEventParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
eventType: { type: 'string' },
stateKey: { type: 'string' },
},
required: ['roomId', 'eventType'],
};
const isStateEventParamsProps = ajv.compile(StateEventParamsSchema);
const StateContentResponseSchema = {
type: 'object',
additionalProperties: true,
};
const isStateContentResponseProps = ajv.compile(StateContentResponseSchema);
const PutStateBodySchema = {
type: 'object',
additionalProperties: true,
};
const isPutStateBodyProps = ajv.compile(PutStateBodySchema);
const PutStateResponseSchema = {
type: 'object',
properties: {
event_id: { type: 'string' },
},
required: ['event_id'],
};
const isPutStateResponseProps = ajv.compile(PutStateResponseSchema);
const getRoomStateEvent = async (roomId: RoomID, eventType: string, stateKey = '') => {
try {
const state = await federationSDK.getLatestRoomState(roomId);
const key = `${eventType}:${stateKey}`;
let pe: PersistentEventBase | undefined;
for (const [k, v] of state) {
if (k === key) {
pe = v;
break;
}
}
if (!pe) {
return {
statusCode: 404 as const,
body: {
errcode: 'M_NOT_FOUND',
error: 'State event not found',
},
};
}
return {
statusCode: 200 as const,
body: pe.getContent(),
};
} catch (error) {
if (isUnknownRoomError(error)) {
return roomNotFound();
}
return internalError('Failed to fetch state event', error);
}
};
const putRoomStateEvent = async (
roomId: RoomID,
eventType: string,
senderUsername: UserID,
body: Record<string, unknown>,
stateKey = '',
) => {
try {
// The supported event types are all empty-state-key events per spec; writing them in
// response to a keyed request would silently target the wrong state.
if (stateKey) {
return notImplemented('State events with a non-empty state key are not yet implemented', { roomId, eventType, stateKey });
}
if (eventType === 'm.room.name' && typeof body.name === 'string') {
const event = await federationSDK.updateRoomName(roomId, body.name, senderUsername);
return {
statusCode: 200 as const,
body: { event_id: event.eventId },
};
}
if (eventType === 'm.room.topic' && typeof body.topic === 'string') {
const event = await federationSDK.setRoomTopic(roomId, senderUsername, body.topic);
return {
statusCode: 200 as const,
body: { event_id: event.eventId },
};
}
// TODO: extend SDK to send arbitrary state events
return notImplemented(`State event type ${eventType} not yet implemented`, { roomId, eventType, senderUsername });
} catch (error) {
return internalError('Failed to send state event', error, { roomId, eventType, senderUsername });
}
};
export const addRoomsStateRoutes = (router: ClientRouter) => {
router
// GET /_matrix/client/v3/rooms/:roomId/joined_members
.get(
'/v3/rooms/:roomId/joined_members',
{
params: isRoomIdParamsProps,
response: {
200: isJoinedMembersResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
try {
const state = await federationSDK.getLatestRoomState(roomId);
const joined: Record<string, { display_name?: string; avatar_url?: string }> = {};
for (const [key, pe] of state) {
if (!key.startsWith('m.room.member:')) continue;
const content = pe.getContent() as { membership?: string; displayname?: string; avatar_url?: string };
if (content?.membership !== 'join') continue;
const userId = pe.stateKey;
if (!userId) continue;
joined[userId] = {
...(content.displayname ? { display_name: content.displayname } : {}),
...(content.avatar_url ? { avatar_url: content.avatar_url } : {}),
};
}
return {
statusCode: 200,
body: { joined },
};
} catch (error) {
if (isUnknownRoomError(error)) {
return roomNotFound();
}
return internalError('Failed to fetch joined members', error, { roomId });
}
},
)
// GET /_matrix/client/v3/rooms/:roomId/state
.get(
'/v3/rooms/:roomId/state',
{
params: isRoomIdParamsProps,
response: {
200: isStateArrayResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
try {
const state = await federationSDK.getLatestRoomState(roomId);
const events: unknown[] = [];
for (const pe of state.values()) {
events.push(pe.event);
}
return {
statusCode: 200,
body: events,
};
} catch (error) {
if (isUnknownRoomError(error)) {
return roomNotFound();
}
return internalError('Failed to fetch room state', error, { roomId });
}
},
)
// GET /_matrix/client/v3/rooms/:roomId/state/:eventType/
// The optional-param route below (`:stateKey?`) matches `/state/:eventType` and
// `/state/:eventType/:stateKey`, but NOT the trailing-slash form `/state/:eventType/`
// (an empty final segment). Bridges request that trailing-slash URL for the
// default (empty) state key, so this explicit route handles it with stateKey=''.
.get(
'/v3/rooms/:roomId/state/:eventType/',
{
params: isStateEventParamsProps,
response: {
200: isStateContentResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
return getRoomStateEvent(roomId, eventType);
},
)
// GET /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey?
.get(
'/v3/rooms/:roomId/state/:eventType/:stateKey?',
{
params: isStateEventParamsProps,
response: {
200: isStateContentResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
const stateKey = c.req.param('stateKey') ?? '';
return getRoomStateEvent(roomId, eventType, stateKey);
},
)
// PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/
// Same trailing-slash note as the GET above: the optional-param route (`:stateKey?`)
// doesn't match an empty final segment, so this explicit route handles it with stateKey=''.
.put(
'/v3/rooms/:roomId/state/:eventType/',
{
params: isStateEventParamsProps,
query: isImpersonationQueryProps,
body: isPutStateBodyProps,
response: {
200: isPutStateResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
return putRoomStateEvent(roomId, eventType, senderUsername, body);
},
)
// PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey?
// The state key is optional per spec: `PUT /state/:eventType` targets the empty state key.
.put(
'/v3/rooms/:roomId/state/:eventType/:stateKey?',
{
params: isStateEventParamsProps,
query: isImpersonationQueryProps,
body: isPutStateBodyProps,
response: {
200: isPutStateResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
const stateKey = c.req.param('stateKey') ?? '';
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
return putRoomStateEvent(roomId, eventType, senderUsername, body, stateKey);
},
);
};

View File

@@ -0,0 +1,87 @@
import type { RoomID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Users } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
MATRIX_USER_ID_PATTERN,
internalError,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const AccountDataDisplaynameParamsSchema = {
type: 'object',
properties: {
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['userId', 'roomId'],
};
const isAccountDataDisplaynameParamsProps = ajv.compile(AccountDataDisplaynameParamsSchema);
const AccountDataDisplaynameBodySchema = {
type: 'object',
properties: {
displayname: { type: 'string', nullable: true },
},
additionalProperties: true,
};
const isAccountDataDisplaynameBodyProps = ajv.compile(AccountDataDisplaynameBodySchema);
export const addUserRoutes = (router: ClientRouter) => {
router.put(
'/v3/user/:userId/rooms/:roomId/account_data/m.room.displayname',
{
params: isAccountDataDisplaynameParamsProps,
query: isImpersonationQueryProps,
body: isAccountDataDisplaynameBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as string;
const body = await c.req.json();
const user = await Users.findOneByUsername(senderUsername);
if (!user) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
try {
await federationSDK.updateUserProfile(roomId, senderUsername, {
displayname: body.displayname ?? undefined,
});
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to update per-room displayname', error, { roomId, senderUsername });
}
},
);
};

View File

@@ -0,0 +1,38 @@
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { license, tags } from './_shared';
const VersionsResponseSchema = {
type: 'object',
properties: {
versions: {
type: 'array',
items: { type: 'string' },
},
unstable_features: {
type: 'object',
additionalProperties: { type: 'boolean' },
},
},
required: ['versions'],
};
const isVersionsResponseProps = ajv.compile(VersionsResponseSchema);
export const addVersionsRoutes = (router: ClientRouter) => {
router.get(
'/versions',
{
response: { 200: isVersionsResponseProps },
tags,
license,
},
async () => ({
body: {
versions: ['v1.4'],
},
statusCode: 200,
}),
);
};