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
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:
114
ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts
Normal file
114
ee/packages/federation-matrix/src/api/_matrix/client/_shared.ts
Normal 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);
|
||||
218
ee/packages/federation-matrix/src/api/_matrix/client/account.ts
Normal file
218
ee/packages/federation-matrix/src/api/_matrix/client/account.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
238
ee/packages/federation-matrix/src/api/_matrix/client/media.ts
Normal file
238
ee/packages/federation-matrix/src/api/_matrix/client/media.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
238
ee/packages/federation-matrix/src/api/_matrix/client/profile.ts
Normal file
238
ee/packages/federation-matrix/src/api/_matrix/client/profile.ts
Normal 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 });
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
87
ee/packages/federation-matrix/src/api/_matrix/client/user.ts
Normal file
87
ee/packages/federation-matrix/src/api/_matrix/client/user.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user