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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (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,61 @@
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import type { ValidateFunction } from 'ajv';
import type { APIClass, ExtractRoutesFromAPI } from './ApiClass';
type Expect<T extends true> = T;
type ShallowEqual<X, Y> = X extends Y ? (Y extends X ? true : false) : false;
type API = APIClass<
'/v1',
{
method: 'GET';
path: '/v1/endpoint.test';
response: {
200: ValidateFunction<
PaginatedResult<{
sounds: string[];
}>
>;
};
query: ValidateFunction<{
query: string;
}>;
authRequired: true;
}
>;
it('Should return the expected type', () => {
type test = Expect<
ShallowEqual<
ReturnType<ExtractRoutesFromAPI<API>['/v1/endpoint.test']['GET']>,
{
count: number;
offset: number;
total: number;
sounds: string[];
}
>
>;
true as test;
});
describe('ExtractRoutesFromAPI', () => {
it('Should extract correct function signature when query is not present', () => {
type APIWithNeverQuery = APIClass<
'/v1',
{
method: 'GET';
path: '/v1/endpoint.test';
response: {
200: ValidateFunction<unknown>;
};
authRequired: true;
}
>;
type ExpectedFunctionSignature = Expect<
ShallowEqual<ExtractRoutesFromAPI<APIWithNeverQuery>['/v1/endpoint.test']['GET'], () => unknown>
>;
true as ExpectedFunctionSignature;
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
# API Development Guidelines
## Creating Automatic OpenAPI Specifications
When developing new endpoints or modifying existing ones, follow these guidelines to ensure automatic OpenAPI specification generation and request/response validation.
### Using Status Codes and AJV Schema
1. **Status Codes**: Ensure that each response from your endpoint includes the appropriate HTTP status code.
2. **AJV Schema Validation**: Use the AJV schema to validate the response structure. This ensures that the response adheres to the defined schema.
3. **Query and Body Parameters**: Use the new `query` and `body` parameters to validate the inputs to your endpoints. This ensures that the incoming request data adheres to the defined schema.
### Steps to Follow
1. **Define the Schema**: Create a schema for your endpoint response using AJV. This schema will be used to validate the response structure.
2. **Optional Input Validation**: Optionally, you can validate your input parameters (query and body) using AJV schemas. This helps ensure that the incoming request data adheres to the defined schema.
3. **Validation in Test Environments**: When an endpoint is created or changed, the response will be validated in TEST environments. If the response does not match the schema, an error will be thrown.
### Deprecation Notice
The `addRoute` method is now deprecated. Instead, use the following methods available inside the API instance:
- `.get`
- `.post`
- `.put`
- `.delete`
These methods provide a more structured and clear approach to defining your API endpoints.
The `validateParams` property is now deprecated. Instead, use the `query` and `body` properties to validate the inputs to your endpoints. This change provides a more structured and clear approach to input validation.
### Example
Here is an example of how to define an endpoint using the new methods and schema validation:
```typescript
import { API } from 'path/to/api';
import Ajv from 'ajv';
const ajv = new Ajv();
API.v1
.get(
'endpoint-name',
{
authRequired: true,
query: ajv.compile({
type: 'object',
properties: {
param1: { type: 'string' },
param2: { type: 'number' },
},
required: ['param1'],
}),
body: ajv.compile({
type: 'object',
properties: {
field1: { type: 'string' },
field2: { type: 'boolean' },
},
required: ['field1'],
}),
response: {
200: ajv.compile({
additionalProperties: false,
type: 'object',
properties: {
count: {
type: 'number',
description: 'The number of sounds returned in this response.',
},
offset: {
type: 'number',
description: 'The number of sounds that were skipped in this response.',
},
total: {
type: 'number',
description: 'The total number of sounds that match the query.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
items: {
type: 'array',
items: {
type: 'object',
properties: {
_id: {
type: 'string',
},
prop1: {
type: 'number',
},
prop2: {
type: 'string',
},
prop3: {
type: 'string',
},
},
required: ['_id', 'prop1', 'prop2', 'prop3'],
},
},
},
}),
401: ajv.compile({
additionalProperties: false,
type: 'object',
properties: {
error: {
type: 'string',
},
status: {
type: 'string',
nullable: true,
},
message: {
type: 'string',
nullable: true,
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success', 'error'],
}),
},
},
async function action() {
const result = await anyLogic();
return API.v1.success(result);
},
)
```
By following these guidelines, you ensure that your API endpoints are well-documented, validated, and maintainable.

View File

@@ -0,0 +1,118 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { ActionThis } from './definition';
import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../lib/authorization/hasPermission';
import type { DeprecationLoggerNextPlannedVersion } from '../lib/deprecationWarningLogger';
import { apiDeprecationLogger } from '../lib/deprecationWarningLogger';
type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | '*';
export type PermissionsPayload = {
[key in RequestMethod]?: {
operation: 'hasAll' | 'hasAny';
permissions: string[];
};
};
type PermissionsPayloadLight = {
[key in RequestMethod]?: string[];
};
type PermissionsRequiredKey = string[] | PermissionsPayload | PermissionsPayloadLight;
const isLegacyPermissionsPayload = (permissionsPayload: PermissionsRequiredKey): permissionsPayload is string[] => {
return Array.isArray(permissionsPayload);
};
const isLightPermissionsPayload = (permissionsPayload: PermissionsRequiredKey): permissionsPayload is PermissionsPayloadLight => {
return (
typeof permissionsPayload === 'object' &&
Object.keys(permissionsPayload).some((key) => ['GET', 'POST', 'PUT', 'DELETE', '*'].includes(key.toUpperCase())) &&
Object.values(permissionsPayload).every((value) => Array.isArray(value))
);
};
const isPermissionsPayload = (permissionsPayload: PermissionsRequiredKey): permissionsPayload is PermissionsPayload => {
return (
typeof permissionsPayload === 'object' &&
Object.keys(permissionsPayload).some((key) => ['GET', 'POST', 'PUT', 'DELETE', '*'].includes(key.toUpperCase())) &&
Object.values(permissionsPayload).every((value) => typeof value === 'object' && value.operation && value.permissions)
);
};
export async function canBypassRateLimit(userId: IUser['_id'], bypassPermissions: string[] = []): Promise<boolean> {
return hasAtLeastOnePermissionAsync(userId, ['api-bypass-rate-limit', ...bypassPermissions]);
}
export async function checkPermissionsForInvocation(
userId: IUser['_id'],
permissionsPayload: PermissionsPayload,
requestMethod: RequestMethod,
): Promise<boolean> {
const permissions = permissionsPayload[requestMethod] || permissionsPayload['*'];
if (!permissions) {
// how we reached here in the first place?
return false;
}
if (permissions.permissions.length === 0) {
// You can pass an empty array of permissions to allow access to the method
return true;
}
if (permissions.operation === 'hasAll') {
return hasAllPermissionAsync(userId, permissions.permissions);
}
if (permissions.operation === 'hasAny') {
return hasAtLeastOnePermissionAsync(userId, permissions.permissions);
}
return false;
}
// We'll assume options only contains permissionsRequired, as we don't care of the other elements
export function checkPermissions(options: { permissionsRequired?: PermissionsRequiredKey }) {
if (!options.permissionsRequired) {
return false;
}
if (isPermissionsPayload(options.permissionsRequired)) {
// No modifications needed
return true;
}
if (isLegacyPermissionsPayload(options.permissionsRequired)) {
options.permissionsRequired = {
'*': {
operation: 'hasAll',
permissions: options.permissionsRequired,
},
};
return true;
}
if (isLightPermissionsPayload(options.permissionsRequired)) {
Object.keys(options.permissionsRequired).forEach((method) => {
const methodKey = method as RequestMethod;
// @ts-expect-error -- we know the type of the value but ts refuses to infer it
options.permissionsRequired[methodKey] = {
operation: 'hasAll',
// @ts-expect-error -- we know the type of the value but ts refuses to infer it
permissions: options.permissionsRequired[methodKey],
};
});
return true;
}
// If reached here, options.permissionsRequired contained an invalid payload
return false;
}
export function parseDeprecation(
methodThis: ActionThis<any, any, any>,
{ alternatives, version }: { version: DeprecationLoggerNextPlannedVersion; alternatives?: string[] },
): void {
const infoMessage = alternatives?.length ? ` Please use the alternative(s): ${alternatives.join(',')}` : '';
apiDeprecationLogger.endpoint(methodThis.route, version, methodThis.response, infoMessage);
}

View File

@@ -0,0 +1,163 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { Router } from '@rocket.chat/http-router';
import { Logger } from '@rocket.chat/logger';
import type express from 'express';
import { WebApp } from 'meteor/webapp';
import { APIClass } from './ApiClass';
import type { RateLimiterOptions } from './definition';
import { type APIActionHandler, RocketChatAPIRouter } from './router';
import { metrics } from '../lib/metrics';
import { settings } from '../settings';
import { cors } from './v1/middlewares/cors';
import { experimentalWarningMiddleware } from './v1/middlewares/experimental';
import { loggerMiddleware } from './v1/middlewares/logger';
import { metricsMiddleware } from './v1/middlewares/metrics';
import { remoteAddressMiddleware } from './v1/middlewares/remoteAddressMiddleware';
import { tracerSpanMiddleware } from './v1/middlewares/tracer';
const logger = new Logger('API');
export type Prettify<T> = {
[K in keyof T]: T[K];
} & unknown;
export type { RateLimiterOptions } from './definition';
export const defaultRateLimiterOptions: RateLimiterOptions = {
numRequestsAllowed: settings.get<number>('API_Enable_Rate_Limiter_Limit_Calls_Default'),
intervalTimeInMS: settings.get<number>('API_Enable_Rate_Limiter_Limit_Time_Default'),
};
const createApi = function _createApi(options: { version?: string; useDefaultAuth?: true } = {}): APIClass {
return new APIClass({
apiPath: '',
useDefaultAuth: false,
prettyJson: process.env.NODE_ENV === 'development',
...options,
});
};
export const API: {
api: Router<'/api', any, APIActionHandler>;
v1: APIClass<'/v1'>;
experimental: APIClass<'/experimental'>;
default: APIClass;
ApiClass: typeof APIClass;
channels?: {
create: {
validate: (params: {
user: { value: string };
name?: { key: string; value?: string };
members?: { key: string; value?: string[] };
customFields?: { key: string; value?: string };
teams?: { key: string; value?: string[] };
teamId?: { key: string; value?: string };
}) => Promise<void>;
execute: (
userId: string,
params: {
name?: string;
members?: string[];
customFields?: Record<string, any>;
extraData?: Record<string, any>;
readOnly?: boolean;
},
) => Promise<{ channel: IRoom }>;
};
};
} = {
ApiClass: APIClass,
api: new RocketChatAPIRouter('/api'),
v1: createApi({
version: 'v1',
useDefaultAuth: true,
}),
experimental: createApi({
version: 'experimental',
useDefaultAuth: true,
}),
default: createApi({}),
};
settings.watch<string>('Accounts_CustomFields', (value) => {
if (!value) {
return API.v1?.setLimitedCustomFields([]);
}
try {
const customFields = JSON.parse(value);
const nonPublicCustomFields = Object.keys(customFields).filter((customFieldKey) => customFields[customFieldKey].public !== true);
API.v1.setLimitedCustomFields(nonPublicCustomFields);
} catch (error) {
console.warn('Invalid Custom Fields', error);
}
});
const reloadRoutesToRefreshRateLimiter = () => {
API.v1.reloadRoutesToRefreshRateLimiter();
API.experimental.reloadRoutesToRefreshRateLimiter();
};
settings.watch<number>('API_Enable_Rate_Limiter_Limit_Time_Default', (value) => {
defaultRateLimiterOptions.intervalTimeInMS = value;
reloadRoutesToRefreshRateLimiter();
});
settings.watch<number>('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => {
defaultRateLimiterOptions.numRequestsAllowed = value;
reloadRoutesToRefreshRateLimiter();
});
export const startRestAPI = () => {
(WebApp.rawConnectHandlers as unknown as ReturnType<typeof express>).use(
API.api
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\/v1\//),
api: API.v1,
settings,
endpointTimeSummary: metrics.rocketchatRestApi,
endpointTimeHistogram: metrics.rocketchatRestApiSeconds,
responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes,
activeRequestsGauge: metrics.rocketchatRestApiActiveRequests,
}),
)
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\/experimental\//),
api: API.experimental,
settings,
endpointTimeSummary: metrics.rocketchatRestApi,
endpointTimeHistogram: metrics.rocketchatRestApiSeconds,
responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes,
activeRequestsGauge: metrics.rocketchatRestApiActiveRequests,
}),
)
.use(
// Catch-all sampler for the default router (`/api/info`, `/api/docs/json`) and for
// unmatched `/api/*` paths, which belong to none of the versioned prefixes above.
// Add any new versioned namespace to `excludePathRegex` as well, or it gets counted twice.
metricsMiddleware({
excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//),
// `API.default` has no `version`; label it explicitly so the series is not blank.
api: { version: 'default' },
settings,
endpointTimeSummary: metrics.rocketchatRestApi,
endpointTimeHistogram: metrics.rocketchatRestApiSeconds,
responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes,
activeRequestsGauge: metrics.rocketchatRestApiActiveRequests,
}),
)
.use(tracerSpanMiddleware)
.use(remoteAddressMiddleware)
.use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) }))
.use(cors(settings))
.use(loggerMiddleware(logger))
.use(API.v1.router)
.use(API.experimental.router)
.use(API.default.router).router,
);
};
export type ExtractApiClassEndpoints<TApi extends APIClass<any>> =
TApi extends APIClass<any, infer TOperations> ? (TOperations extends { method: string } ? Prettify<TOperations> : never) : never;

View File

@@ -0,0 +1,28 @@
import type { IWorkspaceInfo } from '@rocket.chat/core-typings';
import { ajv } from '@rocket.chat/rest-typings';
import { API } from '../api';
import { getServerInfo } from '../lib/getServerInfo';
const infoResponseSchema = ajv.compile<IWorkspaceInfo>({
type: 'object',
properties: {
version: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: true,
});
API.default.get(
'info',
{
authRequired: false,
response: {
200: infoResponseSchema,
},
},
async function action() {
return API.v1.success(await getServerInfo(this.userId));
},
);

View File

@@ -0,0 +1,115 @@
import { schemas } from '@rocket.chat/core-typings';
import type { Route } from '@rocket.chat/http-router';
import { ajv, isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings';
import express from 'express';
import { WebApp } from 'meteor/webapp';
import swaggerUi from 'swagger-ui-express';
import { settings } from '../../settings';
import { API } from '../api';
import { getTrimmedServerVersion } from '../lib/getTrimmedServerVersion';
const app = express();
const getTypedRoutes = (
typedRoutes: Record<string, Record<string, Route>>,
{ withUndocumented = false }: { withUndocumented?: boolean } = {},
): Record<string, Record<string, Route>> => {
if (withUndocumented) {
return typedRoutes;
}
return Object.entries(typedRoutes).reduce(
(acc, [path, methods]) => {
const filteredMethods = Object.entries(methods)
.filter(([_, options]) => !options?.tags?.includes('Missing Documentation'))
.reduce(
(acc, [method, options]) => {
acc[method] = options;
return acc;
},
{} as Record<string, Route>,
);
if (Object.keys(filteredMethods).length > 0) {
acc[path] = filteredMethods;
}
return acc;
},
{} as Record<string, Record<string, Route>>,
);
};
const makeOpenAPIResponse = (paths: Record<string, Record<string, Route>>) => ({
openapi: '3.0.3',
info: {
title: 'Rocket.Chat API',
description: 'Rocket.Chat API',
version: getTrimmedServerVersion(),
},
servers: [
{
url: settings.get('Site_Url'),
},
],
components: {
securitySchemes: {
userId: {
type: 'apiKey',
in: 'header',
name: 'X-User-Id',
},
authToken: {
type: 'apiKey',
in: 'header',
name: 'X-Auth-Token',
},
},
schemas: schemas.components.schemas,
},
schemas: schemas.components.schemas,
paths,
});
const openApiResponseSchema = ajv.compile<Record<string, unknown>>({
type: 'object',
properties: {
openapi: { type: 'string' },
info: { type: 'object' },
servers: { type: 'array' },
components: { type: 'object' },
paths: { type: 'object' },
schemas: { type: 'object' },
success: { type: 'boolean', enum: [true] },
},
required: ['openapi', 'info', 'paths', 'success'],
additionalProperties: false,
});
API.default.get(
'docs/json',
{
authRequired: false,
query: isOpenAPIJSONEndpoint,
response: {
200: openApiResponseSchema,
},
},
function action() {
const { withUndocumented = false } = this.queryParams;
return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented })));
},
);
app.use(
'/api-docs',
swaggerUi.serve,
swaggerUi.setup(null, {
swaggerOptions: {
url: `${settings.get('Site_Url')}/api/docs/json`,
},
}),
);
WebApp.connectHandlers.use(app);

View File

@@ -0,0 +1,371 @@
import type { IncomingMessage } from 'node:http';
import type { IUser, LicenseModule, RequiredField } from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import type { Method, MethodOf, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings';
import type { ValidateFunction } from 'ajv';
import type { ITwoFactorOptions } from '../lib/2fa/code';
import type { DeprecationLoggerNextPlannedVersion } from '../lib/deprecationWarningLogger';
export type SuccessStatusCodes = Exclude<Range<208>, Range<200>>;
export type RedirectStatusCodes = Exclude<Range<308>, Range<300>>;
export type AuthorizationStatusCodes = Exclude<Range<451>, Range<400>>;
export type ErrorStatusCodes = Exclude<Exclude<Range<511>, Range<500>>, 509>;
export type SuccessResult<T, TStatusCode extends SuccessStatusCodes = 200> = {
statusCode: TStatusCode;
body: T extends Record<string, unknown> ? { success: true } & T : T;
};
export type FailureResult<T, TStack = undefined, TErrorType = undefined, TErrorDetails = undefined> = {
statusCode: 400;
body: T extends object
? { success: false } & T
: {
success: false;
error?: T;
stack?: TStack;
errorType?: TErrorType;
details?: TErrorDetails;
message?: string;
} & (undefined extends TErrorType ? object : { errorType: TErrorType }) &
(undefined extends TErrorDetails ? object : { details: TErrorDetails extends string ? unknown : TErrorDetails });
};
export type RedirectResult<T, TStatusCode extends RedirectStatusCodes = 300> = {
statusCode: TStatusCode;
body: T;
};
export type UnauthorizedResult<T> = {
statusCode: 401;
body: {
success: false;
error: T | 'unauthorized';
};
};
export type ForbiddenResult<T> = {
statusCode: 403;
body: {
success: false;
// TODO: MAJOR remove 'unauthorized'
error: T | 'forbidden' | 'unauthorized';
};
};
export type TooManyRequestsResult<T> = {
statusCode: 429;
body: {
success: false;
error: T | 'Too many requests';
};
};
export type InternalError<T, StatusCode extends ErrorStatusCodes = 500, D = 'Internal server error'> = {
statusCode: StatusCode;
body: {
error: T | D;
success: false;
};
};
export type UnavailableResult<T, StatusCode = 503> = {
statusCode: StatusCode;
body: {
error: T | 'Service Unavailable';
success: false;
};
};
export type NotFoundResult<T = string> = {
statusCode: 404;
body: {
success: false;
error: T;
};
};
export type TOperation = 'hasAll' | 'hasAny';
export type NonEnterpriseTwoFactorOptions = {
authRequired: true;
forceTwoFactorAuthenticationForNonEnterprise: true;
twoFactorRequired: true;
permissionsRequired?: string[] | { [key in Method]: string[] } | { [key in Method]: { operation: TOperation; permissions: string[] } };
twoFactorOptions: ITwoFactorOptions;
};
export type RateLimiterOptions = {
numRequestsAllowed?: number;
intervalTimeInMS?: number;
bypassPermissions?: string[];
};
export type Options = SharedOptions<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>;
export type SharedOptions<TMethod extends string> = (
| {
permissionsRequired?:
| string[]
| ({ [key in TMethod]?: string[] } & { '*'?: string[] })
| ({ [key in TMethod]?: { operation: TOperation; permissions: string[] } } & {
'*'?: { operation: TOperation; permissions: string[] };
});
authRequired?: boolean;
userWithoutUsername?: boolean;
forceTwoFactorAuthenticationForNonEnterprise?: boolean;
rateLimiterOptions?: RateLimiterOptions | boolean;
queryOperations?: string[];
queryFields?: string[];
}
| {
permissionsRequired?:
| string[]
| ({ [key in TMethod]?: string[] } & { '*'?: string[] })
| ({ [key in TMethod]?: { operation: TOperation; permissions: string[] } } & {
'*'?: { operation: TOperation; permissions: string[] };
});
authRequired: true;
userWithoutUsername?: boolean;
twoFactorRequired: true;
twoFactorOptions?: ITwoFactorOptions;
rateLimiterOptions?: RateLimiterOptions | boolean;
queryOperations?: string[];
queryFields?: string[];
}
) & {
/**
* @deprecated The `validateParams` option is deprecated. Use `query` and/OR `body` instead.
*/
validateParams?: ValidateFunction | { [key in TMethod]?: ValidateFunction };
authOrAnonRequired?: true;
deprecation?: {
version: DeprecationLoggerNextPlannedVersion;
alternatives?: PathPattern[];
};
applyMeteorContext?: boolean;
};
export type GenericRouteExecutionContext = ActionThis<any, any, any>;
export type RouteExecutionContext<TMethod extends Method, TPathPattern extends PathPattern, TOptions> = ActionThis<
TMethod,
TPathPattern,
TOptions
>;
export type ActionThis<TMethod extends Method, TPathPattern extends PathPattern, TOptions> = {
readonly logger: Logger;
route: string;
readonly requestIp: string;
urlParams: UrlParams<TPathPattern>;
readonly response: Response;
// TODO make it unsafe
readonly queryParams: TMethod extends 'GET'
? TOptions extends { validateParams: ValidateFunction<infer T> }
? T
: TOptions extends { validateParams: { GET: ValidateFunction<infer T> } }
? T
: Partial<OperationParams<TMethod, TPathPattern>> & { offset?: number; count?: number }
: Record<string, string>;
// TODO make it unsafe
readonly bodyParams: TMethod extends 'GET'
? Record<string, unknown>
: TOptions extends { validateParams: ValidateFunction<infer T> }
? T
: TOptions extends { validateParams: infer V }
? V extends { [key in TMethod]: ValidateFunction<infer T> }
? T
: Partial<OperationParams<TMethod, TPathPattern>>
: // TODO remove the extra (optionals) params when all the endpoints that use these are typed correctly
Partial<OperationParams<TMethod, TPathPattern>>;
readonly request: Request;
readonly incoming: IncomingMessage;
readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never;
readonly queryFields: TOptions extends { queryFields: infer T } ? T : never;
readonly twoFactorChecked: boolean;
parseJsonQuery(): Promise<{
sort: Record<string, 1 | -1>;
/**
* @deprecated To access "fields" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable.
*/
fields: Record<string, 0 | 1>;
/**
* @deprecated To access "query" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable.
*/
query: Record<string, unknown>;
}>;
readonly connection: {
token: string;
id: string;
close: () => void;
clientAddress: string;
httpHeaders: Record<string, any>;
};
} & (TOptions extends { authRequired: true }
? {
user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField<IUser, 'username'>;
userId: string;
readonly token: string;
}
: TOptions extends { authOrAnonRequired: true }
? {
user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField<IUser, 'username'>;
userId?: string;
readonly token?: string;
}
: {
user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField<IUser, 'username'>;
userId?: string;
readonly token?: string;
});
export type ResultFor<TMethod extends Method, TPathPattern extends PathPattern> = (
| SuccessResult<OperationResult<TMethod, TPathPattern>>
| FailureResult<unknown, unknown, unknown, unknown>
| UnauthorizedResult<unknown>
| NotFoundResult
| {
statusCode: number;
body: unknown;
}
) & {
headers?: Record<string, string>;
};
export type Action<TMethod extends Method, TPathPattern extends PathPattern, TOptions> =
| ((this: ActionThis<TMethod, TPathPattern, TOptions>) => Promise<ResultFor<TMethod, TPathPattern>>)
| ((this: ActionThis<TMethod, TPathPattern, TOptions>) => ResultFor<TMethod, TPathPattern>);
export type InnerAction<TMethod extends Method, TPathPattern extends PathPattern, TOptions> =
Action<TMethod, TPathPattern, TOptions> extends (this: infer This) => infer Result
? This extends ActionThis<TMethod, TPathPattern, TOptions>
? (this: Mutable<This>) => Result
: never
: never;
type Mutable<Immutable> = {
-readonly [key in keyof Immutable]: Immutable[key];
};
type Operation<TMethod extends Method, TPathPattern extends PathPattern, TEndpointOptions> =
| Action<TMethod, TPathPattern, TEndpointOptions>
| ({
action: Action<TMethod, TPathPattern, TEndpointOptions>;
} & { twoFactorRequired: boolean });
type ActionOperation<TMethod extends Method, TPathPattern extends PathPattern, TEndpointOptions extends Options = object> = {
action: Action<TMethod, TPathPattern, TEndpointOptions>;
} & TEndpointOptions;
export type Operations<TPathPattern extends PathPattern, TOptions extends Options = object> = {
[M in MethodOf<TPathPattern> as Lowercase<M>]: Operation<Uppercase<M>, TPathPattern, TOptions>;
};
export type ActionOperations<TPathPattern extends PathPattern, TOptions extends Options = object> = {
[M in MethodOf<TPathPattern> as Lowercase<M>]: ActionOperation<Uppercase<M>, TPathPattern, TOptions>;
};
type Range<N extends number, Result extends number[] = []> = Result['length'] extends N
? Result[number]
: Range<N, [...Result, Result['length']]>;
type HTTPStatusCodes = SuccessStatusCodes | RedirectStatusCodes | AuthorizationStatusCodes | ErrorStatusCodes;
export type TypedOptions = {
response: {
[K in HTTPStatusCodes]?: ValidateFunction;
};
query?: ValidateFunction;
body?: ValidateFunction;
tags?: string[];
typed?: boolean;
license?: LicenseModule[];
} & SharedOptions<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'>;
export type TypedThis<TOptions extends TypedOptions, TPath extends string = ''> = {
readonly logger: Logger;
userId: TOptions['authRequired'] extends true ? string : string | undefined;
token: TOptions['authRequired'] extends true ? string : string | undefined;
queryParams: TOptions['query'] extends ValidateFunction<infer Query> ? Query : never;
urlParams: UrlParams<TPath> extends Record<any, any> ? UrlParams<TPath> : never;
parseJsonQuery(): Promise<{
sort: Record<string, 1 | -1>;
/**
* @deprecated To access "fields" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable.
*/
fields: Record<string, 0 | 1>;
/**
* @deprecated To access "query" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable.
*/
query: Record<string, unknown>;
}>;
bodyParams: TOptions['body'] extends ValidateFunction<infer Body> ? Body : never;
request: Request;
requestIp?: string;
route: string;
response: Response;
readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never;
readonly queryFields: TOptions extends { queryFields: infer T } ? T : never;
readonly connection: {
token: string;
id: string;
close: () => void;
clientAddress: string;
httpHeaders: Record<string, string>;
};
readonly twoFactorChecked: boolean;
} & (TOptions['authRequired'] extends true
? {
user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField<IUser, 'username'>;
}
: {
user?: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField<IUser, 'username'>;
});
type PromiseOrValue<T> = T | Promise<T>;
type InferResult<TResult> = TResult extends ValidateFunction<infer T> ? T : TResult;
type InferNon200Result<T> =
InferResult<T> extends {
success: false;
error?: infer TError;
}
? TError
: never;
type Results<TResponse extends TypedOptions['response']> = {
[K in keyof TResponse]: K extends SuccessStatusCodes
? SuccessResult<InferResult<TResponse[200]>, K>
: K extends RedirectStatusCodes
? RedirectResult<InferNon200Result<TResponse[300]>, K>
: K extends 400
? FailureResult<InferResult<TResponse[400]>>
: K extends 401
? UnauthorizedResult<InferNon200Result<TResponse[401]>>
: K extends 403
? ForbiddenResult<InferNon200Result<TResponse[403]>>
: K extends 404
? NotFoundResult<InferNon200Result<TResponse[404]>>
: K extends ErrorStatusCodes
? InternalError<InferNon200Result<TResponse[K]>, K>
: never;
}[keyof TResponse] & {
headers?: Record<string, string>;
};
export type TypedAction<
TOptions extends TypedOptions,
TPath extends string = '',
TThis extends TypedThis<TOptions, TPath> = TypedThis<TOptions, TPath>,
> = (this: TThis, request: Request) => PromiseOrValue<Results<TOptions['response']>>;

View File

@@ -0,0 +1,76 @@
import { SIDEBAR_SYSTEM_GROUP_KEYS } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import {
ajv,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { notifyOnSubscriptionsChangedByRoomIdsAndUserId } from '../../lib/notifyListener';
import { API } from '../api';
const isRoomsSetCategoryParamsPOST = ajv.compile<{ roomIds: string[]; category: string | null }>({
type: 'object',
properties: {
roomIds: {
type: 'array',
items: { type: 'string', minLength: 1 },
minItems: 1,
uniqueItems: true,
},
category: {
type: 'string',
nullable: true,
not: { enum: [...SIDEBAR_SYSTEM_GROUP_KEYS] },
},
},
required: ['roomIds', 'category'],
additionalProperties: false,
});
API.experimental.post(
'rooms.setCategory',
{
authRequired: true,
license: ['experimental-enterprise-features'],
body: isRoomsSetCategoryParamsPOST,
response: {
200: ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { roomIds, category } = this.bodyParams;
const { userId } = this;
const user = await Users.findOneById(userId, { projection: { 'settings.preferences.sidebarCategories': 1 } });
if (!user) {
return API.experimental.failure('error-invalid-user');
}
if (category !== null) {
const categories: Array<{ _id: string }> = user.settings?.preferences?.sidebarCategories ?? [];
if (!categories.some((cat) => cat._id === category)) {
return API.experimental.failure('error-invalid-param', 'Category not found in user preferences.');
}
}
const { modifiedCount } = await Subscriptions.setCategoryByRoomIdsAndUserId(roomIds, userId, category);
if (modifiedCount) {
void notifyOnSubscriptionsChangedByRoomIdsAndUserId(roomIds, userId);
}
return API.experimental.success();
},
);

View File

@@ -0,0 +1,57 @@
import './validation/ajv';
import './lib/composeRoomWithLastMessage';
import './lib/getPaginationItems';
import './lib/getUserFromParams';
import './lib/getUserInfo';
import './lib/isUserFromParams';
import './lib/parseJsonQuery';
import './default/info';
import './v1/assets';
import './v1/ai-search';
import './v1/calendar';
import './v1/call-history';
import './v1/channels';
import './v1/chat';
import './v1/cloud';
import './v1/commands';
import './v1/e2e';
import './v1/emoji-custom';
import './v1/groups';
import './v1/im';
import './v1/integrations';
import './v1/invites';
import './v1/import';
import './v1/ldap';
import './v1/saml';
import './v1/media-calls';
import './v1/misc';
import './v1/permissions';
import './v1/presence';
import './v1/push';
import './v1/roles';
import './v1/rooms';
import './v1/settings';
import './v1/setupWizard';
import './v1/stats';
import './v1/subscriptions';
import './v1/users';
import './v1/videoConference';
import './v1/autotranslate';
import './v1/webdav';
import './v1/oauthapps';
import './v1/custom-sounds';
import './v1/custom-user-status';
import './v1/instances';
import './v1/banners';
import './v1/email-inbox';
import './v1/mailer';
import './v1/teams';
import './v1/moderation';
import './v1/uploads';
import './v1/twoFactorChallenges';
import './v1/loginCode';
import './experimental/rooms.setCategory';
// This has to come last so all endpoints are registered before generating the OpenAPI documentation
import './default/openApi';
export { API, defaultRateLimiterOptions } from './api';

View File

@@ -0,0 +1,205 @@
import fs from 'node:fs';
import { IncomingMessage } from 'node:http';
import type { Stream, Transform } from 'node:stream';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { MeteorError } from '@rocket.chat/core-services';
import { Random } from '@rocket.chat/random';
import busboy, { type BusboyConfig } from 'busboy';
import ExifTransformer from 'exif-be-gone';
import { getMimeType } from '../../../app/utils/lib/mimeTypes';
import { UploadFS } from '../../ufs';
export type ParsedUpload = {
tempFilePath: string;
filename: string;
mimetype: string;
size: number;
fieldname: string;
};
export type ParseOptions = {
field: string;
maxSize?: number;
allowedMimeTypes?: string[];
transforms?: Transform[]; // Optional transform pipeline (e.g., EXIF stripping)
fileOptional?: boolean;
};
export class MultipartUploadHandler {
static transforms = {
stripExif(): Transform {
return new ExifTransformer();
},
};
static async cleanup(tempFilePath: string): Promise<void> {
try {
await fs.promises.unlink(tempFilePath);
} catch (error: any) {
console.warn(`[UploadService] Failed to cleanup temp file: ${tempFilePath}`, error);
}
}
static async stripExifFromFile(tempFilePath: string): Promise<number> {
const strippedPath = `${tempFilePath}.stripped`;
try {
const writeStream = fs.createWriteStream(strippedPath);
await pipeline(fs.createReadStream(tempFilePath), new ExifTransformer(), writeStream);
await fs.promises.rename(strippedPath, tempFilePath);
return writeStream.bytesWritten;
} catch (error) {
void this.cleanup(strippedPath);
throw error;
}
}
static async parseRequest(
request: IncomingMessage | Request,
options: ParseOptions,
): Promise<{ file: ParsedUpload | null; fields: Record<string, string> }> {
const limits: BusboyConfig['limits'] = { files: 1 };
if (options.maxSize && options.maxSize > 0) {
// We add an extra byte to the configured limit so we don't fail the upload
// of a file that is EXACTLY maxSize
limits.fileSize = options.maxSize + 1;
}
const headers =
request instanceof IncomingMessage ? (request.headers as Record<string, string>) : Object.fromEntries(request.headers.entries());
const bb = busboy({
headers,
defParamCharset: 'utf8',
limits,
});
const fields: Record<string, string> = {};
let parsedFile: ParsedUpload | null = null;
let busboyFinished = false;
let filePendingCount = 0;
const { promise, resolve, reject } = Promise.withResolvers<{
file: ParsedUpload | null;
fields: Record<string, string>;
}>();
const tryResolve = () => {
if (busboyFinished && filePendingCount < 1) {
if (!parsedFile && !options.fileOptional) {
return reject(new MeteorError('error-no-file', 'No file uploaded'));
}
resolve({ file: parsedFile, fields });
}
};
bb.on('field', (fieldname: string, value: string) => {
fields[fieldname] = value;
});
bb.on('file', (fieldname, file, info) => {
const { filename, mimeType } = info;
++filePendingCount;
if (options.field && fieldname !== options.field) {
file.resume();
return reject(new MeteorError('invalid-field'));
}
if (options.allowedMimeTypes && !options.allowedMimeTypes.includes(mimeType)) {
file.resume();
return reject(new MeteorError('error-invalid-file-type', `File type ${mimeType} not allowed`));
}
const fileId = Random.id();
const tempFilePath = UploadFS.getTempFilePath(fileId);
const writeStream = fs.createWriteStream(tempFilePath);
let currentStream: Stream = file;
if (options.transforms?.length) {
const fileDestroyer = file.destroy.bind(file);
for (const transform of options.transforms) {
transform.on('error', fileDestroyer);
currentStream = currentStream.pipe(transform);
}
}
currentStream.pipe(writeStream);
writeStream.on('finish', () => {
if (file.truncated) {
void this.cleanup(tempFilePath);
return reject(new MeteorError('error-file-too-large', 'File size exceeds the allowed limit'));
}
parsedFile = {
tempFilePath,
filename,
mimetype: getMimeType(mimeType, filename),
size: writeStream.bytesWritten,
fieldname,
};
--filePendingCount;
tryResolve();
});
writeStream.on('error', (err) => {
file.destroy();
void this.cleanup(tempFilePath);
reject(new MeteorError('error-file-upload', err.message));
});
file.on('error', (err) => {
writeStream.destroy();
void this.cleanup(tempFilePath);
reject(new MeteorError('error-file-upload', err.message));
});
});
bb.on('finish', () => {
busboyFinished = true;
tryResolve();
});
bb.on('error', (err: any) => {
reject(new MeteorError('error-upload-failed', err.message));
});
bb.on('filesLimit', () => {
reject(new MeteorError('error-too-many-files', 'Too many files in upload'));
});
bb.on('partsLimit', () => {
reject(new MeteorError('error-too-many-parts', 'Too many parts in upload'));
});
bb.on('fieldsLimit', () => {
reject(new MeteorError('error-too-many-fields', 'Too many fields in upload'));
});
if (request instanceof IncomingMessage) {
request.pipe(bb);
} else {
if (!request.body) {
return Promise.reject(new MeteorError('error-no-body', 'Request has no body'));
}
const nodeStream = Readable.fromWeb(request.body as any);
nodeStream.pipe(bb);
}
return promise;
}
}

View File

@@ -0,0 +1,22 @@
import type { IUpload, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
const isString = (value: unknown): value is string => typeof value === 'string';
export async function addUserToFileObj(files: IUpload[]): Promise<(IUpload & { user?: Pick<IUser, '_id' | 'name' | 'username'> })[]> {
const uids = files.map(({ userId }) => userId).filter(isString);
const users = await Users.findByIds(uids, { projection: { name: 1, username: 1 } }).toArray();
const usersById = new Map(users.map((user) => [user._id, user]));
return files.map((file) => {
const user = file.userId ? usersById.get(file.userId) : undefined;
if (!user) {
return file;
}
return {
...file,
user,
};
});
}

View File

@@ -0,0 +1,28 @@
type Query = { [k: string]: any };
const denyList = ['constructor', '__proto__', 'prototype'];
export const removeDangerousProps = (v: Query): Query => {
const query = Object.create(null);
for (const key in v) {
if (v.hasOwnProperty(key) && !denyList.includes(key)) {
query[key] = v[key];
}
}
return query;
};
/* @deprecated */
export function clean(v: Query, allowList: string[] = []): Query {
const typedParam = removeDangerousProps(v);
if (v instanceof Object) {
for (const key in typedParam) {
if (key.startsWith('$') && !allowList.includes(key)) {
delete typedParam[key];
} else {
clean(typedParam[key], allowList);
}
}
}
return typedParam;
}

View File

@@ -0,0 +1,11 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
export async function composeRoomWithLastMessage(room: IRoom, userId: string) {
if (room.lastMessage) {
const [lastMessage] = await normalizeMessagesForUser([room.lastMessage], userId);
room.lastMessage = lastMessage;
}
return room;
}

View File

@@ -0,0 +1,104 @@
import type { IEmailInbox } from '@rocket.chat/core-typings';
import { EmailInbox, Users } from '@rocket.chat/models';
import type { DeleteResult, Filter, InsertOneResult, Sort } from 'mongodb';
import { notifyOnEmailInboxChanged } from '../../lib/notifyListener';
export const findEmailInboxes = async ({
query = {},
pagination: { offset, count, sort },
}: {
query?: Filter<IEmailInbox>;
pagination: {
offset: number;
count: number;
sort?: Sort;
};
}): Promise<{
emailInboxes: IEmailInbox[];
total: number;
count: number;
offset: number;
}> => {
const { cursor, totalCount } = EmailInbox.findPaginated(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [emailInboxes, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
emailInboxes,
count: emailInboxes.length,
offset,
total,
};
};
export const insertOneEmailInbox = async (
userId: string,
emailInboxParams: Pick<IEmailInbox, 'active' | 'name' | 'email' | 'description' | 'senderInfo' | 'department' | 'smtp' | 'imap'>,
): Promise<InsertOneResult<IEmailInbox>> => {
const obj = {
...emailInboxParams,
_createdAt: new Date(),
_updatedAt: new Date(),
_createdBy: await Users.findOneById(userId, { projection: { username: 1 } }),
};
const response = await EmailInbox.create(obj);
if (response.insertedId) {
void notifyOnEmailInboxChanged({ _id: response.insertedId, ...obj }, 'inserted');
}
return response;
};
export const updateEmailInbox = async (
emailInboxParams: Pick<IEmailInbox, '_id' | 'active' | 'name' | 'email' | 'description' | 'senderInfo' | 'department' | 'smtp' | 'imap'>,
): Promise<Pick<IEmailInbox, '_id'> | null> => {
const { _id, active, name, email, description, senderInfo, department, smtp, imap } = emailInboxParams;
const updateEmailInbox = {
$set: {
active,
name,
email,
description,
senderInfo,
smtp,
imap,
_updatedAt: new Date(),
...(department !== 'All' && { department }),
},
...(department === 'All' && { $unset: { department: 1 as const } }),
};
const updatedResponse = await EmailInbox.updateById(_id, updateEmailInbox);
if (!updatedResponse) {
throw new Error('error-invalid-email-inbox');
}
void notifyOnEmailInboxChanged(
{
...updatedResponse,
...(department === 'All' && { department: undefined }),
},
'updated',
);
return updatedResponse;
};
export const removeEmailInbox = async (emailInboxId: IEmailInbox['_id']): Promise<DeleteResult> => {
const removeResponse = await EmailInbox.removeById(emailInboxId);
if (removeResponse.deletedCount) {
void notifyOnEmailInboxChanged({ _id: emailInboxId }, 'removed');
}
return removeResponse;
};

View File

@@ -0,0 +1,31 @@
import type { IEmojiCustom } from '@rocket.chat/core-typings';
import { EmojiCustom } from '@rocket.chat/models';
import type { Filter, FindOptions } from 'mongodb';
export async function findEmojisCustom({
query = {},
pagination: { offset, count, sort },
}: {
query: Filter<IEmojiCustom>;
pagination: { offset: number; count: number; sort: FindOptions<IEmojiCustom>['sort'] };
}): Promise<{
emojis: IEmojiCustom[];
count: number;
offset: any;
total: number;
}> {
const { cursor, totalCount } = EmojiCustom.findPaginated(query, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [emojis, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
emojis,
count: emojis.length,
offset,
total,
};
}

View File

@@ -0,0 +1,292 @@
import { expect } from 'chai';
import proxyquireRaw from 'proxyquire';
import * as sinon from 'sinon';
const proxyquire = proxyquireRaw.noCallThru();
type Stubbed = { [k: string]: any };
describe('eraseTeam (TypeScript) module', () => {
let sandbox: sinon.SinonSandbox;
let stubs: Stubbed;
let subject: any;
beforeEach(() => {
sandbox = sinon.createSandbox();
stubs = {
'Team': {
getMatchingTeamRooms: sandbox.stub().resolves([]),
unsetTeamIdOfRooms: sandbox.stub().resolves(),
removeAllMembersFromTeam: sandbox.stub().resolves(),
deleteById: sandbox.stub().resolves(),
},
'Users': {
findOneById: sandbox.stub().resolves(null),
},
'Rooms': {
findOneById: sandbox.stub().resolves(null),
},
'eraseRoomStub': sandbox.stub().resolves(true),
'deleteRoomStub': sandbox.stub().resolves(),
'../../lib/logger/system': {
SystemLogger: {
error: sandbox.stub(),
},
},
'@rocket.chat/apps': {
AppEvents: {
IPreRoomDeletePrevent: 'IPreRoomDeletePrevent',
IPostRoomDeleted: 'IPostRoomDeleted',
},
Apps: {
self: {
isLoaded: () => false,
triggerEvent: sandbox.stub().resolves(false),
},
},
},
'@rocket.chat/models': {
Rooms: {
findOneById: (...args: any[]) => stubs.Rooms.findOneById(...args),
},
Users: {
findOneById: (...args: any[]) => stubs.Users.findOneById(...args),
},
},
'@rocket.chat/core-services': {
MeteorError: (function () {
class MeteorError extends Error {
public error: string | undefined;
public details: any;
constructor(message?: string, error?: string, details?: any) {
super(message);
this.error = error;
this.details = details;
}
}
return MeteorError;
})(),
},
};
subject = proxyquire('./eraseTeam', {
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
'@rocket.chat/models': stubs['@rocket.chat/models'],
'../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
'../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
'../../lib/logger/system': stubs['../../lib/logger/system'],
'@rocket.chat/core-services': {
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
Team: stubs.Team,
},
});
});
afterEach(() => {
sandbox.restore();
});
describe('eraseTeamShared', () => {
it('throws when user is undefined', async () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
await expect(subject.eraseTeamShared(undefined, { _id: 'team1', roomId: 'teamRoom' }, [], () => {})).to.be.rejected;
});
it('erases provided rooms (excluding team.roomId) and cleans up team', async () => {
const team = { _id: 'team-id', roomId: 'team-room' };
const user = { _id: 'user-1', username: 'u' };
stubs.Team.getMatchingTeamRooms.resolves(['room-1', 'room-2', team.roomId]);
stubs.Rooms.findOneById.withArgs(team.roomId).resolves({ _id: team.roomId });
const erased: Array<{ rid: string; user: any }> = [];
const eraseRoomFn = async (rid: string, user: any) => {
erased.push({ rid, user });
};
await subject.eraseTeamShared(user, team, ['room-1', 'room-2', team.roomId], eraseRoomFn);
expect(erased.some((r) => r.rid === 'room-1')).to.be.true;
expect(erased.some((r) => r.rid === 'room-2')).to.be.true;
sinon.assert.calledOnce(stubs.Team.unsetTeamIdOfRooms);
expect(erased.some((r) => r.rid === team.roomId)).to.be.true;
sinon.assert.calledOnce(stubs.Team.removeAllMembersFromTeam);
sinon.assert.calledOnce(stubs.Team.deleteById);
});
it('skips the main room but still deletes the team when the main room no longer exists', async () => {
const team = { _id: 'team-id', roomId: 'team-room' };
const user = { _id: 'user-1', username: 'u' };
stubs.Team.getMatchingTeamRooms.resolves([]);
stubs.Rooms.findOneById.withArgs(team.roomId).resolves(null);
const eraseRoomFn = sandbox.stub().resolves();
await subject.eraseTeamShared(user, team, [], eraseRoomFn);
sinon.assert.notCalled(eraseRoomFn);
sinon.assert.calledOnce(stubs.Team.unsetTeamIdOfRooms);
sinon.assert.calledOnce(stubs.Team.removeAllMembersFromTeam);
sinon.assert.calledOnce(stubs.Team.deleteById);
});
});
describe('eraseTeam', () => {
it('calls eraseRoom for the team main room (via eraseTeamShared)', async () => {
const team = { _id: 't1', roomId: 't-room' };
const user = { _id: 'u1', username: 'u', name: 'User' };
stubs.Team.getMatchingTeamRooms.resolves([]);
stubs.Rooms.findOneById.withArgs(team.roomId).resolves({ _id: team.roomId });
const { eraseRoomStub } = stubs;
eraseRoomStub.resolves(true);
await subject.eraseTeam(user, team, []);
sinon.assert.calledWith(eraseRoomStub, team.roomId, user);
});
});
describe('eraseTeamOnRelinquishRoomOwnerships', () => {
it('returns successfully deleted room ids only', async () => {
const team = { _id: 't1', roomId: 't-room' };
stubs.Team.getMatchingTeamRooms.resolves(['r1', 'r2']);
stubs.Rooms.findOneById.withArgs('r1').resolves({ _id: 'r1', federated: false });
stubs.Rooms.findOneById.withArgs('r2').resolves(null);
stubs.deleteRoomStub.withArgs('r1').resolves();
stubs.deleteRoomStub.withArgs('r2').rejects(new Error('boom'));
const base = proxyquire('./eraseTeam', {
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
'@rocket.chat/models': stubs['@rocket.chat/models'],
'../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
'../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
'../../lib/logger/system': stubs['../../lib/logger/system'],
'@rocket.chat/core-services': {
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
Team: stubs.Team,
},
});
const result: string[] = await base.eraseTeamOnRelinquishRoomOwnerships(team, ['r1', 'r2']);
expect(result).to.be.an('array').that.includes('r1').and.not.includes('r2');
});
});
describe('eraseRoomLooseValidation', () => {
let baseModule: any;
beforeEach(() => {
baseModule = proxyquire('./eraseTeam', {
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
'@rocket.chat/models': stubs['@rocket.chat/models'],
'../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
'../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
'../../lib/logger/system': stubs['../../lib/logger/system'],
'@rocket.chat/core-services': {
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
Team: stubs.Team,
},
});
});
it('returns false when room not found', async () => {
stubs.Rooms.findOneById.resolves(null);
const res = await baseModule.eraseRoomLooseValidation('does-not-exist');
expect(res).to.be.false;
});
it('returns false when room.federated is true', async () => {
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: true });
const res = await baseModule.eraseRoomLooseValidation('r');
expect(res).to.be.false;
});
it('returns false when app pre-delete prevents deletion', async () => {
const listenerStub = sandbox.stub().resolves(true);
const AppsStub = {
AppEvents: stubs['@rocket.chat/apps'].AppEvents,
Apps: {
self: {
isLoaded: () => true,
triggerEvent: listenerStub,
},
},
};
const m = proxyquire('./eraseTeam', {
'@rocket.chat/apps': AppsStub,
'@rocket.chat/models': stubs['@rocket.chat/models'],
'../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
'../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
'../../lib/logger/system': stubs['../../lib/logger/system'],
'@rocket.chat/core-services': {
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
Team: stubs.Team,
},
});
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false });
const res = await m.eraseRoomLooseValidation('r');
expect(listenerStub.calledOnce).to.be.true;
expect(res).to.be.false;
});
it('logs and returns false when deleteRoom throws', async () => {
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false });
stubs.deleteRoomStub.rejects(new Error('boom'));
const m = proxyquire('./eraseTeam', {
'@rocket.chat/apps': stubs['@rocket.chat/apps'],
'@rocket.chat/models': stubs['@rocket.chat/models'],
'../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
'../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
'../../lib/logger/system': stubs['../../lib/logger/system'],
'@rocket.chat/core-services': {
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
Team: stubs.Team,
},
});
const res = await m.eraseRoomLooseValidation('r');
expect(res).to.be.false;
sinon.assert.calledOnce(stubs['../../lib/logger/system'].SystemLogger.error);
});
it('calls post-deleted event and returns true on success', async () => {
const roomEventStub = sandbox.stub().onFirstCall().resolves(false).onSecondCall().resolves();
const AppsStub = {
AppEvents: stubs['@rocket.chat/apps'].AppEvents,
Apps: {
self: {
isLoaded: () => true,
triggerEvent: roomEventStub,
},
},
};
stubs.deleteRoomStub.resolves();
const m = proxyquire('./eraseTeam', {
'@rocket.chat/apps': AppsStub,
'@rocket.chat/models': stubs['@rocket.chat/models'],
'../../lib/eraseRoom': { __esModule: true, eraseRoom: stubs.eraseRoomStub },
'../../lib/rooms/deleteRoom': { __esModule: true, deleteRoom: stubs.deleteRoomStub },
'../../lib/logger/system': stubs['../../lib/logger/system'],
'@rocket.chat/core-services': {
MeteorError: stubs['@rocket.chat/core-services'].MeteorError,
Team: stubs.Team,
},
});
stubs.Rooms.findOneById.resolves({ _id: 'r', federated: false });
const res = await m.eraseRoomLooseValidation('r');
expect(res).to.be.true;
sinon.assert.calledTwice(roomEventStub);
});
});
});

View File

@@ -0,0 +1,98 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import { MeteorError, Team } from '@rocket.chat/core-services';
import type { IRoom, ITeam, IUser, AtLeast } from '@rocket.chat/core-typings';
import { Rooms } from '@rocket.chat/models';
import { eraseRoom } from '../../lib/eraseRoom';
import { SystemLogger } from '../../lib/logger/system';
import { deleteRoom } from '../../lib/rooms/deleteRoom';
type EraseRoomFnType = <T extends AtLeast<IUser, '_id' | 'name' | 'username' | 'roles'>>(rid: string, user: T) => Promise<boolean | void>;
export const eraseTeamShared = async <T extends AtLeast<IUser, '_id' | 'name' | 'username' | 'roles'>>(
user: T,
team: ITeam,
roomsToRemove: IRoom['_id'][] = [],
eraseRoomFn: EraseRoomFnType,
) => {
const rooms: string[] = roomsToRemove.length
? (await Team.getMatchingTeamRooms(team._id, roomsToRemove)).filter((roomId) => roomId !== team.roomId)
: [];
if (!user) {
throw new MeteorError('Invalid user provided for erasing team', 'error-invalid-user', {
method: 'eraseTeamShared',
});
}
// If we got a list of rooms to delete along with the team, remove them first
await Promise.all(rooms.map((room) => eraseRoomFn(room, user)));
// Move every other room back to the workspace
await Team.unsetTeamIdOfRooms(user, team);
const room = await Rooms.findOneById<Pick<IRoom, '_id'>>(team.roomId, { projection: { _id: 1 } });
if (room) {
await eraseRoomFn(team.roomId, user);
}
// Delete all team memberships
await Team.removeAllMembersFromTeam(team._id);
// And finally delete the team itself
await Team.deleteById(team._id);
};
export const eraseTeam = async (user: IUser, team: ITeam, roomsToRemove: IRoom['_id'][]) => {
await eraseTeamShared(user, team, roomsToRemove, async (rid, user) => {
return eraseRoom(rid, user);
});
};
/**
* @param team
* @param roomsToRemove
* @returns deleted room ids
*/
export const eraseTeamOnRelinquishRoomOwnerships = async (team: ITeam, roomsToRemove: IRoom['_id'][] = []): Promise<string[]> => {
const deletedRooms = new Set<string>();
await eraseTeamShared({ _id: 'rocket.cat', username: 'rocket.cat', name: 'Rocket.Cat' } as IUser, team, roomsToRemove, async (rid) => {
const isDeleted = await eraseRoomLooseValidation(rid);
if (isDeleted) {
deletedRooms.add(rid);
}
});
return Array.from(deletedRooms);
};
export async function eraseRoomLooseValidation(rid: string): Promise<boolean> {
const room = await Rooms.findOneById(rid);
if (!room) {
return false;
}
if (room.federated) {
return false;
}
if (Apps.self?.isLoaded()) {
const prevent = await Apps.self?.triggerEvent(AppEvents.IPreRoomDeletePrevent, room);
if (prevent) {
return false;
}
}
try {
await deleteRoom(rid);
} catch (err) {
SystemLogger.error({ err });
return false;
}
if (Apps.self?.isLoaded()) {
void Apps.self?.triggerEvent(AppEvents.IPostRoomDeleted, room);
}
return true;
}

View File

@@ -0,0 +1,4 @@
import { makeFunction } from '@rocket.chat/patch-injection';
import type { BrokerNode } from 'moleculer';
export const getInstanceList = makeFunction(async (): Promise<BrokerNode[]> => []);

View File

@@ -0,0 +1,37 @@
// If the count query param is higher than the "API_Upper_Count_Limit" setting, then we limit that
// If the count query param isn't defined, then we set it to the "API_Default_Count" setting
// If the count is zero, then that means unlimited and is only allowed if the setting "API_Allow_Infinite_Count" is true
import { settings } from '../../settings';
export async function getPaginationItems(params: { offset?: string | number | null; count?: string | number | null }): Promise<{
readonly offset: number;
readonly count: number;
}> {
const hardUpperLimitTest = settings.get<number>('API_Upper_Count_Limit');
const defaultCountTest = settings.get<number>('API_Default_Count');
const hardUpperLimit = hardUpperLimitTest && hardUpperLimitTest <= 0 ? 100 : settings.get<number>('API_Upper_Count_Limit');
const defaultCount = defaultCountTest && defaultCountTest <= 0 ? 50 : settings.get<number>('API_Default_Count');
const offset = params.offset ? parseInt(String(params.offset || 0)) : 0;
let count = defaultCount;
// Ensure count is an appropriate amount
if (params.count !== undefined && params.count !== null) {
count = parseInt(String(params.count || 0));
} else {
count = defaultCount;
}
if (count > hardUpperLimit) {
count = hardUpperLimit;
}
if (count === 0 && !settings.get('API_Allow_Infinite_Count')) {
count = defaultCount;
}
return {
offset,
count,
};
}

View File

@@ -0,0 +1,60 @@
import { expect } from 'chai';
import { describe, it, before } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const hasAllPermissionAsyncMock = sinon.stub();
const getCachedSupportedVersionsTokenMock = sinon.stub();
// #ToDo: Fix those tests in a separate PR
describe.skip('#getServerInfo()', () => {
let getServerInfo: any;
before(() => {
const { getServerInfo: importedGetServerInfo } = proxyquire.noCallThru().load('./getServerInfo', {
'../../../app/utils/rocketchat.info': {
Info: {
version: '3.0.1',
},
},
'../../lib/authorization/hasPermission': {
hasPermissionAsync: hasAllPermissionAsyncMock,
},
'../../lib/cloud/supportedVersionsToken/supportedVersionsToken': {
getCachedSupportedVersionsToken: getCachedSupportedVersionsTokenMock,
},
'../../settings': {
settings: new Map(),
},
});
getServerInfo = importedGetServerInfo;
});
beforeEach(() => {
hasAllPermissionAsyncMock.reset();
getCachedSupportedVersionsTokenMock.reset();
});
it('should return only the version (without the patch info) when the user is not present', async () => {
expect(await getServerInfo(undefined)).to.be.eql({ version: '3.0' });
});
it('should return only the version (without the patch info) when the user present but they dont have permission', async () => {
hasAllPermissionAsyncMock.resolves(false);
expect(await getServerInfo('userId')).to.be.eql({ version: '3.0' });
});
it('should return the info object + the supportedVersions from the cloud when the request to the cloud was a success', async () => {
const signedJwt = 'signedJwt';
hasAllPermissionAsyncMock.resolves(true);
getCachedSupportedVersionsTokenMock.resolves(signedJwt);
expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1', supportedVersions: signedJwt } });
});
it('should return the info object ONLY from the cloud when the request to the cloud was NOT a success', async () => {
hasAllPermissionAsyncMock.resolves(true);
getCachedSupportedVersionsTokenMock.rejects();
expect(await getServerInfo('userId')).to.be.eql({ info: { version: '3.0.1' } });
});
});

View File

@@ -0,0 +1,34 @@
import type { IWorkspaceInfo } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { getTrimmedServerVersion } from './getTrimmedServerVersion';
import { Info, minimumClientVersions } from '../../../app/utils/rocketchat.info';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { getCachedSupportedVersionsToken, wrapPromise } from '../../lib/cloud/supportedVersionsToken/supportedVersionsToken';
import { settings } from '../../settings';
export async function getServerInfo(userId?: string): Promise<IWorkspaceInfo> {
const hasPermissionToViewStatistics = userId && (await hasPermissionAsync(userId, 'view-statistics'));
const supportedVersionsToken = await wrapPromise(getCachedSupportedVersionsToken());
const cloudWorkspaceId = settings.get<string | undefined>('Cloud_Workspace_Id');
return {
workspaceUrl: License.getWorkspaceUrl(),
hashedWorkspaceUrl: License.getHashedWorkspaceUrl(),
version: getTrimmedServerVersion(),
...(hasPermissionToViewStatistics && {
info: {
...Info,
},
version: Info.version,
}),
minimumClientVersions,
...(supportedVersionsToken.success &&
supportedVersionsToken.result && {
supportedVersions: { signed: supportedVersionsToken.result },
}),
cloudWorkspaceId,
};
}

View File

@@ -0,0 +1,4 @@
import { Info } from '../../../app/utils/rocketchat.info';
// Removes the patch version from the server version string
export const getTrimmedServerVersion = (): string => Info.version.replace(/(\d+\.\d+).*/, '$1');

View File

@@ -0,0 +1,196 @@
import { ReadableStream } from 'stream/web';
import { expect } from 'chai';
import { getUploadFormData } from './getUploadFormData';
const createMockRequest = (
fields: Record<string, string>,
file?: {
fieldname: string;
filename: string;
content: string | Buffer;
mimetype?: string;
},
options: { simulateError?: boolean } = {},
): Request => {
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
const parts: string[] = [];
if (file) {
parts.push(
`--${boundary}`,
`Content-Disposition: form-data; name="${file.fieldname}"; filename="${file.filename}"`,
`Content-Type: ${file.mimetype || 'application/octet-stream'}`,
'',
file.content.toString(),
);
}
for (const [name, value] of Object.entries(fields)) {
parts.push(`--${boundary}`, `Content-Disposition: form-data; name="${name}"`, '', value);
}
parts.push(`--${boundary}--`);
const buffer = Buffer.from(parts.join('\r\n'));
const mockRequest: any = {
headers: {
entries: () => [['content-type', `multipart/form-data; boundary=${boundary}`]],
},
body: new ReadableStream({
async pull(controller) {
if (options.simulateError) {
controller.error(new Error('aborted'));
return;
}
controller.enqueue(buffer);
controller.close();
},
}),
};
return mockRequest as Request & { headers: Record<string, string> };
};
describe('getUploadFormData', () => {
it('should successfully parse a single file upload and fields', async () => {
const mockRequest = createMockRequest(
{ fieldName: 'fieldValue' },
{
fieldname: 'fileField',
filename: 'test.txt',
content: 'Hello, this is a test file!',
mimetype: 'text/plain',
},
);
const result = await getUploadFormData({ request: mockRequest }, { field: 'fileField' });
expect(result).to.deep.include({
fieldname: 'fileField',
filename: 'test.txt',
mimetype: 'text/plain',
fields: { fieldName: 'fieldValue' },
});
expect(result.fileBuffer).to.not.be.undefined;
expect(result.fileBuffer.toString()).to.equal('Hello, this is a test file!');
});
it('should parse a file upload with multiple additional fields', async () => {
const mockRequest = createMockRequest(
{
fieldName: 'fieldValue',
extraField1: 'extraValue1',
extraField2: 'extraValue2',
},
{
fieldname: 'fileField',
filename: 'test_with_fields.txt',
content: 'This file has additional fields!',
mimetype: 'text/plain',
},
);
const result = await getUploadFormData({ request: mockRequest }, { field: 'fileField' });
expect(result).to.deep.include({
fieldname: 'fileField',
filename: 'test_with_fields.txt',
mimetype: 'text/plain',
fields: {
fieldName: 'fieldValue',
extraField1: 'extraValue1',
extraField2: 'extraValue2',
},
});
expect(result.fileBuffer).to.not.be.undefined;
expect(result.fileBuffer.toString()).to.equal('This file has additional fields!');
});
it('should handle a file upload when fileOptional is true', async () => {
const mockRequest = createMockRequest(
{ fieldName: 'fieldValue' },
{
fieldname: 'fileField',
filename: 'optional.txt',
content: 'This file is optional!',
mimetype: 'text/plain',
},
);
const result = await getUploadFormData({ request: mockRequest }, { fileOptional: true });
expect(result).to.deep.include({
fieldname: 'fileField',
filename: 'optional.txt',
mimetype: 'text/plain',
fields: { fieldName: 'fieldValue' },
});
expect(result.fileBuffer).to.not.be.undefined;
expect(result.fileBuffer?.toString()).to.equal('This file is optional!');
});
it('should throw an error when no file is uploaded and fileOptional is false', async () => {
const mockRequest = createMockRequest({ fieldName: 'fieldValue' });
try {
await getUploadFormData({ request: mockRequest }, { fileOptional: false });
throw new Error('Expected function to throw');
} catch (error) {
expect((error as Error).message).to.equal('[No file uploaded]');
}
});
it('should return fields without errors when no file is uploaded but fileOptional is true', async () => {
const mockRequest = createMockRequest({ fieldName: 'fieldValue' }); // No file
const result = await getUploadFormData({ request: mockRequest }, { fileOptional: true });
expect(result).to.deep.equal({
fields: { fieldName: 'fieldValue' },
file: undefined,
fileBuffer: undefined,
fieldname: undefined,
filename: undefined,
encoding: undefined,
mimetype: undefined,
});
});
it('should reject an oversized file', async () => {
const mockRequest = createMockRequest(
{},
{
fieldname: 'fileField',
filename: 'large.txt',
content: 'x'.repeat(1024 * 1024 * 2), // 2 MB file
mimetype: 'text/plain',
},
);
try {
await getUploadFormData(
{ request: mockRequest },
{ sizeLimit: 1024 * 1024 }, // 1 MB limit
);
throw new Error('Expected function to throw');
} catch (error) {
expect((error as Error).message).to.equal('[error-file-too-large]');
}
});
it('should handle an aborted request stream', async () => {
const mockRequest = createMockRequest({}, undefined, { simulateError: true });
try {
await getUploadFormData({ request: mockRequest }, { fileOptional: true });
throw new Error('Expected function to throw');
} catch (error) {
expect((error as Error).message).to.equal('aborted');
}
});
});

View File

@@ -0,0 +1,175 @@
import { Readable } from 'node:stream';
import { ReadableStream } from 'node:stream/web';
import { MeteorError } from '@rocket.chat/core-services';
import type { ValidateFunction } from 'ajv';
import busboy from 'busboy';
import { getMimeType } from '../../../app/utils/lib/mimeTypes';
type UploadResult<K> = {
file: Readable & { truncated: boolean };
fieldname: string;
filename: string;
encoding: string;
mimetype: string;
fileBuffer: Buffer;
fields: K;
};
type UploadResultWithOptionalFile<K> =
| UploadResult<K>
| ({
[P in keyof Omit<UploadResult<K>, 'fields'>]: undefined;
} & {
fields: K;
});
export async function getUploadFormData<
T extends string,
K extends Record<string, string> = Record<string, string>,
V extends ValidateFunction<K> = ValidateFunction<K>,
>(
{ request }: { request: Request },
options: {
field?: T;
validate?: V;
sizeLimit?: number;
fileOptional: true;
},
): Promise<UploadResultWithOptionalFile<K>>;
export async function getUploadFormData<
T extends string,
K extends Record<string, string> = Record<string, string>,
V extends ValidateFunction<K> = ValidateFunction<K>,
>(
{ request }: { request: Request },
options?: {
field?: T;
validate?: V;
sizeLimit?: number;
fileOptional?: false | undefined;
},
): Promise<UploadResult<K>>;
export async function getUploadFormData<
T extends string,
K extends Record<string, string> = Record<string, string>,
V extends ValidateFunction<K> = ValidateFunction<K>,
>(
{ request }: { request: Request },
options: {
field?: T;
validate?: V;
sizeLimit?: number;
fileOptional?: boolean;
} = {},
): Promise<UploadResultWithOptionalFile<K>> {
if (!(request.body instanceof ReadableStream)) {
return Promise.reject(new MeteorError('Invalid request body'));
}
const limits = {
files: 1,
...(options.sizeLimit && options.sizeLimit > -1 && { fileSize: options.sizeLimit }),
};
const bb = busboy({ headers: Object.fromEntries(request.headers.entries()), defParamCharset: 'utf8', limits });
const fields = Object.create(null) as K;
let uploadedFile: UploadResultWithOptionalFile<K> | undefined = {
fields,
encoding: undefined,
filename: undefined,
fieldname: undefined,
mimetype: undefined,
fileBuffer: undefined,
file: undefined,
};
const { promise: resultPromise, resolve, reject } = Promise.withResolvers<UploadResultWithOptionalFile<K>>();
function onField(fieldname: keyof K, value: K[keyof K]) {
fields[fieldname] = value;
}
function onEnd() {
if (!uploadedFile) {
return reject(new MeteorError('No file or fields were uploaded'));
}
if (!options.fileOptional && !uploadedFile?.file) {
return reject(new MeteorError('No file uploaded'));
}
if (options.validate !== undefined && !options.validate(fields)) {
return reject(
new MeteorError(`Invalid fields ${(options.validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join(', ')}`),
);
}
return resolve(uploadedFile);
}
function onFile(
fieldname: string,
file: Readable & { truncated: boolean },
{ filename, encoding, mimeType: mimetype }: { filename: string; encoding: string; mimeType: string },
) {
if (options.field && fieldname !== options.field) {
file.resume();
return reject(new MeteorError('invalid-field'));
}
const fileChunks: Uint8Array[] = [];
file.on('data', (chunk) => {
fileChunks.push(chunk);
});
file.on('end', () => {
if (file.truncated) {
fileChunks.length = 0;
return reject(new MeteorError('error-file-too-large'));
}
uploadedFile = {
file,
filename,
encoding,
mimetype: getMimeType(mimetype, filename),
fieldname,
fields,
fileBuffer: Buffer.concat(fileChunks),
};
});
}
function cleanup() {
bb.removeAllListeners();
}
bb.on('field', onField);
bb.on('file', onFile);
bb.on('close', cleanup);
bb.on('end', onEnd);
bb.on('finish', onEnd);
bb.on('error', (err: Error) => {
reject(err);
});
bb.on('partsLimit', () => {
reject();
});
bb.on('filesLimit', () => {
reject('Just 1 file is allowed');
});
bb.on('fieldsLimit', () => {
reject();
});
// Unclear why typescript complains that the ReadableStream from request.body is incompatible here
Readable.fromWeb(request.body satisfies ReadableStream)
.on('error', (err) => reject(err))
.pipe(bb);
return resultPromise;
}

View File

@@ -0,0 +1,98 @@
// Convenience method, almost need to turn it into a middleware of sorts
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
export async function getUserFromParams<T extends boolean = false>(
params: {
userId?: string;
username?: string;
user?: string;
},
full?: T,
): Promise<
T extends true
? IUser
: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusDefault' | 'statusText' | 'statusSource' | 'statusExpiresAt' | 'roles'>
> {
let user;
const projection = full
? {}
: { username: 1, name: 1, status: 1, statusDefault: 1, statusText: 1, statusSource: 1, statusExpiresAt: 1, roles: 1 };
if (params.userId?.trim()) {
user = await Users.findOneById(params.userId, { projection });
} else if (params.username?.trim()) {
user = await Users.findOneByUsernameIgnoringCase(params.username, { projection });
} else if (params.user?.trim()) {
user = await Users.findOneByUsernameIgnoringCase(params.user, { projection });
} else {
throw new Meteor.Error('error-user-param-not-provided', 'The required "userId" or "username" param was not provided');
}
if (!user) {
throw new Meteor.Error('error-invalid-user', 'The required "userId" or "username" param provided does not match any users');
}
return user;
}
export async function getUserListFromParams(params: {
userId?: string;
username?: string;
user?: string;
userIds?: string[];
usernames?: string[];
}): Promise<Pick<IUser, '_id' | 'username'>[]> {
// if params.userId is provided, include it as well
const soleUser = params.userId || params.username || params.user;
let userListParam = params.userIds || params.usernames || [];
userListParam.push(soleUser || '');
userListParam = userListParam.filter(Boolean);
// deduplicate to avoid errors
userListParam = [...new Set(userListParam)];
if (!userListParam.length) {
throw new Meteor.Error('error-users-params-not-provided', 'Please provide "userId" or "username" or "userIds" or "usernames" as param');
}
if (params.userIds || params.userId) {
return Users.findByIds(userListParam, { projection: { username: 1 } }).toArray();
}
return Users.findByUsernamesIgnoringCase(userListParam, { projection: { username: 1 } }).toArray();
}
/**
* Resolves a list of usernames from the request params without requiring the users to
* already exist locally. `username`/`usernames`/`user` are passed through verbatim, while
* `userId`/`userIds` are resolved to their usernames via the database.
*
* Unlike `getUserListFromParams`, this does not drop usernames that have no local record yet
* — which is what federation invites rely on: the federated user record is created lazily
* inside `addUsersToRoomMethod`.
*/
export async function getUsernameListFromParams(params: {
userId?: string;
username?: string;
user?: string;
userIds?: string[];
usernames?: string[];
}): Promise<string[]> {
const usernames = [...(params.usernames || []), params.username, params.user].filter(isTruthy);
const userIds = [...(params.userIds || []), params.userId].filter(isTruthy);
const usernamesFromIds = userIds.length
? (await Users.findByIds(userIds, { projection: { username: 1 } }).toArray()).map((u) => u.username).filter(isTruthy)
: [];
const all = [...new Set([...usernames, ...usernamesFromIds])];
if (!all.length) {
throw new Meteor.Error('error-users-params-not-provided', 'Please provide "userId" or "username" or "userIds" or "usernames" as param');
}
return all;
}

View File

@@ -0,0 +1,257 @@
import { getUserInfo } from './getUserInfo';
import type { CachedSettings } from '../../settings/CachedSettings';
const mockInfoVersion = jest.fn(() => '7.5.0');
jest.mock('../../../app/utils/rocketchat.info', () => ({
Info: {
get version() {
return mockInfoVersion();
},
},
}));
jest.mock('@rocket.chat/models', () => ({
Users: {
findOneById: jest.fn().mockResolvedValue({
id: '123',
name: 'Test User',
emails: [{ address: 'test@example.com' }],
}),
},
}));
const settings = new Map<string, unknown>();
jest.mock('../../settings', () => ({
settings: {
getByRegexp(_id) {
return [...settings].filter(([key]) => key.match(_id)) as any;
},
get(_id) {
return settings.get(_id) as any;
},
set(record) {
settings.set(record._id, record.value);
},
} satisfies Partial<CachedSettings>,
}));
// @ts-expect-error __meteor_runtime_config__ is not defined in the type definitions
global.__meteor_runtime_config__ = {
ROOT_URL: 'http://localhost:3000',
ROOT_URL_PATH_PREFIX: '',
};
describe('getUserInfo', () => {
let user: Parameters<typeof getUserInfo>[0];
beforeEach(() => {
settings.clear();
settings.set('Site_Url', 'http://localhost:3000');
user = {
_id: '123',
createdAt: new Date(),
roles: [],
type: 'user',
active: true,
_updatedAt: new Date(),
};
});
it('should return user info', async () => {
const userInfo = await getUserInfo(user);
expect(userInfo).toEqual(
expect.objectContaining({
_id: '123',
type: 'user',
roles: [],
active: true,
_updatedAt: expect.any(Date),
createdAt: expect.any(Date),
email: undefined,
avatarUrl: 'http://localhost:3000/avatar/undefined',
settings: {
calendar: {},
profile: {},
preferences: {},
},
}),
);
});
it("should NOT return user preferences when 'pullPreferences' param is false", async () => {
const userInfo = await getUserInfo(user, false);
expect(userInfo.settings).not.toHaveProperty('preferences');
});
describe('email handling', () => {
it('should not include email if no emails are present', async () => {
user.emails = [];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toBe(undefined);
});
it('should include email if one email is present and verified', async () => {
user.emails = [{ address: 'test@example.com', verified: true }];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toEqual('test@example.com');
});
it('should not include email if one email is present and not verified', async () => {
user.emails = [{ address: 'test@example.com', verified: false }];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toBe(undefined);
});
it('should include email if multiple emails are present and one is verified', async () => {
user.emails = [
{ address: 'test@example.com', verified: false },
{ address: 'test2@example.com', verified: true },
];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toEqual('test2@example.com');
});
it('should not include email if multiple emails are present and none are verified', async () => {
user.emails = [
{ address: 'test@example.com', verified: false },
{ address: 'test2@example.com', verified: false },
];
const userInfo = await getUserInfo(user);
expect(userInfo.email).toBe(undefined);
});
});
describe('outlook calendar settings', () => {
beforeEach(() => {
settings.set('Outlook_Calendar_Enabled', true);
settings.set('Outlook_Calendar_Exchange_Url', 'https://outlook.office365.com/');
settings.set('Outlook_Calendar_Outlook_Url', 'https://outlook.office365.com/owa/#calendar/view/month');
settings.set('Outlook_Calendar_Url_Mapping', JSON.stringify({}));
user.emails = [{ address: 'test@example.com', verified: true }];
});
it('should return empty calendar settings if Outlook is disabled', async () => {
settings.set('Outlook_Calendar_Enabled', false);
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({});
});
it('should return calendar settings with Outlook enabled and default URLs', async () => {
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar?.outlook).toEqual({
Enabled: true,
Exchange_Url: 'https://outlook.office365.com/',
Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month',
});
});
it('should return calendar settings with Outlook enabled and domain mapping', async () => {
settings.set(
'Outlook_Calendar_Url_Mapping',
JSON.stringify({
'example.com': { Exchange_Url: 'https://custom.exchange.com/', Outlook_Url: 'https://custom.outlook.com/' },
}),
);
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://custom.exchange.com/',
Outlook_Url: 'https://custom.outlook.com/',
},
});
});
it('should return calendar settings with outlook disabled but enabled for specific domain', async () => {
settings.set('Outlook_Calendar_Enabled', false);
settings.set(
'Outlook_Calendar_Url_Mapping',
JSON.stringify({
'specific.com': { Enabled: true, Exchange_Url: 'https://specific.exchange.com/', Outlook_Url: 'https://specific.outlook.com/' },
}),
);
user.emails = [{ address: 'me@specific.com', verified: true }];
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://specific.exchange.com/',
Outlook_Url: 'https://specific.outlook.com/',
},
});
});
it('should return calendar settings with Outlook enabled and default mapping for unknown domain', async () => {
user.emails = [{ address: 'unknown@example.com', verified: true }];
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://outlook.office365.com/',
Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month',
},
});
});
it('should handle invalid JSON in Outlook_Calendar_Url_Mapping', async () => {
settings.set('Outlook_Calendar_Url_Mapping', 'invalid json');
const userInfo = await getUserInfo(user);
expect(userInfo.settings?.calendar).toEqual({
outlook: {
Enabled: true,
Exchange_Url: 'https://outlook.office365.com/',
Outlook_Url: 'https://outlook.office365.com/owa/#calendar/view/month',
},
});
});
});
describe('version update banner filtering', () => {
beforeEach(() => {
mockInfoVersion.mockReturnValue('7.5.0');
});
it('should filter out versionUpdate banners for versions <= current installed', async () => {
user.banners = {
'versionUpdate-6_2_0': {
id: 'versionUpdate-6_2_0',
priority: 10,
title: 'Update',
text: 'New version',
modifiers: [],
link: '',
read: false,
},
};
const userInfo = await getUserInfo(user);
expect(userInfo.banners).toEqual({});
});
it('should keep versionUpdate banners for versions > current installed', async () => {
user.banners = {
'versionUpdate-8_0_0': {
id: 'versionUpdate-8_0_0',
priority: 10,
title: 'Update',
text: 'New version',
modifiers: [],
link: '',
read: false,
},
};
const userInfo = await getUserInfo(user);
expect(userInfo.banners).toHaveProperty('versionUpdate-8_0_0');
});
it('should keep non-versionUpdate banners unchanged', async () => {
user.banners = {
'other-banner': { id: 'other-banner', priority: 10, title: 'Other', text: 'Other banner', modifiers: [], link: '', read: false },
};
const userInfo = await getUserInfo(user);
expect(userInfo.banners).toHaveProperty('other-banner');
});
});
});

View File

@@ -0,0 +1,129 @@
import { isOAuthUser, type IMeApiUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings';
import semver from 'semver';
import { Info } from '../../../app/utils/rocketchat.info';
import { resolveUsersByIds } from '../../lib/statusVisibility/resolveUsers';
import { getURL } from '../../lib/utils/getURL';
import { getUserPreference } from '../../lib/utils/lib/getUserPreference';
import { settings } from '../../settings';
const isVerifiedEmail = (me: IUser): false | IUserEmail | undefined => {
if (!me || !Array.isArray(me.emails)) {
return false;
}
return me.emails.find((email) => email.verified);
};
const getPreferencesWithDefaults = async (me: IUser): Promise<Record<string, unknown>> => {
const defaultUserSettingPrefix = 'Accounts_Default_User_Preferences_';
const allDefaultUserSettings = settings.getByRegexp(new RegExp(`^${defaultUserSettingPrefix}.*$`));
const accumulator: Record<string, any> = {};
for (const [key] of allDefaultUserSettings) {
const settingWithoutPrefix = key.replace(defaultUserSettingPrefix, ' ').trim();
accumulator[settingWithoutPrefix] = await getUserPreference(me, settingWithoutPrefix);
}
return accumulator;
};
const filterOutdatedVersionUpdateBanners = (banners: NonNullable<IUser['banners']>): IUser['banners'] => {
return Object.fromEntries(
Object.entries(banners).filter(([id]) => {
if (!id.startsWith('versionUpdate-')) {
return true;
}
const version = id.replace('versionUpdate-', '').replace(/_/g, '.');
if (!semver.valid(version) || semver.lte(version, Info.version)) {
return false;
}
return true;
}),
);
};
/**
* Returns the user's calendar settings based on their email domain and the configured mapping.
* If the email is not provided or the domain is not found in the mapping,
* it returns the default Outlook calendar settings.
* @param email - The user's email object, which may contain the address and verification status.
* @returns The calendar settings for the user, including Outlook calendar settings if enabled.
*/
const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar => {
const calendarSettings: IUserCalendar = {};
const outlook = {
Enabled: settings.get<boolean>('Outlook_Calendar_Enabled'),
Exchange_Url: settings.get<string>('Outlook_Calendar_Exchange_Url'),
Outlook_Url: settings.get<string>('Outlook_Calendar_Outlook_Url'),
};
const domain = email ? email.address.split('@').pop() : undefined;
const outlookCalendarUrlMapping = settings.get<string>('Outlook_Calendar_Url_Mapping');
if (domain && outlookCalendarUrlMapping && outlookCalendarUrlMapping.includes(domain)) {
try {
const mappingObject = JSON.parse(outlookCalendarUrlMapping);
const mappedSettings = mappingObject[domain];
if (mappedSettings) {
outlook.Enabled = mappedSettings.Enabled ?? outlook.Enabled;
outlook.Exchange_Url = mappedSettings.Exchange_Url ?? outlook.Exchange_Url;
outlook.Outlook_Url = mappedSettings.Outlook_Url ?? outlook.Outlook_Url;
}
} catch (error) {
console.error('Invalid Outlook Calendar URL Mapping JSON:', error);
}
}
if (outlook.Enabled) {
calendarSettings.outlook = outlook;
}
return calendarSettings;
};
export async function getUserInfo(me: IUser, pullPreferences = true): Promise<IMeApiUser> {
const verifiedEmail = isVerifiedEmail(me);
const { statusVisibilityDenied, ...savedPreferences } = me.settings?.preferences ?? {};
const preferences = pullPreferences
? {
...(await getPreferencesWithDefaults(me)),
...savedPreferences,
...(settings.get<boolean>('Accounts_StatusVisibility_Enabled') &&
statusVisibilityDenied?.length && {
statusVisibilityDenied: (await resolveUsersByIds(statusVisibilityDenied)).usernames,
}),
}
: undefined;
return {
...me,
...(me.banners && { banners: filterOutdatedVersionUpdateBanners(me.banners) }),
email: verifiedEmail ? verifiedEmail.address : undefined,
settings: {
profile: {},
...(preferences && { preferences }),
calendar: getUserCalendar(verifiedEmail),
},
avatarUrl: getURL(`/avatar/${me.username}`, { cdn: false, full: true }),
isOAuthUser: isOAuthUser(me),
...(me.services && {
services: {
...(me.services.github && { github: me.services.github as Record<string, unknown> }),
...(me.services.gitlab && { gitlab: me.services.gitlab as Record<string, unknown> }),
...(me.services.email2fa?.enabled && { email2fa: { enabled: me.services.email2fa.enabled } }),
...(me.services.totp?.enabled && { totp: { enabled: me.services.totp.enabled } }),
password: {
// The password hash shouldn't be leaked but the client may need to know if it exists.
exists: Boolean(me.services?.password?.bcrypt),
},
},
}),
// Cast needed: spread of full IUser produces a superset; runtime response schema validates the actual shape
} as IMeApiUser;
}

View File

@@ -0,0 +1,40 @@
import type { IIntegration, IUser } from '@rocket.chat/core-typings';
import { Integrations } from '@rocket.chat/models';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
const hasIntegrationsPermission = async (userId: string, integration: IIntegration): Promise<boolean> => {
const type = integration.type === 'webhook-incoming' ? 'incoming' : 'outgoing';
if (await hasPermissionAsync(userId, `manage-${type}-integrations`)) {
return true;
}
if (userId === integration._createdBy?._id) {
return hasPermissionAsync(userId, `manage-own-${type}-integrations`);
}
return false;
};
export const findOneIntegration = async ({
userId,
integrationId,
createdBy,
}: {
userId: string;
integrationId: string;
createdBy?: IUser['_id'];
}): Promise<IIntegration> => {
const integration = await Integrations.findOneByIdAndCreatedByIfExists({
_id: integrationId,
createdBy,
});
if (!integration) {
throw new Error('The integration does not exists.');
}
if (!(await hasIntegrationsPermission(userId, integration))) {
throw new Error('not-authorized');
}
return integration;
};

View File

@@ -0,0 +1,14 @@
import type { IUser } from '@rocket.chat/core-typings';
export function isUserFromParams(
params: { userId?: string; username?: string; user?: string },
loggedInUserId?: string,
loggedInUser?: IUser,
): boolean {
return Boolean(
(!params.userId && !params.username && !params.user) ||
(params.userId && loggedInUserId === params.userId) ||
(params.username && loggedInUser?.username === params.username) ||
(params.user && loggedInUser?.username === params.user),
);
}

View File

@@ -0,0 +1,66 @@
import { isRecord } from '@rocket.chat/tools';
import { removeDangerousProps } from './cleanQuery';
type Query = { [k: string]: any };
export const isValidQuery: {
(query: Query, allowedAttributes: string[], allowedOperations: string[]): boolean;
errors: string[];
} = Object.assign(
(query: Query, allowedAttributes: string[], allowedOperations: string[]): boolean => {
isValidQuery.errors = [];
// query is an object with null prototype, so it wont be instance of Object
if (!isRecord(query)) {
throw new Error('query must be an object');
}
return verifyQuery(query, allowedAttributes, allowedOperations);
},
{
errors: [],
},
);
const verifyQuery = (query: Query, allowedAttributes: string[], allowedOperations: string[], parent = ''): boolean => {
return Object.entries(removeDangerousProps({ ...query })).every(([key, value]) => {
const path = parent ? `${parent}.${key}` : key;
if (key.startsWith('$')) {
if (!allowedOperations.includes(key)) {
isValidQuery.errors.push(`Invalid operation: ${key}`);
return false;
}
if (Array.isArray(value)) {
return value.every((v) => verifyQuery(v, allowedAttributes, allowedOperations));
}
if (isRecord(value)) {
return verifyQuery(value, allowedAttributes, allowedOperations, path);
}
// handles primitive values (strings, numbers, booleans, etc.)
return true;
}
if (
!allowedAttributes.some((allowedAttribute) => {
if (allowedAttribute.endsWith('.*')) {
return path.startsWith(allowedAttribute.replace('.*', ''));
}
if (allowedAttribute.endsWith('*') && !allowedAttribute.endsWith('.*')) {
return true;
}
return path === allowedAttribute;
})
) {
isValidQuery.errors.push(`Invalid attribute: ${path}`);
return false;
}
if (isRecord(value)) {
return verifyQuery(value, allowedAttributes, allowedOperations, path);
}
return true;
});
};

View File

@@ -0,0 +1,8 @@
import { parse } from 'cookie';
export const isWidget = (headers: Headers): boolean => {
const { rc_room_type: roomType, rc_is_widget: isWidget } = parse(headers.get('cookie') || '');
const isLivechatRoom = roomType && roomType === 'l';
return !!(isLivechatRoom && isWidget === 't');
};

View File

@@ -0,0 +1,82 @@
import { expect } from 'chai';
import { describe, it, before, beforeEach } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const debugMock = sinon.stub();
const errorMock = sinon.stub();
const meteorDebugMock = sinon.stub();
const settingsGetMock = sinon.stub();
describe('logMethodCallError', () => {
let logMethodCallError: (method: string, err: unknown) => void;
before(() => {
({ logMethodCallError } = proxyquire.noCallThru().load('./logMethodCallError', {
'meteor/meteor': {
Meteor: { _debug: meteorDebugMock },
},
'../../lib/logger/system': {
SystemLogger: { debug: debugMock, error: errorMock },
},
'../../settings': {
settings: { get: settingsGetMock },
},
}));
});
beforeEach(() => {
debugMock.reset();
errorMock.reset();
meteorDebugMock.reset();
settingsGetMock.reset();
settingsGetMock.withArgs('Log_Level').returns('2');
});
it('should not report client-safe errors as exceptions', () => {
const err = { isClientSafe: true, error: 'error-invalid-user' };
logMethodCallError('loadHistory', err);
expect(meteorDebugMock.called).to.be.false;
expect(errorMock.called).to.be.false;
expect(debugMock.calledOnce).to.be.true;
expect(debugMock.calledWithExactly({ msg: 'Expected error while invoking method', err, method: 'loadHistory' })).to.be.true;
});
it('should not report meteor errors as exceptions', () => {
const err = { meteorError: { error: 'totp-required' } };
logMethodCallError('login', err);
expect(meteorDebugMock.called).to.be.false;
expect(errorMock.called).to.be.false;
expect(debugMock.calledOnce).to.be.true;
expect(debugMock.calledWithExactly({ msg: 'Expected error while invoking method', err, method: 'login' })).to.be.true;
});
it('should report unexpected errors as exceptions when Log_Level is 2', () => {
const err = new Error('Match error: Expected string, got number');
logMethodCallError('loadHistory', err);
expect(debugMock.called).to.be.false;
expect(errorMock.calledOnce).to.be.true;
expect(errorMock.calledWithExactly({ msg: 'Exception while invoking method', err, method: 'loadHistory' })).to.be.true;
expect(meteorDebugMock.calledOnce).to.be.true;
expect(meteorDebugMock.calledWithExactly('Exception while invoking method loadHistory', err)).to.be.true;
});
it('should report unexpected errors without notifying the exceptions channel when Log_Level is not 2', () => {
settingsGetMock.withArgs('Log_Level').returns('0');
const err = new Error('Match error: Expected string, got number');
logMethodCallError('loadHistory', err);
expect(debugMock.called).to.be.false;
expect(errorMock.calledOnce).to.be.true;
expect(errorMock.calledWithExactly({ msg: 'Exception while invoking method', err, method: 'loadHistory' })).to.be.true;
expect(meteorDebugMock.called).to.be.false;
});
});

View File

@@ -0,0 +1,17 @@
import { Meteor } from 'meteor/meteor';
import { SystemLogger } from '../../lib/logger/system';
import { settings } from '../../settings';
export function logMethodCallError(method: string, err: unknown): void {
if ((err as any)?.isClientSafe || (err as any)?.meteorError) {
SystemLogger.debug({ msg: 'Expected error while invoking method', err, method });
return;
}
SystemLogger.error({ msg: 'Exception while invoking method', err, method });
if (settings.get('Log_Level') === '2') {
Meteor._debug(`Exception while invoking method ${method}`, err);
}
}

View File

@@ -0,0 +1,40 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import type { FindOptions } from 'mongodb';
import { projectionAllowsAttribute } from './projectionAllowsAttribute';
import { migrateVisitorIfMissingContact } from '../../lib/omnichannel/contacts/migrateVisitorIfMissingContact';
/**
* If the room is a livechat room and it doesn't yet have a contact, trigger the migration for its visitor and source
* The migration will create/use a contact and assign it to every room that matches this visitorId and source.
**/
export async function maybeMigrateLivechatRoom(
room: IOmnichannelRoom | null,
options: FindOptions<IRoom> = {},
): Promise<IOmnichannelRoom | null> {
if (!room || !isOmnichannelRoom(room)) {
return room;
}
// Already migrated
if (room.contactId) {
return room;
}
// If the query options specify that contactId is not needed, then do not trigger the migration
if (!projectionAllowsAttribute('contactId', options)) {
return room;
}
const contactId = await migrateVisitorIfMissingContact(room.v._id, room.source);
// Did not migrate
if (!contactId) {
return room;
}
// Load the room again with the same options so it can be reloaded with the contactId in place
return LivechatRooms.findOneById(room._id, options);
}

View File

@@ -0,0 +1,121 @@
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import { Rooms, Messages, Users } from '@rocket.chat/models';
import type { FindOptions } from 'mongodb';
import { canAccessRoomAsync } from '../../lib/authorization/canAccessRoom';
export async function findMentionedMessages({
uid,
roomId,
pagination: { offset, count, sort },
}: {
uid: string;
roomId: string;
pagination: { offset: number; count: number; sort: FindOptions<IMessage>['sort'] };
}): Promise<{
messages: IMessage[];
count: number;
offset: number;
total: number;
}> {
const room = await Rooms.findOneById(roomId);
if (!room || !(await canAccessRoomAsync(room, { _id: uid }))) {
throw new Error('error-not-allowed');
}
const user = await Users.findOneById<Pick<IUser, 'username'>>(uid, { projection: { username: 1 } });
if (!user) {
throw new Error('invalid-user');
}
const { cursor, totalCount } = Messages.findPaginatedVisibleByMentionAndRoomId(user.username, roomId, {
sort: sort || { ts: -1 },
skip: offset,
limit: count,
});
const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
messages,
count: messages.length,
offset,
total,
};
}
export async function findStarredMessages({
uid,
roomId,
pagination: { offset, count, sort },
}: {
uid: string;
roomId: string;
pagination: { offset: number; count: number; sort: FindOptions<IMessage>['sort'] };
}): Promise<{
messages: IMessage[];
count: number;
offset: number;
total: number;
}> {
const room = await Rooms.findOneById(roomId);
if (!room || !(await canAccessRoomAsync(room, { _id: uid }))) {
throw new Error('error-not-allowed');
}
const user = await Users.findOneById<Pick<IUser, 'username'>>(uid, { projection: { username: 1 } });
if (!user) {
throw new Error('invalid-user');
}
const { cursor, totalCount } = Messages.findStarredByUserAtRoom(uid, roomId, {
sort: sort || { ts: -1 },
skip: offset,
limit: count,
});
const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
messages,
count: messages.length,
offset,
total,
};
}
export async function findDiscussionsFromRoom({
uid,
roomId,
text,
pagination: { offset, count, sort },
}: {
uid: string;
roomId: string;
text: string;
pagination: { offset: number; count: number; sort: FindOptions<IMessage>['sort'] };
}): Promise<{
messages: IMessage[];
count: number;
offset: number;
total: number;
}> {
const room = await Rooms.findOneById(roomId);
if (!room || !(await canAccessRoomAsync(room, { _id: uid }))) {
throw new Error('error-not-allowed');
}
const { cursor, totalCount } = await Messages.findDiscussionsByRoomAndText(roomId, text, {
sort: sort || { ts: -1 },
skip: offset,
limit: count,
});
const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
messages,
count: messages.length,
offset,
total,
};
}

View File

@@ -0,0 +1,159 @@
import ejson from 'ejson';
import { Meteor } from 'meteor/meteor';
import { clean } from './cleanQuery';
import { isValidQuery } from './isValidQuery';
import { isPlainObject } from '../../../lib/utils/isPlainObject';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { apiDeprecationLogger } from '../../lib/deprecationWarningLogger';
import { API } from '../api';
import type { GenericRouteExecutionContext } from '../definition';
const pathAllowConf = {
'/api/v1/users.list': ['$or', '$regex', '$and'],
'def': ['$or', '$and', '$regex'],
};
export async function parseJsonQuery(api: GenericRouteExecutionContext): Promise<{
sort: Record<string, 1 | -1>;
/**
* @deprecated To access "fields" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable.
*/
fields: Record<string, 0 | 1>;
/**
* @deprecated To access "query" parameter, use ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS environment variable.
*/
query: Record<string, unknown>;
}> {
const { userId = '', response, route, logger } = api;
const isUsersRoute = route.includes('/v1/users.');
const canViewFullOtherUserInfo = isUsersRoute && (await hasPermissionAsync(userId, 'view-full-other-user-info'));
const params = isPlainObject(api.queryParams) ? api.queryParams : {};
const queryFields = Array.isArray(api.queryFields) ? (api.queryFields as string[]) : [];
const queryOperations = Array.isArray(api.queryOperations) ? (api.queryOperations as string[]) : [];
let sort;
if (typeof params?.sort === 'string') {
try {
sort = JSON.parse(params.sort);
Object.entries(sort).forEach(([key, value]) => {
if (value !== 1 && value !== -1) {
throw new Meteor.Error('error-invalid-sort-parameter', `Invalid sort parameter: ${key}`, {
helperMethod: 'parseJsonQuery',
});
}
});
} catch (e) {
logger.warn({
msg: 'Invalid sort parameter provided',
sort: params.sort,
err: e,
});
throw new Meteor.Error('error-invalid-sort', `Invalid sort parameter provided: \"${params.sort}\"`, {
helperMethod: 'parseJsonQuery',
});
}
}
const isUnsafeQueryParamsAllowed = process.env.ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS?.toUpperCase() === 'TRUE';
const messageGenerator = ({ endpoint, version, parameter }: { endpoint: string; version: string; parameter: string }): string =>
`The usage of the "${parameter}" parameter in endpoint "${endpoint}" breaks the security of the API and can lead to data exposure. It has been deprecated and will be removed in the version ${version}.`;
let fields: Record<string, 0 | 1> | undefined;
if (typeof params?.fields === 'string' && isUnsafeQueryParamsAllowed) {
try {
apiDeprecationLogger.parameter(route, 'fields', '9.0.0', response, messageGenerator);
fields = JSON.parse(params.fields) as Record<string, 0 | 1>;
Object.entries(fields).forEach(([key, value]) => {
if (value !== 1 && value !== 0) {
throw new Meteor.Error('error-invalid-sort-parameter', `Invalid fields parameter: ${key}`, {
helperMethod: 'parseJsonQuery',
});
}
});
} catch (e) {
logger.warn({
msg: 'Invalid fields parameter provided',
fields: params.fields,
err: e,
});
throw new Meteor.Error('error-invalid-fields', `Invalid fields parameter provided: \"${params.fields}\"`, {
helperMethod: 'parseJsonQuery',
});
}
}
// Verify the user's selected fields only contains ones which their role allows
if (typeof fields === 'object') {
let nonSelectableFields = Object.keys(API.v1.defaultFieldsToExclude);
if (isUsersRoute) {
nonSelectableFields = nonSelectableFields.concat(
Object.keys(canViewFullOtherUserInfo ? API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser : API.v1.limitedUserFieldsToExclude),
);
}
Object.keys(fields).forEach((k) => {
if (nonSelectableFields.includes(k) || nonSelectableFields.includes(k.split(API.v1.fieldSeparator)[0])) {
fields && delete fields[k as keyof typeof fields];
}
});
}
// Limit the fields by default
fields = Object.assign({}, fields, API.v1.defaultFieldsToExclude);
if (isUsersRoute) {
if (canViewFullOtherUserInfo) {
fields = Object.assign(fields, API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser);
} else {
fields = Object.assign(fields, API.v1.limitedUserFieldsToExclude);
}
}
let query: Record<string, any> = {};
if (typeof params?.query === 'string' && isUnsafeQueryParamsAllowed) {
apiDeprecationLogger.parameter(route, 'query', '9.0.0', response, messageGenerator);
try {
query = ejson.parse(params.query);
query = clean(query, pathAllowConf.def);
} catch (e) {
logger.warn({
msg: 'Invalid query parameter provided',
query: params.query,
err: e,
});
throw new Meteor.Error('error-invalid-query', `Invalid query parameter provided: \"${params.query}\"`, {
helperMethod: 'parseJsonQuery',
});
}
}
// Verify the user has permission to query the fields they are
if (typeof query === 'object') {
let nonQueryableFields = Object.keys(API.v1.defaultFieldsToExclude);
if (isUsersRoute) {
if (canViewFullOtherUserInfo) {
nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser));
} else {
nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExclude));
}
}
const containsQueryFields = queryFields.length > 0;
if (containsQueryFields && !isValidQuery(query, queryFields, queryOperations ?? pathAllowConf.def)) {
throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n'));
}
Object.keys(query).forEach((k) => {
if (nonQueryableFields.includes(k) || nonQueryableFields.includes(k.split(API.v1.fieldSeparator)[0])) {
query && delete query[k];
}
});
}
return {
sort,
fields,
query,
};
}

View File

@@ -0,0 +1,29 @@
import { expect } from 'chai';
import { projectionAllowsAttribute } from './projectionAllowsAttribute';
describe('projectionAllowsAttribute', () => {
it('should return true if there are no options', () => {
expect(projectionAllowsAttribute('attributeName')).to.be.equal(true);
});
it('should return true if there is no projection', () => {
expect(projectionAllowsAttribute('attributeName', {})).to.be.equal(true);
});
it('should return true if the field is projected', () => {
expect(projectionAllowsAttribute('attributeName', { projection: { attributeName: 1 } })).to.be.equal(true);
});
it('should return false if the field is disallowed by projection', () => {
expect(projectionAllowsAttribute('attributeName', { projection: { attributeName: 0 } })).to.be.equal(false);
});
it('should return false if the field is not projected and others are', () => {
expect(projectionAllowsAttribute('attributeName', { projection: { anotherAttribute: 1 } })).to.be.equal(false);
});
it('should return true if the field is not projected and others are disallowed', () => {
expect(projectionAllowsAttribute('attributeName', { projection: { anotherAttribute: 0 } })).to.be.equal(true);
});
});

View File

@@ -0,0 +1,19 @@
import type { IRocketChatRecord } from '@rocket.chat/core-typings';
import type { FindOptions } from 'mongodb';
export function projectionAllowsAttribute(attributeName: string, options?: FindOptions<IRocketChatRecord>): boolean {
if (!options?.projection) {
return true;
}
if (attributeName in options.projection) {
return Boolean(options.projection[attributeName]);
}
const projectingAllowedFields = Object.values(options.projection).some((value) => Boolean(value));
// If the attribute is not on the projection list, return the opposite of the values in the projection. aka:
// if the projection is specifying blocked fields, then this field is allowed;
// if the projection is specifying allowed fields, then this field is blocked;
return !projectingAllowedFields;
}

View File

@@ -0,0 +1,33 @@
import { expect } from 'chai';
import { queryFiltersStatus } from './queryFiltersStatus';
describe('queryFiltersStatus', () => {
it('should return false for a query without status fields', () => {
expect(queryFiltersStatus({ username: { $regex: 'ana' } })).to.be.equal(false);
});
it('should return true for a top level status filter', () => {
expect(queryFiltersStatus({ status: 'online' })).to.be.equal(true);
});
it('should return true for a status filter nested in $or', () => {
expect(queryFiltersStatus({ $or: [{ username: { $regex: '' } }, { status: 'online' }] })).to.be.equal(true);
});
it('should return true for a status filter nested in $and inside $or', () => {
expect(queryFiltersStatus({ $or: [{ $and: [{ statusText: { $regex: 'lunch' } }] }] })).to.be.equal(true);
});
it('should return true for any redacted status field', () => {
for (const field of ['statusText', 'statusSource', 'statusExpiresAt', 'statusDefault', 'statusConnection']) {
expect(queryFiltersStatus({ [field]: { $exists: true } })).to.be.equal(true);
}
});
it('should return false for null and primitive values', () => {
expect(queryFiltersStatus(null)).to.be.equal(false);
expect(queryFiltersStatus('status')).to.be.equal(false);
expect(queryFiltersStatus(undefined)).to.be.equal(false);
});
});

View File

@@ -0,0 +1,11 @@
export function queryFiltersStatus(query: unknown): boolean {
if (Array.isArray(query)) {
return query.some(queryFiltersStatus);
}
if (query === null || typeof query !== 'object') {
return false;
}
return Object.entries(query).some(([key, value]) => key.startsWith('status') || queryFiltersStatus(value));
}

View File

@@ -0,0 +1,202 @@
import type { IRoom, IRoomAbacRedaction, ISubscription, RoomAdminFieldsType, RoomType } from '@rocket.chat/core-typings';
import { Rooms, Subscriptions } from '@rocket.chat/models';
import type { FindOptions, Sort } from 'mongodb';
import { scopeAdminRoomsForAbac } from './scopeAdminRoomsForAbac';
import { adminFields } from '../../../lib/rooms/adminFields';
import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { stripABACManagedFieldsForAdmin } from '../../lib/authorization/isABACManagedRoom';
export async function findAdminRooms({
uid,
filter,
types = [],
pagination: { offset, count, sort },
}: {
uid: string;
filter: string;
types: Array<RoomType | 'discussions' | 'teams'>;
pagination: { offset: number; count: number; sort: Sort };
}): Promise<{
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
count: number;
offset: number;
total: number;
}> {
if (!(await hasPermissionAsync(uid, 'view-room-administration'))) {
throw new Error('error-not-authorized');
}
const name = filter?.trim();
const discussion = types?.includes('discussions');
const includeTeams = types?.includes('teams');
const typesToRemove = ['discussions', 'teams'];
const showTypes = Array.isArray(types) ? types.filter((type): type is RoomType => !typesToRemove.includes(type)) : [];
const options: FindOptions<IRoom> = {
projection: adminFields,
skip: offset,
limit: count,
};
const result = Rooms.findByNameOrFnameContainingAndTypes(name, showTypes, discussion, includeTeams, options);
const { cursor, totalCount } = result;
const [rooms, total] = await Promise.all([
cursor
.sort(sort || { default: -1, name: 1 })
.map(stripABACManagedFieldsForAdmin)
.toArray(),
totalCount,
]);
return {
rooms: await scopeAdminRoomsForAbac(rooms, uid),
count: rooms.length,
offset,
total,
};
}
export async function findAdminRoom({
uid,
rid,
}: {
uid: string;
rid: string;
}): Promise<(Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction) | null> {
if (!(await hasPermissionAsync(uid, 'view-room-administration'))) {
throw new Error('error-not-authorized');
}
const room = await Rooms.findOneById(rid, { projection: adminFields });
if (!room) {
return null;
}
const [scoped] = await scopeAdminRoomsForAbac([stripABACManagedFieldsForAdmin(room)], uid);
return scoped ?? null;
}
export async function findChannelAndPrivateAutocomplete({ uid, selector }: { uid: string; selector: { name: string } }): Promise<{
items: IRoom[];
}> {
const options: FindOptions<IRoom> = {
projection: {
_id: 1,
fname: 1,
name: 1,
t: 1,
avatarETag: 1,
encrypted: 1,
},
limit: 10,
sort: {
name: 1,
},
};
const userRoomsIds = (await Subscriptions.findByUserId(uid, { projection: { rid: 1 } }).toArray()).map(
(item: Pick<ISubscription, 'rid'>) => item.rid,
);
const rooms = await Rooms.findRoomsWithoutDiscussionsByRoomIds(selector.name, userRoomsIds, options).toArray();
return {
items: rooms,
};
}
export async function findAdminRoomsAutocomplete({ uid, selector }: { uid: string; selector: { name: string } }): Promise<{
items: IRoom[];
}> {
if (!(await hasAtLeastOnePermissionAsync(uid, ['view-room-administration', 'can-audit']))) {
throw new Error('error-not-authorized');
}
const options: FindOptions<IRoom> = {
projection: {
_id: 1,
fname: 1,
name: 1,
t: 1,
avatarETag: 1,
encrypted: 1,
},
limit: 10,
sort: {
name: 1,
},
};
const rooms = await Rooms.findRoomsByNameOrFnameStarting(selector.name, options).toArray();
return {
items: rooms,
};
}
export async function findChannelAndPrivateAutocompleteWithPagination({
uid,
selector,
pagination: { offset, count, sort },
}: {
uid: string;
selector: { name: string };
pagination: { offset: number; count: number; sort: Sort };
}): Promise<{
items: IRoom[];
total: number;
}> {
const userRoomsIds = (await Subscriptions.findByUserId(uid, { projection: { rid: 1 } }).toArray()).map(
(item: Pick<ISubscription, 'rid'>) => item.rid,
);
const options: FindOptions<IRoom> = {
projection: {
_id: 1,
fname: 1,
name: 1,
t: 1,
avatarETag: 1,
},
sort: sort || { name: 1 },
skip: offset,
limit: count,
};
const { cursor, totalCount } = Rooms.findPaginatedRoomsWithoutDiscussionsByRoomIds(selector.name, userRoomsIds, options);
const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
items: rooms,
total,
};
}
export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name?: string }): Promise<{
items: IRoom[];
}> {
const options: FindOptions<IRoom> = {
projection: {
_id: 1,
fname: 1,
name: 1,
t: 1,
avatarETag: 1,
},
limit: 10,
sort: {
name: 1,
},
};
const userRooms = (
(await Subscriptions.findByUserIdAndRoles(uid, ['owner'], { projection: { rid: 1 } }).toArray()) as Pick<ISubscription, 'rid'>[]
).map((item) => item.rid);
const rooms = await Rooms.findChannelAndGroupListWithoutTeamsByNameStartingByOwner(name, userRooms, options).toArray();
return {
items: rooms,
};
}

View File

@@ -0,0 +1,7 @@
import type { IRoom, IRoomAbacRedaction, RoomAdminFieldsType } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
export const scopeAdminRoomsForAbac = makeFunction(
async (rooms: Pick<IRoom, RoomAdminFieldsType>[], _uid: string): Promise<Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>> =>
rooms,
);

View File

@@ -0,0 +1,242 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users, Subscriptions } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/tools';
import type { Mongo } from 'meteor/mongo';
import type { Filter, FindOptions, RootFilterOperators } from 'mongodb';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { settings } from '../../settings';
type UserAutoComplete = Required<Pick<IUser, '_id' | 'name' | 'username' | 'nickname' | 'status' | 'avatarETag'>>;
export async function findUsersToAutocomplete({
uid,
selector,
}: {
uid: string;
selector: { exceptions: Required<IUser>['username'][]; conditions: Filter<IUser>; term: string };
}): Promise<{
items: UserAutoComplete[];
}> {
const searchFields = settings.get<string>('Accounts_SearchFields').trim().split(',');
const exceptions = selector.exceptions || [];
const conditions = selector.conditions || {};
const options: FindOptions<IUser> & { limit: number } = {
projection: {
name: 1,
username: 1,
nickname: 1,
status: 1,
avatarETag: 1,
freeSwitchExtension: 1,
},
sort: {
username: 1,
},
limit: 10,
};
// Search on DMs first, to list known users before others.
const contacts = await Subscriptions.findConnectedUsersExcept(uid, selector.term, exceptions, searchFields, conditions, 10, 'd');
if (contacts.length >= options.limit) {
return { items: contacts as UserAutoComplete[] };
}
options.limit -= contacts.length;
contacts.forEach(({ username }) => exceptions.push(username));
if (!(await hasPermissionAsync(uid, 'view-outside-room'))) {
const users = await Subscriptions.findConnectedUsersExcept(uid, selector.term, exceptions, searchFields, conditions, 10);
return { items: contacts.concat(users) as UserAutoComplete[] };
}
const users = await Users.findActiveByUsernameOrNameRegexWithExceptionsAndConditions<UserAutoComplete>(
new RegExp(escapeRegExp(selector.term), 'i'),
exceptions,
conditions,
options,
).toArray();
return {
items: (contacts as UserAutoComplete[]).concat(users),
};
}
/**
* Returns a new query object with the inclusive fields only
*/
export function getInclusiveFields(query: Record<string, 1 | 0>): Record<string, 1> {
const newQuery = Object.create(null);
for (const [key, value] of Object.entries(query)) {
if (value === 1) {
newQuery[key] = value;
}
}
return newQuery;
}
/**
* get the default fields if **fields** are empty (`{}`) or `undefined`/`null`
* @param fields the fields from parsed jsonQuery
*/
export function getNonEmptyFields(fields: Record<string, 1 | 0>): Record<string, 1 | 0> {
const defaultFields = {
name: 1,
username: 1,
emails: 1,
roles: 1,
status: 1,
active: 1,
avatarETag: 1,
lastLogin: 1,
type: 1,
} as const;
if (!fields || Object.keys(fields).length === 0) {
return defaultFields;
}
return { ...defaultFields, ...fields };
}
/**
* get the default query if **query** is empty (`{}`) or `undefined`/`null`
* @param query the query from parsed jsonQuery
*/
export function getNonEmptyQuery<T extends IUser>(query: Mongo.Query<T> | undefined | null, canSeeAllUserInfo?: boolean): Mongo.Query<T> {
const defaultQuery: Mongo.Query<IUser> = {
$or: [{ username: { $regex: '', $options: 'i' } }, { name: { $regex: '', $options: 'i' } }],
};
if (canSeeAllUserInfo) {
defaultQuery.$or?.push({ 'emails.address': { $regex: '', $options: 'i' } });
}
if (!query || Object.keys(query).length === 0) {
return defaultQuery;
}
return { ...defaultQuery, ...query };
}
type FindPaginatedUsersByStatusProps = {
uid: string;
offset: number;
count: number;
sort: Record<string, 1 | -1>;
status?: 'active' | 'deactivated';
roles?: string[] | null;
searchTerm?: string;
hasLoggedIn?: boolean;
type?: string;
inactiveReason?: ('deactivated' | 'pending_approval' | 'idle_too_long')[];
};
export async function findPaginatedUsersByStatus({
uid,
offset,
count,
sort,
status,
roles,
searchTerm,
hasLoggedIn,
type,
inactiveReason,
}: FindPaginatedUsersByStatusProps) {
const actualSort: Record<string, 1 | -1> = sort || { username: 1 };
if (sort?.status) {
actualSort.active = sort.status;
}
const match: Filter<IUser & RootFilterOperators<IUser>> = {};
switch (status) {
case 'active':
match.active = true;
break;
case 'deactivated':
match.active = false;
break;
}
if (hasLoggedIn !== undefined) {
match.lastLogin = { $exists: hasLoggedIn };
}
if (type) {
match.type = type;
}
const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info');
const projection = {
name: 1,
username: 1,
emails: 1,
roles: 1,
status: 1,
active: 1,
avatarETag: 1,
lastLogin: 1,
type: 1,
reason: 1,
federated: 1,
freeSwitchExtension: 1,
};
if (searchTerm?.trim()) {
match.$or = [
...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []),
{
username: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' },
},
{
name: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' },
},
];
}
if (roles?.length && !roles.includes('all')) {
match.roles = { $in: roles };
}
if (inactiveReason) {
const inactiveReasonCondition = {
$or: [
{ inactiveReason: { $in: inactiveReason } },
// This condition is to make it backward compatible with the old behavior
// The deactivated users not having the inactiveReason field should be returned as well
...(inactiveReason.includes('deactivated') || inactiveReason.includes('idle_too_long')
? [{ inactiveReason: { $exists: false } }]
: []),
],
};
if (match.$or) {
match.$and = [{ $or: match.$or }, inactiveReasonCondition];
delete match.$or;
} else {
Object.assign(match, inactiveReasonCondition);
}
}
const { cursor, totalCount } = Users.findPaginated(
{
...match,
},
{
sort: actualSort,
skip: offset,
limit: count,
projection,
allowDiskUse: true,
},
);
const [users, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
users,
count: users.length,
offset,
total,
};
}

View File

@@ -0,0 +1,13 @@
import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings';
import { WebdavAccounts } from '@rocket.chat/models';
export async function findWebdavAccountsByUserId({ uid }: { uid: string }): Promise<IWebdavAccountIntegration[]> {
return WebdavAccounts.findWithUserId(uid, {
projection: {
_id: 1,
username: 1,
serverURL: 1,
name: 1,
},
}).toArray();
}

View File

@@ -0,0 +1,56 @@
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import type { APIActionContext } from './router';
import { RocketChatAPIRouter } from './router';
describe('Router use method', () => {
it('should parse nested query params into object for GET requests', async () => {
const ajv = new Ajv();
const app = express();
const isTestQueryParams = ajv.compile({
type: 'object',
properties: {
outerProperty: { type: 'object', properties: { innerProperty: { type: 'string' } } },
},
additionalProperties: false,
required: ['outerProperty'],
});
const api = new RocketChatAPIRouter('/api').get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
outerProperty: { type: 'object', properties: { innerProperty: { type: 'string' } } },
},
additionalProperties: false,
required: ['outerProperty'],
}),
},
query: isTestQueryParams,
},
async function action(this: APIActionContext) {
const { outerProperty } = this.queryParams;
return {
statusCode: 200,
body: {
outerProperty,
},
};
},
);
app.use(api.router);
const response1 = await request(app).get('/api/test?outerProperty[innerProperty]=test');
expect(response1.statusCode).toBe(200);
expect(response1.body).toHaveProperty('outerProperty');
expect(response1.body.outerProperty).toHaveProperty('innerProperty', 'test');
});
});

View File

@@ -0,0 +1,81 @@
import type { IncomingMessage } from 'node:http';
import type { IUser } from '@rocket.chat/core-typings';
import type { ResponseSchema } from '@rocket.chat/http-router';
import { Router } from '@rocket.chat/http-router';
import { type Logger } from '@rocket.chat/logger';
import type { Context } from 'hono';
import type { TypedOptions } from './definition';
export type HonoContext = Context<{
Bindings: { incoming: IncomingMessage };
Variables: {
remoteAddress: string;
bodyParams: Record<string, unknown>;
queryParams: Record<string, unknown>;
user?: IUser | null;
};
}>;
export type APIActionContext = {
requestIp: string;
urlParams: Record<string, string>;
queryParams: Record<string, unknown>;
bodyParams: Record<string, unknown>;
request: Request;
path: string;
response: any;
route: string;
incoming: IncomingMessage;
logger: Logger;
};
export type APIActionHandler = (this: APIActionContext, request: Request) => Promise<ResponseSchema<TypedOptions>>;
export class RocketChatAPIRouter<
TBasePath extends string,
TOperations extends {
[x: string]: unknown;
} = NonNullable<unknown>,
> extends Router<TBasePath, TOperations, APIActionHandler> {
protected override convertActionToHandler(
action: APIActionHandler,
logger: Logger,
): (c: HonoContext) => Promise<ResponseSchema<TypedOptions>> {
return async (c: HonoContext): Promise<ResponseSchema<TypedOptions>> => {
const { req } = c;
const request = req.raw.clone();
const context = convertHonoContextToApiActionContext(c, { logger });
return action.apply(context, [request]);
};
}
}
export const convertHonoContextToApiActionContext = (
c: HonoContext,
options: {
logger: Logger;
},
): APIActionContext => {
const user = c.get('user');
return {
requestIp: c.get('remoteAddress'),
urlParams: c.req.param(),
queryParams: c.get('queryParams'),
bodyParams: c.get('bodyParams'),
request: c.req.raw,
path: c.req.path,
response: c.res,
route: c.req.routePath,
incoming: c.env.incoming,
logger: options.logger,
...(user && { user, userId: user._id }),
};
};
export type ExtractRouterEndpoints<TRoute extends RocketChatAPIRouter<any, any>> =
TRoute extends RocketChatAPIRouter<any, infer TOperations> ? TOperations : never;

View File

@@ -0,0 +1,385 @@
import { AI_SEARCH_PAGE_SIZE, MAX_INTELLIGENT_SEARCH_RESULTS, MAX_SEARCH_FILTER_VALUES } from '@rocket.chat/ai-search';
import { AISearch } from '@rocket.chat/core-services';
import { isBannedSubscription, type IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms, Subscriptions } from '@rocket.chat/models';
import {
ajv,
isSearchAnswerProps,
isAISearchProps,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import type { AISearchResult, SearchAnswer } from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { getSettingPermissionId } from '../../../app/authorization/lib';
import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission';
import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser';
import { API } from '../api';
const aiSearchResponseSchema = ajv.compile<{
intelligent: AISearchResult[];
meta: {
intelligentSearchEnabled: boolean;
intelligentSearchConfigured: boolean;
answerGenerationConfigured: boolean;
};
}>({
type: 'object',
properties: {
intelligent: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
rid: { type: 'string' },
msgId: { type: 'string' },
text: { type: 'string' },
score: { type: 'number' },
room: {
type: 'object',
properties: {
_id: { type: 'string' },
t: { type: 'string' },
name: { type: 'string' },
fname: { type: 'string' },
},
required: ['_id', 't'],
additionalProperties: true,
},
},
required: ['_id', 'text'],
additionalProperties: true,
},
},
meta: {
type: 'object',
properties: {
intelligentSearchEnabled: { type: 'boolean' },
intelligentSearchConfigured: { type: 'boolean' },
answerGenerationConfigured: { type: 'boolean' },
},
required: ['intelligentSearchEnabled', 'intelligentSearchConfigured', 'answerGenerationConfigured'],
additionalProperties: false,
},
success: { type: 'boolean', enum: [true] },
},
required: ['intelligent', 'meta', 'success'],
additionalProperties: false,
});
const aiModelsResponseSchema = ajv.compile<{ data: { key: string; label: string }[] }>({
type: 'object',
properties: {
data: {
type: 'array',
items: {
type: 'object',
properties: {
key: { type: 'string' },
label: { type: 'string' },
},
required: ['key', 'label'],
additionalProperties: false,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['data', 'success'],
additionalProperties: false,
});
const searchAnswerResponseSchema = ajv.compile<{
answer: string;
provider: { name: string; model: string };
}>({
type: 'object',
properties: {
answer: { type: 'string' },
provider: {
type: 'object',
properties: {
name: { type: 'string' },
model: { type: 'string' },
},
required: ['name', 'model'],
additionalProperties: false,
},
success: { type: 'boolean', enum: [true] },
},
required: ['answer', 'provider', 'success'],
additionalProperties: false,
});
const parseCommaList = (value: string | undefined): string[] => {
if (!value) {
return [];
}
const items: string[] = [];
for (const item of value.split(',')) {
const normalizedItem = item.trim();
if (normalizedItem) {
items.push(normalizedItem);
if (items.length === MAX_SEARCH_FILTER_VALUES) {
break;
}
}
}
return items;
};
const parseQueryDate = (value: string | undefined): Date | undefined => (value ? new Date(value) : undefined);
const getRoomMap = async (roomIds: string[]): Promise<Map<string, Pick<IRoom, '_id' | 't' | 'name' | 'fname'>>> => {
if (!roomIds.length) {
return new Map();
}
const rooms = await Rooms.findByIds([...new Set(roomIds)], {
projection: { _id: 1, t: 1, name: 1, fname: 1 },
}).toArray();
return new Map(rooms.map((room) => [room._id, room]));
};
const getSubscribedRoomIds = async (userId: string, roomIds: string[]): Promise<Set<string>> => {
const uniqueRoomIdSet = new Set<string>();
for (const roomId of roomIds) {
if (roomId) {
uniqueRoomIdSet.add(roomId);
}
}
const uniqueRoomIds = [...uniqueRoomIdSet];
if (!uniqueRoomIds.length) {
return new Set();
}
const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, {
projection: { rid: 1, status: 1 },
}).toArray();
const subscribedRoomIds = new Set<string>();
for (const subscription of subscriptions) {
if (!isBannedSubscription(subscription)) {
subscribedRoomIds.add(subscription.rid);
}
}
return subscribedRoomIds;
};
const getSearchAnswerMessagesForUser = async (userId: string, messages: SearchAnswer['messages']) => {
const messageIds: string[] = [];
const messageIdSet = new Set<string>();
const scoreByMessageId = new Map<string, number | undefined>();
const clampScore = (score: number | undefined): number | undefined =>
typeof score === 'number' && Number.isFinite(score) ? Math.min(1, Math.max(0, score)) : undefined;
for (const { _id, score } of messages) {
if (!_id) {
continue;
}
if (!messageIdSet.has(_id)) {
messageIdSet.add(_id);
messageIds.push(_id);
}
scoreByMessageId.set(_id, clampScore(score));
}
if (!messageIds.length) {
throw new Meteor.Error('error-invalid-search-answer-sources');
}
const docs = await Messages.findVisibleByIds(messageIds, {
projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 },
}).toArray();
const subscribedRoomIds = await getSubscribedRoomIds(
userId,
docs.map((message) => message.rid),
);
if (docs.length !== messageIds.length || docs.some((message) => !subscribedRoomIds.has(message.rid))) {
throw new Meteor.Error('error-invalid-search-answer-sources');
}
const normalizedDocs = await normalizeMessagesForUser(docs, userId);
const docsById = new Map(normalizedDocs.map((message) => [message._id, message]));
const rooms = await getRoomMap(normalizedDocs.map((message) => message.rid));
const answerMessages = [];
for (const messageId of messageIds) {
const message = docsById.get(messageId);
if (message?.msg) {
const score = scoreByMessageId.get(message._id);
const room = rooms.get(message.rid);
answerMessages.push({
text: message.msg,
username: message.u?.username,
roomName: room?.fname || room?.name,
ts: message.ts?.toISOString(),
...(Number.isFinite(score) && { score }),
});
}
}
if (!answerMessages.length) {
throw new Meteor.Error('error-invalid-search-answer-sources');
}
return answerMessages;
};
API.v1.get(
'ai.search',
{
authRequired: true,
query: isAISearchProps,
rateLimiterOptions: {
numRequestsAllowed: 120,
intervalTimeInMS: 60000,
},
response: {
200: aiSearchResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const query = this.queryParams.query.trim();
const requestedIntelligentCount = this.queryParams.intelligentCount ?? AI_SEARCH_PAGE_SIZE;
const intelligentLimit = Math.min(Math.max(Math.floor(requestedIntelligentCount), 1), MAX_INTELLIGENT_SEARCH_RESULTS);
const rid = this.queryParams.rid || undefined;
const rids = parseCommaList(this.queryParams.rids);
const roomNames = parseCommaList(this.queryParams.roomNames);
const fromUsername = this.queryParams.fromUsername || undefined;
const fromUsernames = parseCommaList(this.queryParams.fromUsernames);
const startDate = parseQueryDate(this.queryParams.startDate);
const endDate = parseQueryDate(this.queryParams.endDate);
const aiSearchStatus = await AISearch.status().catch((error) => {
this.logger.warn({ msg: 'AI search status unavailable', err: error });
return {
hasIntelligentSearchLicense: false,
intelligentSearchEnabled: false,
intelligentSearchConfigured: false,
answerGenerationConfigured: false,
};
});
let intelligentResults: AISearchResult[] = [];
if (
aiSearchStatus.hasIntelligentSearchLicense &&
aiSearchStatus.intelligentSearchEnabled &&
aiSearchStatus.intelligentSearchConfigured
) {
try {
intelligentResults = await AISearch.search({
query,
userId: this.userId,
filters: {
rid,
rids,
roomNames,
fromUsername,
fromUsernames,
startDate: startDate?.toISOString(),
endDate: endDate?.toISOString(),
},
limit: intelligentLimit,
});
} catch (error) {
this.logger.warn({ msg: 'AI search request failed', err: error });
intelligentResults = [];
}
}
return API.v1.success({
intelligent: intelligentResults,
meta: {
intelligentSearchEnabled: aiSearchStatus.intelligentSearchEnabled,
intelligentSearchConfigured: aiSearchStatus.intelligentSearchConfigured,
answerGenerationConfigured: aiSearchStatus.answerGenerationConfigured,
},
});
},
);
API.v1.get(
'ai.llm.models',
{
authRequired: true,
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
response: {
200: aiModelsResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const canAccessAllSettings = await hasAtLeastOnePermissionAsync(this.userId, ['view-privileged-setting', 'edit-privileged-setting']);
const canAccessModelSetting =
canAccessAllSettings ||
(await hasAllPermissionAsync(this.userId, ['manage-selected-settings', getSettingPermissionId('AI_LLM_OpenAI_Model')]));
if (!canAccessModelSetting) {
return API.v1.forbidden();
}
return API.v1.success({
data: await AISearch.models(),
});
},
);
API.v1.post(
'ai.search.answer',
{
authRequired: true,
body: isSearchAnswerProps,
rateLimiterOptions: {
numRequestsAllowed: 10,
intervalTimeInMS: 60000,
},
response: {
200: searchAnswerResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { query, messages } = this.bodyParams;
// gate before any DB work so users cannot drive message lookups for an unlicensed feature
const aiSearchStatus = await AISearch.status();
if (
!aiSearchStatus.hasIntelligentSearchLicense ||
!aiSearchStatus.intelligentSearchEnabled ||
!aiSearchStatus.answerGenerationConfigured
) {
throw new Meteor.Error('error-ai-not-enabled');
}
const answerMessages = await getSearchAnswerMessagesForUser(this.userId, messages);
try {
const answer = await AISearch.answer({
query,
messages: answerMessages,
});
return API.v1.success(answer);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message === 'error-ai-not-enabled') {
throw new Meteor.Error('error-ai-not-enabled');
}
if (message === 'error-ai-provider-not-configured') {
throw new Meteor.Error('error-ai-provider-not-configured');
}
if (message === 'error-ai-provider-empty-response') {
throw new Meteor.Error('error-ai-provider-empty-response');
}
throw new Meteor.Error('error-ai-provider-request-failed');
}
},
);

View File

@@ -0,0 +1,115 @@
import { Settings } from '@rocket.chat/models';
import {
ajv,
isAssetsUnsetAssetProps,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
import { RocketChatAssets, refreshClients } from '../../lib/media/assets';
import { notifyOnSettingChangedById } from '../../lib/notifyListener';
import { settings } from '../../settings';
import { updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates';
import { API } from '../api';
import { getUploadFormData } from '../lib/getUploadFormData';
const successResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
API.v1.post(
'assets.setAsset',
{
authRequired: true,
permissionsRequired: ['manage-assets'],
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const asset = await getUploadFormData(
{
request: this.request,
},
{ field: 'asset', sizeLimit: settings.get('FileUpload_MaxFileSize') },
);
const { fileBuffer, fields, filename, mimetype } = asset;
const { refreshAllClients, assetName: customName } = fields;
const assetName = customName || filename;
const assetsKeys = Object.keys(RocketChatAssets.assets);
const isValidAsset = assetsKeys.includes(assetName);
if (!isValidAsset) {
throw new Error('Invalid asset');
}
const { key, value } = await RocketChatAssets.setAssetWithBuffer(fileBuffer, mimetype, assetName);
const { modifiedCount } = await updateAuditedByUser({
_id: this.userId,
username: this.user.username ?? '',
ip: this.requestIp ?? '',
useragent: this.request.headers.get('user-agent') ?? '',
})(Settings.updateValueById, key, value);
if (modifiedCount) {
void notifyOnSettingChangedById(key);
}
if (refreshAllClients) {
await refreshClients(this.userId);
}
return API.v1.success();
},
);
API.v1.post(
'assets.unsetAsset',
{
authRequired: true,
body: isAssetsUnsetAssetProps,
permissionsRequired: ['manage-assets'],
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { assetName, refreshAllClients } = this.bodyParams;
const isValidAsset = Object.keys(RocketChatAssets.assets).includes(assetName);
if (!isValidAsset) {
throw Error('Invalid asset');
}
const { key, value } = await RocketChatAssets.unsetAsset(assetName);
const { modifiedCount } = await updateAuditedByUser({
_id: this.userId,
username: this.user.username ?? '',
ip: this.requestIp ?? '',
useragent: this.request.headers.get('user-agent') ?? '',
})(Settings.updateValueById, key, value);
if (modifiedCount) {
void notifyOnSettingChangedById(key);
}
if (refreshAllClients) {
await refreshClients(this.userId);
}
return API.v1.success();
},
);

View File

@@ -0,0 +1,165 @@
import type { IMessage, ISupportedLanguage } from '@rocket.chat/core-typings';
import { Messages, Rooms } from '@rocket.chat/models';
import {
ajv,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
isAutotranslateSaveSettingsParamsPOST,
isAutotranslateGetSupportedLanguagesParamsGET,
} from '@rocket.chat/rest-typings';
import { canAccessRoomAsync } from '../../lib/authorization';
import { getSupportedLanguages } from '../../lib/autotranslate/functions/getSupportedLanguages';
import { saveAutoTranslateSettings } from '../../lib/autotranslate/functions/saveSettings';
import { translateMessage } from '../../lib/autotranslate/functions/translateMessage';
import { settings } from '../../settings';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
type AutotranslateTranslateMessageParamsPOST = {
messageId: string;
targetLanguage?: string;
};
const AutotranslateTranslateMessageParamsPostSchema = {
type: 'object',
properties: {
messageId: {
type: 'string',
},
targetLanguage: {
type: 'string',
nullable: true,
},
},
required: ['messageId'],
additionalProperties: false,
};
const isAutotranslateTranslateMessageParamsPOST = ajv.compile<AutotranslateTranslateMessageParamsPOST>(
AutotranslateTranslateMessageParamsPostSchema,
);
const autotranslateEndpoints = API.v1
.get(
'autotranslate.getSupportedLanguages',
{
authRequired: true,
query: isAutotranslateGetSupportedLanguagesParamsGET,
response: {
200: ajv.compile<{ languages: ISupportedLanguage[] }>({
type: 'object',
properties: {
languages: { type: 'array', items: { type: 'object' } },
success: { type: 'boolean', enum: [true] },
},
required: ['languages', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!settings.get('AutoTranslate_Enabled')) {
return API.v1.failure('AutoTranslate is disabled.');
}
const { targetLanguage } = this.queryParams;
const languages = await getSupportedLanguages(this.userId, targetLanguage);
return API.v1.success({ languages: languages || [] });
},
)
.post(
'autotranslate.saveSettings',
{
authRequired: true,
body: isAutotranslateSaveSettingsParamsPOST,
response: {
200: ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { roomId, field, value, defaultLanguage } = this.bodyParams;
if (!settings.get('AutoTranslate_Enabled')) {
return API.v1.failure('AutoTranslate is disabled.');
}
// ajv 2020-12 with coerceTypes coerces booleans to strings, so check for both
if (field === 'autoTranslate' && value !== true && value !== 'true' && value !== false && value !== 'false') {
return API.v1.failure('The bodyParam "autoTranslate" must be a boolean.');
}
if (field === 'autoTranslateLanguage' && (typeof value !== 'string' || !Number.isNaN(Number.parseInt(value)))) {
return API.v1.failure('The bodyParam "autoTranslateLanguage" must be a string.');
}
await saveAutoTranslateSettings(this.userId, roomId, field, value === true || value === 'true' ? '1' : String(value), {
defaultLanguage: defaultLanguage || '',
});
return API.v1.success();
},
)
.post(
'autotranslate.translateMessage',
{
authRequired: true,
body: isAutotranslateTranslateMessageParamsPOST,
response: {
200: ajv.compile<{ message: IMessage }>({
type: 'object',
properties: {
message: { $ref: '#/components/schemas/IMessage' },
success: { type: 'boolean', enum: [true] },
},
required: ['message', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { messageId, targetLanguage } = this.bodyParams;
if (!settings.get('AutoTranslate_Enabled')) {
return API.v1.failure('AutoTranslate is disabled.');
}
if (!messageId) {
return API.v1.failure('The bodyParam "messageId" is required.');
}
const message = await Messages.findOneById(messageId);
if (!message) {
return API.v1.failure('Message not found.');
}
const room = await Rooms.findOneById(message.rid);
if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) {
return API.v1.forbidden();
}
const translatedMessage = await translateMessage(targetLanguage, message);
if (!translatedMessage) {
return API.v1.failure('Failed to translate message.');
}
return API.v1.success({ message: translatedMessage });
},
);
type AutotranslateEndpoints = ExtractRoutesFromAPI<typeof autotranslateEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends AutotranslateEndpoints {}
}

View File

@@ -0,0 +1,204 @@
import { Banner } from '@rocket.chat/core-services';
import type { IBanner } from '@rocket.chat/core-typings';
import {
ajv,
isBannersDismissProps,
isBannersProps,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { API } from '../api';
const bannersResponseSchema = ajv.compile<{ banners: IBanner[] }>({
type: 'object',
properties: {
banners: { type: 'array', items: { $ref: '#/components/schemas/IBanner' } },
success: { type: 'boolean', enum: [true] },
},
required: ['banners', 'success'],
additionalProperties: false,
});
const dismissResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
/**
* @openapi
* /api/v1/banners/{id}:
* get:
* description: Gets the banner to be shown to the authenticated user
* security:
* $ref: '#/security/authenticated'
* parameters:
* - name: platform
* in: query
* description: The platform rendering the banner
* required: true
* schema:
* type: string
* enum: [web, mobile]
* example: web
* - name: id
* in: path
* description: The id of the banner
* required: true
* schema:
* type: string
* example: ByehQjC44FwMeiLbX
* responses:
* 200:
* description: |
* A collection with a single banner matching the criteria; an empty
* collection otherwise
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiSuccessV1'
* - type: object
* properties:
* banners:
* type: array
* items:
* $ref: '#/components/schemas/IBanner'
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
API.v1.get(
'banners/:id',
{
authRequired: true,
query: isBannersProps,
response: {
200: bannersResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { platform } = this.queryParams;
const { id } = this.urlParams;
const banners = await Banner.getBannersForUser(this.userId, platform, id);
return API.v1.success({ banners });
},
);
/**
* @openapi
* /api/v1/banners:
* get:
* description: Gets the banners to be shown to the authenticated user
* security:
* $ref: '#/security/authenticated'
* parameters:
* - name: platform
* in: query
* description: The platform rendering the banner
* required: true
* schema:
* type: string
* enum: [web, mobile]
* example: web
* responses:
* 200:
* description: The banners matching the criteria
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiSuccessV1'
* - type: object
* properties:
* banners:
* type: array
* items:
* $ref: '#/components/schemas/IBanner'
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
API.v1.get(
'banners',
{
authRequired: true,
query: isBannersProps,
response: {
200: bannersResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { platform } = this.queryParams;
const banners = await Banner.getBannersForUser(this.userId, platform);
return API.v1.success({ banners });
},
);
/**
* @openapi
* /api/v1/banners.dismiss:
* post:
* description: Dismisses a banner
* security:
* $ref: '#/security/authenticated'
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* bannerId:
* type: string
* example: |
* {
* "bannerId": "ByehQjC44FwMeiLbX"
* }
* responses:
* 200:
* description: The banners matching the criteria
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiSuccessV1'
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
API.v1.post(
'banners.dismiss',
{
authRequired: true,
body: isBannersDismissProps,
response: {
200: dismissResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { bannerId } = this.bodyParams;
await Banner.dismiss(this.userId, bannerId);
return API.v1.success();
},
);

View File

@@ -0,0 +1,224 @@
import { Calendar } from '@rocket.chat/core-services';
import type { ICalendarEvent } from '@rocket.chat/core-typings';
import {
ajv,
isCalendarEventListProps,
isCalendarEventCreateProps,
isCalendarEventImportProps,
isCalendarEventInfoProps,
isCalendarEventUpdateProps,
isCalendarEventDeleteProps,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { API } from '../api';
const successWithDataSchema = ajv.compile<{ data: ICalendarEvent[] }>({
type: 'object',
properties: {
data: {},
success: { type: 'boolean', enum: [true] },
},
required: ['data', 'success'],
additionalProperties: false,
});
const successWithEventSchema = ajv.compile<{ event: ICalendarEvent }>({
type: 'object',
properties: {
event: { $ref: '#/components/schemas/ICalendarEvent' },
success: { type: 'boolean', enum: [true] },
},
required: ['event', 'success'],
additionalProperties: false,
});
const successWithIdSchema = ajv.compile<{ id: ICalendarEvent['_id'] }>({
type: 'object',
properties: {
id: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['id', 'success'],
additionalProperties: false,
});
const successSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
API.v1.get(
'calendar-events.list',
{
authRequired: true,
query: isCalendarEventListProps,
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 },
response: {
200: successWithDataSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { userId } = this;
const { date } = this.queryParams;
const data = await Calendar.list(userId, new Date(date));
return API.v1.success({ data });
},
);
API.v1.get(
'calendar-events.info',
{
authRequired: true,
query: isCalendarEventInfoProps,
rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 },
response: {
200: successWithEventSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { userId } = this;
const { id } = this.queryParams;
const event = await Calendar.get(id);
if (event?.uid !== userId) {
return API.v1.failure();
}
return API.v1.success({ event });
},
);
API.v1.post(
'calendar-events.create',
{
authRequired: true,
body: isCalendarEventCreateProps,
response: {
200: successWithIdSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { userId: uid } = this;
const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams;
const id = await Calendar.create({
uid,
startTime: new Date(startTime),
...(endTime && { endTime: new Date(endTime) }),
externalId,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
...(typeof busy === 'boolean' && { busy }),
});
return API.v1.success({ id });
},
);
API.v1.post(
'calendar-events.import',
{
authRequired: true,
body: isCalendarEventImportProps,
response: {
200: successWithIdSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { userId: uid } = this;
const { startTime, endTime, externalId, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams;
const id = await Calendar.import({
uid,
startTime: new Date(startTime),
...(endTime && { endTime: new Date(endTime) }),
externalId,
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
...(typeof busy === 'boolean' && { busy }),
});
return API.v1.success({ id });
},
);
API.v1.post(
'calendar-events.update',
{
authRequired: true,
body: isCalendarEventUpdateProps,
response: {
200: successSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { userId } = this;
const { eventId, startTime, endTime, subject, description, meetingUrl, reminderMinutesBeforeStart, busy } = this.bodyParams;
const event = await Calendar.get(eventId);
if (event?.uid !== userId) {
throw new Error('invalid-calendar-event');
}
await Calendar.update(eventId, {
startTime: new Date(startTime),
...(endTime && { endTime: new Date(endTime) }),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
...(typeof busy === 'boolean' && { busy }),
});
return API.v1.success();
},
);
API.v1.post(
'calendar-events.delete',
{
authRequired: true,
body: isCalendarEventDeleteProps,
response: {
200: successSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { userId } = this;
const { eventId } = this.bodyParams;
const event = await Calendar.get(eventId);
if (event?.uid !== userId) {
throw new Error('invalid-calendar-event');
}
await Calendar.delete(eventId);
return API.v1.success();
},
);

View File

@@ -0,0 +1,234 @@
import { CallHistory as CallHistoryService } from '@rocket.chat/core-services';
import type { CallHistoryItem, CallHistoryItemState, IMediaCall } from '@rocket.chat/core-typings';
import { CallHistory, MediaCalls } from '@rocket.chat/models';
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
import {
ajv,
ajvQuery,
validateNotFoundErrorResponse,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { ensureArray } from '../../../lib/utils/arrayUtils';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { getPaginationItems } from '../lib/getPaginationItems';
type CallHistoryList = PaginatedRequest<{
filter?: string;
direction?: CallHistoryItem['direction'];
state?: CallHistoryItemState[];
}>;
const CallHistoryListSchema = {
type: 'object',
properties: {
count: {
type: 'number',
},
offset: {
type: 'number',
},
sort: {
type: 'string',
},
filter: {
type: 'string',
},
direction: {
type: 'string',
enum: ['inbound', 'outbound'],
},
state: {
type: 'array',
items: {
$ref: '#/components/schemas/CallHistoryItemState',
},
},
},
required: [],
additionalProperties: false,
};
export const isCallHistoryListProps = ajvQuery.compile<CallHistoryList>(CallHistoryListSchema);
const callHistoryListEndpoints = API.v1.get(
'call-history.list',
{
response: {
200: ajv.compile<
PaginatedResult<{
items: CallHistoryItem[];
}>
>({
additionalProperties: false,
type: 'object',
properties: {
count: {
type: 'number',
description: 'The number of history items returned in this response.',
},
offset: {
type: 'number',
description: 'The number of history items that were skipped in this response.',
},
total: {
type: 'number',
description: 'The total number of history items that match the query.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
items: {
type: 'array',
items: {
$ref: '#/components/schemas/CallHistoryItem',
},
},
},
required: ['count', 'offset', 'total', 'items', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
query: isCallHistoryListProps,
authRequired: true,
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | number | null | undefined>);
const { sort } = await this.parseJsonQuery();
const { direction, state, filter } = this.queryParams;
const searchTerm = typeof filter === 'string' && filter.trim();
const { items, total } = await CallHistoryService.search(
this.userId,
{
...(searchTerm && { searchTerm }),
...(direction && { direction }),
...(state && { inStates: ensureArray(state) }),
},
{ count, offset, sort },
);
return API.v1.success({
items,
count: items.length,
offset,
total,
});
},
);
type CallHistoryListEndpoints = ExtractRoutesFromAPI<typeof callHistoryListEndpoints>;
type CallHistoryInfo = { historyId: string } | { callId: string };
const CallHistoryInfoSchema = {
oneOf: [
{
type: 'object',
properties: {
historyId: {
type: 'string',
nullable: false,
},
},
required: ['historyId'],
additionalProperties: false,
},
{
type: 'object',
properties: {
callId: {
type: 'string',
nullable: false,
},
},
required: ['callId'],
additionalProperties: false,
},
],
};
export const isCallHistoryInfoProps = ajvQuery.compile<CallHistoryInfo>(CallHistoryInfoSchema);
const callHistoryInfoEndpoints = API.v1.get(
'call-history.info',
{
response: {
200: ajv.compile<{
item: CallHistoryItem;
call?: IMediaCall;
}>({
additionalProperties: false,
type: 'object',
properties: {
item: {
$ref: '#/components/schemas/CallHistoryItem',
description: 'The requested call history item.',
},
call: {
type: 'object',
$ref: '#/components/schemas/IMediaCall',
description: 'The call information for the requested call history item.',
nullable: true,
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['item', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
query: isCallHistoryInfoProps,
authRequired: true,
},
async function action() {
const { historyId, callId } = this.queryParams as Record<string, never> & typeof this.queryParams;
if (!historyId && !callId) {
return API.v1.failure();
}
const item = await (historyId
? CallHistory.findOneByIdAndUid(historyId, this.userId)
: CallHistory.findOneByCallIdAndUid(callId, this.userId));
if (!item) {
return API.v1.notFound();
}
if (item.type === 'media-call' && item.callId) {
const call = await MediaCalls.findOneById(item.callId);
if (call) {
return API.v1.success({
item,
call,
});
}
}
return API.v1.success({ item });
},
);
type CallHistoryInfoEndpoints = ExtractRoutesFromAPI<typeof callHistoryInfoEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends CallHistoryListEndpoints {}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends CallHistoryInfoEndpoints {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
import type { CloudRegistrationIntentData, CloudConfirmationPollData, CloudRegistrationStatus } from '@rocket.chat/core-typings';
import {
isCloudConfirmationPollProps,
isCloudConnectWorkspaceProps,
isCloudCreateRegistrationIntentProps,
isCloudManualRegisterProps,
ajv,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
import { CloudWorkspaceRegistrationError } from '../../../lib/errors/CloudWorkspaceRegistrationError';
import { buildWorkspaceRegistrationData } from '../../lib/cloud/buildRegistrationData';
import { connectWorkspace } from '../../lib/cloud/connectWorkspace';
import { getCheckoutUrl } from '../../lib/cloud/getCheckoutUrl';
import { getConfirmationPoll } from '../../lib/cloud/getConfirmationPoll';
import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError } from '../../lib/cloud/getWorkspaceAccessToken';
import { registerPreIntentWorkspaceWizard } from '../../lib/cloud/registerPreIntentWorkspaceWizard';
import { removeLicense } from '../../lib/cloud/removeLicense';
import { retrieveRegistrationStatus } from '../../lib/cloud/retrieveRegistrationStatus';
import { saveRegistrationData, saveRegistrationDataManual } from '../../lib/cloud/saveRegistrationData';
import { startRegisterWorkspaceSetupWizard } from '../../lib/cloud/startRegisterWorkspaceSetupWizard';
import { syncWorkspace } from '../../lib/cloud/syncWorkspace';
import { SystemLogger } from '../../lib/logger/system';
import { API } from '../api';
const successResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
const manualRegisterResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
const createRegistrationIntentResponseSchema = ajv.compile<{ intentData: CloudRegistrationIntentData }>({
type: 'object',
properties: {
intentData: { $ref: '#/components/schemas/CloudRegistrationIntentData' },
success: { type: 'boolean', enum: [true] },
},
required: ['intentData', 'success'],
additionalProperties: false,
});
const registerPreIntentResponseSchema = ajv.compile<{ offline: boolean }>({
type: 'object',
properties: {
offline: { type: 'boolean' },
success: { type: 'boolean', enum: [true] },
},
required: ['offline', 'success'],
additionalProperties: false,
});
const confirmationPollResponseSchema = ajv.compile<{ pollData: CloudConfirmationPollData }>({
type: 'object',
properties: {
pollData: {
oneOf: [
{ $ref: '#/components/schemas/CloudConfirmationPollDataPending' },
{ $ref: '#/components/schemas/CloudConfirmationPollDataSuccess' },
],
},
success: { type: 'boolean', enum: [true] },
},
required: ['pollData', 'success'],
additionalProperties: false,
});
const registrationStatusResponseSchema = ajv.compile<{ registrationStatus: CloudRegistrationStatus }>({
type: 'object',
properties: {
registrationStatus: { $ref: '#/components/schemas/CloudRegistrationStatus' },
success: { type: 'boolean', enum: [true] },
},
required: ['registrationStatus', 'success'],
additionalProperties: false,
});
const workspaceRegisterDataResponseSchema = ajv.compile<{ registerData: string }>({
type: 'object',
properties: {
registerData: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['registerData', 'success'],
additionalProperties: false,
});
const checkoutUrlResponseSchema = ajv.compile<{ url: string }>({
type: 'object',
properties: {
url: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['url', 'success'],
additionalProperties: false,
});
API.v1.post(
'cloud.manualRegister',
{
authRequired: true,
permissionsRequired: ['register-on-cloud'],
body: isCloudManualRegisterProps,
response: {
200: manualRegisterResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const registrationInfo = await retrieveRegistrationStatus();
if (registrationInfo.workspaceRegistered) {
return API.v1.failure('Workspace is already registered');
}
const settingsData = JSON.parse(Buffer.from(this.bodyParams.cloudBlob, 'base64').toString());
await saveRegistrationDataManual(settingsData);
return API.v1.success();
},
);
API.v1.post(
'cloud.createRegistrationIntent',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
body: isCloudCreateRegistrationIntentProps,
response: {
200: createRegistrationIntentResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const intentData = await startRegisterWorkspaceSetupWizard(this.bodyParams.resend, this.bodyParams.email);
if (intentData) {
return API.v1.success({ intentData });
}
return API.v1.failure('Invalid query');
},
);
API.v1.post(
'cloud.registerPreIntent',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
response: {
200: registerPreIntentResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
return API.v1.success({ offline: !(await registerPreIntentWorkspaceWizard()) });
},
);
API.v1.get(
'cloud.confirmationPoll',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
query: isCloudConfirmationPollProps,
response: {
200: confirmationPollResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { deviceCode } = this.queryParams;
const pollData = await getConfirmationPoll(deviceCode);
if (pollData) {
if ('successful' in pollData && pollData.successful) {
await saveRegistrationData(pollData.payload);
}
return API.v1.success({ pollData });
}
return API.v1.failure('Invalid query');
},
);
API.v1.get(
'cloud.registrationStatus',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
response: {
200: registrationStatusResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const registrationStatus = await retrieveRegistrationStatus();
return API.v1.success({ registrationStatus });
},
);
API.v1.get(
'cloud.workspaceRegisterData',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
response: {
200: workspaceRegisterDataResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const registrationData = await buildWorkspaceRegistrationData(undefined);
return API.v1.success({ registerData: Buffer.from(JSON.stringify(registrationData)).toString('base64') });
},
);
API.v1.post(
'cloud.syncWorkspace',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 },
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
try {
await syncWorkspace();
return API.v1.success();
} catch (error) {
return API.v1.failure('Error during workspace sync');
}
},
);
API.v1.post(
'cloud.removeLicense',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 60000 },
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
try {
await removeLicense();
return API.v1.success();
} catch (error) {
switch (true) {
case error instanceof CloudWorkspaceRegistrationError:
case error instanceof CloudWorkspaceAccessTokenEmptyError:
case error instanceof CloudWorkspaceAccessTokenError: {
SystemLogger.info({
msg: 'Manual license removal failed',
endpoint: 'cloud.removeLicense',
error,
});
break;
}
default: {
SystemLogger.error({
msg: 'Manual license removal failed',
endpoint: 'cloud.removeLicense',
error,
});
break;
}
}
return API.v1.failure('License removal failed');
}
},
);
/**
* Declaring endpoint here because we don't want this available to the sdk client
*/
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/cloud.checkoutUrl': {
GET: () => { url: string };
};
}
}
API.v1.post(
'cloud.connectWorkspace',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
body: isCloudConnectWorkspaceProps,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const connected = await connectWorkspace(this.bodyParams.token);
if (!connected) {
return API.v1.failure('Failed to connect the workspace with Rocket.Chat Cloud');
}
return API.v1.success();
},
);
API.v1.get(
'cloud.checkoutUrl',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
response: {
200: checkoutUrlResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const checkoutUrl = await getCheckoutUrl();
if (!checkoutUrl.url) {
return API.v1.failure();
}
return API.v1.success({ url: checkoutUrl.url });
},
);

View File

@@ -0,0 +1,521 @@
import { Apps } from '@rocket.chat/apps';
import type { SlashCommand, SlashCommandPreviewItem } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import {
ajv,
ajvQuery,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import objectPath from 'object-path';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { slashCommands } from '../../lib/utils/slashCommand';
import { executeSlashCommandPreview } from '../../meteor-methods/messages/executeSlashCommandPreview';
import { getSlashCommandPreviews } from '../../meteor-methods/messages/getSlashCommandPreviews';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { getPaginationItems } from '../lib/getPaginationItems';
type CommandsGetParams = { command: string };
const CommandsGetParamsSchema = {
type: 'object',
properties: {
command: { type: 'string' },
},
required: ['command'],
additionalProperties: false,
};
const isCommandsGetParams = ajvQuery.compile<CommandsGetParams>(CommandsGetParamsSchema);
const commandsListResponseSchema = ajv.compile<{
commands: SlashCommand[];
appsLoaded: boolean;
offset: number;
count: number;
total: number;
}>({
type: 'object',
properties: {
commands: {
type: 'array',
items: { $ref: '#/components/schemas/SlashCommand' },
},
appsLoaded: { type: 'boolean' },
offset: { type: 'number' },
count: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['commands', 'appsLoaded', 'offset', 'count', 'total', 'success'],
additionalProperties: false,
});
type CommandsListParams = {
offset?: number;
count?: number;
sort?: string;
query?: string;
fields?: string;
};
const isCommandsListParams = ajvQuery.compile<CommandsListParams>({
type: 'object',
properties: {
offset: { type: 'number', nullable: true },
count: { type: 'number', nullable: true },
sort: { type: 'string', nullable: true },
query: { type: 'string', nullable: true },
fields: { type: 'string', nullable: true },
},
additionalProperties: false,
});
const commandsEndpoints = API.v1
.get(
'commands.get',
{
authRequired: true,
query: isCommandsGetParams,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<{
command: Pick<SlashCommand, 'clientOnly' | 'command' | 'description' | 'params' | 'providesPreview'>;
success: true;
}>({
type: 'object',
properties: {
command: {
type: 'object',
properties: {
clientOnly: { type: 'boolean' },
command: { type: 'string' },
description: { type: 'string' },
params: { type: 'string' },
providesPreview: { type: 'boolean' },
},
required: ['command', 'providesPreview'],
additionalProperties: false,
},
success: {
type: 'boolean',
enum: [true],
},
},
required: ['command', 'success'],
additionalProperties: false,
}),
},
},
async function action() {
const params = this.queryParams;
const cmd = slashCommands.commands[params.command.toLowerCase()];
if (!cmd) {
return API.v1.failure(`There is no command in the system by the name of: ${params.command}`);
}
return API.v1.success({
command: {
command: cmd.command,
description: cmd.description,
params: cmd.params,
clientOnly: cmd.clientOnly,
providesPreview: cmd.providesPreview,
},
});
},
)
.get(
'commands.list',
{
authRequired: true,
query: isCommandsListParams,
response: {
200: commandsListResponseSchema,
202: commandsListResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
if (!Apps.self?.isLoaded()) {
return API.v1.success(
{
commands: [],
appsLoaded: false as const,
offset: 0,
count: 0,
total: 0,
success: true as const,
},
202,
);
}
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, query } = await this.parseJsonQuery();
let commands = Object.values(slashCommands.commands);
if (query?.command) {
commands = commands.filter((command) => command.command === query.command);
}
const totalCount = commands.length;
return API.v1.success({
commands: processQueryOptionsOnResult(commands, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
}),
appsLoaded: true as const,
offset,
count: commands.length,
total: totalCount,
});
},
);
/* @deprecated */
const processQueryOptionsOnResult = <T extends { _id?: string } & Record<string, any>, F extends keyof T>(
result: T[],
options: {
fields?: {
[key in F]?: 1 | 0;
};
sort?: {
[key: string]: 1 | -1;
};
limit?: number;
skip?: number;
} = {},
): Pick<T, F>[] => {
if (result === undefined || result === null) {
return [];
}
if (Array.isArray(result)) {
if (options.sort) {
result = result.sort((a, b) => {
let r = 0;
for (const field in options.sort) {
if (options.sort.hasOwnProperty(field)) {
const direction = options.sort[field];
let valueA;
let valueB;
if (field.indexOf('.') > -1) {
valueA = objectPath.get(a, field);
valueB = objectPath.get(b, field);
} else {
valueA = a[field];
valueB = b[field];
}
if (valueA > valueB) {
r = direction;
break;
}
if (valueA < valueB) {
r = -direction;
break;
}
}
}
return r;
});
}
if (typeof options.skip === 'number') {
result.splice(0, options.skip);
}
if (typeof options.limit === 'number' && options.limit !== 0) {
result.splice(options.limit);
}
}
const fieldsToRemove: F[] = [];
const fieldsToGet: F[] = [];
if (options.fields) {
for (const field in Object.keys(options.fields)) {
if (options.fields.hasOwnProperty(field as F)) {
if (options.fields[field as F] === 0) {
fieldsToRemove.push(field as F);
} else if (options.fields[field as F] === 1) {
fieldsToGet.push(field as F);
}
}
}
}
if (fieldsToGet.length > 0 && fieldsToGet.indexOf('_id' as F) === -1) {
fieldsToGet.push('_id' as F);
}
const pickFields = <F extends keyof T>(obj: T, fields: F[]): Pick<T, F> => {
const picked: Partial<T> = {};
fields.forEach((field: F) => {
if (String(field).indexOf('.') !== -1) {
objectPath.set(picked, String(field), objectPath.get(obj, String(field)));
} else {
picked[field] = obj[field];
}
});
return picked as Pick<T, F>;
};
if (fieldsToRemove.length > 0 && fieldsToGet.length > 0) {
console.warn("Can't mix remove and get fields");
fieldsToRemove.splice(0, fieldsToRemove.length);
}
if (fieldsToRemove.length > 0 || fieldsToGet.length > 0) {
return result.map((record) => {
if (fieldsToRemove.length > 0) {
return Object.fromEntries(Object.entries(record).filter(([key]) => !fieldsToRemove.includes(key as F))) as Pick<T, F>;
}
return pickFields(record, fieldsToGet);
});
}
return result;
};
const isCommandsRunProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }>({
type: 'object',
properties: {
command: { type: 'string', minLength: 1 },
params: { type: 'string', nullable: true },
roomId: { type: 'string', minLength: 1 },
tmid: { type: 'string', nullable: true },
triggerId: { type: 'string', nullable: true },
},
required: ['command', 'roomId'],
additionalProperties: false,
});
const commandsRunResponseSchema = ajv.compile<{ result: unknown }>({
type: 'object',
properties: {
result: {},
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: true,
});
const isCommandsPreviewGetProps = ajvQuery.compile<{ command: string; params?: string; roomId: string }>({
type: 'object',
properties: {
command: { type: 'string', minLength: 1 },
params: { type: 'string', nullable: true },
roomId: { type: 'string', minLength: 1 },
},
required: ['command', 'roomId'],
additionalProperties: false,
});
const commandsPreviewGetResponseSchema = ajv.compile<{ preview: Record<string, unknown> | null }>({
type: 'object',
properties: {
preview: { type: 'object', nullable: true },
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});
const isCommandsPreviewPostProps = ajv.compile<{
command: string;
params?: string;
roomId: string;
tmid?: string;
triggerId?: string;
previewItem: SlashCommandPreviewItem;
}>({
type: 'object',
properties: {
command: { type: 'string', minLength: 1 },
params: { type: 'string', nullable: true },
roomId: { type: 'string', minLength: 1 },
tmid: { type: 'string', nullable: true },
triggerId: { type: 'string', nullable: true },
previewItem: {
type: 'object',
properties: {
id: { type: 'string' },
type: { type: 'string', enum: ['image', 'video', 'audio', 'text', 'other'] },
value: { type: 'string' },
},
required: ['id', 'type', 'value'],
additionalProperties: false,
},
},
required: ['command', 'roomId', 'previewItem'],
additionalProperties: false,
});
const commandsPreviewPostResponseSchema = ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});
// Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' }
API.v1.post(
'commands.run',
{
authRequired: true,
body: isCommandsRunProps,
response: {
200: commandsRunResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const body = this.bodyParams;
const cmd = body.command.toLowerCase();
if (!slashCommands.commands[cmd]) {
return API.v1.failure('The command provided does not exist (or is disabled).');
}
if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
return API.v1.forbidden();
}
const params = body.params ? body.params : '';
if (body.tmid) {
const thread = await Messages.findOneById(body.tmid);
if (thread?.rid !== body.roomId) {
return API.v1.failure('Invalid thread.');
}
}
const message = {
_id: Random.id(),
rid: body.roomId,
msg: `/${cmd} ${params}`,
...(body.tmid && { tmid: body.tmid }),
};
const { triggerId } = body;
const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId });
return API.v1.success({ result });
},
);
// Expects these query params: command: 'giphy', params: 'mine', roomId: 'value'
API.v1.get(
'commands.preview',
{
authRequired: true,
query: isCommandsPreviewGetProps,
response: {
200: commandsPreviewGetResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const query = this.queryParams;
const cmd = query.command.toLowerCase();
if (!slashCommands.commands[cmd]) {
return API.v1.failure('The command provided does not exist (or is disabled).');
}
if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) {
return API.v1.forbidden();
}
const params = query.params ? query.params : '';
const preview = await getSlashCommandPreviews({
cmd,
params,
msg: { rid: query.roomId },
userId: this.userId,
});
return API.v1.success({ preview: preview ?? null });
},
);
// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } }
API.v1.post(
'commands.preview',
{
authRequired: true,
body: isCommandsPreviewPostProps,
response: {
200: commandsPreviewPostResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const body = this.bodyParams;
const cmd = body.command.toLowerCase();
if (!slashCommands.commands[cmd]) {
return API.v1.failure('The command provided does not exist (or is disabled).');
}
if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) {
return API.v1.forbidden();
}
const { params = '' } = body;
if (body.tmid) {
const thread = await Messages.findOneById(body.tmid);
if (thread?.rid !== body.roomId) {
return API.v1.failure('Invalid thread.');
}
}
const msg = {
rid: body.roomId,
...(body.tmid && { tmid: body.tmid }),
};
await executeSlashCommandPreview(
{
cmd,
params,
msg,
triggerId: body.triggerId,
},
body.previewItem,
this.userId,
);
return API.v1.success();
},
);
export type CommandsEndpoints = ExtractRoutesFromAPI<typeof commandsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends CommandsEndpoints {}
}

View File

@@ -0,0 +1,13 @@
import type { IInstanceStatus } from '@rocket.chat/core-typings';
declare const connection:
| {
_id: string;
address: string;
currentStatus: IInstanceStatus['currentStatus'];
instanceRecord: IInstanceStatus['instanceRecord'];
broadcastAuth: boolean;
}
| undefined;
export as namespace connection;

View File

@@ -0,0 +1,339 @@
import type { ICustomSound } from '@rocket.chat/core-typings';
import { CustomSounds } from '@rocket.chat/models';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import {
isCustomSoundsGetOneProps,
isCustomSoundsListProps,
isCustomSoundsCreateProps,
isCustomSoundsDeleteProps,
isCustomSoundsUpdateProps,
ajv,
validateBadRequestErrorResponse,
validateNotFoundErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
validateInternalErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import { getExtension, getMimeTypeFromFileName } from '../../../app/utils/lib/mimeTypes';
import { MAX_CUSTOM_SOUND_SIZE_BYTES, CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../lib/constants';
import { SystemLogger } from '../../lib/logger/system';
import { deleteCustomSound } from '../../lib/media/custom-sounds/lib/deleteCustomSound';
import { insertOrUpdateSound } from '../../lib/media/custom-sounds/lib/insertOrUpdateSound';
import { uploadCustomSound } from '../../lib/media/custom-sounds/lib/uploadCustomSound';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { getPaginationItems } from '../lib/getPaginationItems';
import { getUploadFormData } from '../lib/getUploadFormData';
const createCustomSoundsResponse = ajv.compile<{ sound: Pick<ICustomSound, '_id'>; success: boolean }>({
additionalProperties: false,
type: 'object',
properties: {
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
sound: {
type: 'object',
properties: {
_id: {
type: 'string',
description: 'The ID of the sound.',
},
},
required: ['_id'],
},
},
required: ['success', 'sound'],
});
const updateCustomSoundsResponse = ajv.compile<{ success: boolean }>({
additionalProperties: false,
type: 'object',
properties: {
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success'],
});
const deleteCustomSoundsResponse = ajv.compile<void>({
additionalProperties: false,
type: 'object',
properties: {
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success'],
});
const customSoundsEndpoints = API.v1
.get(
'custom-sounds.list',
{
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<
PaginatedResult<{
sounds: ICustomSound[];
}>
>({
additionalProperties: false,
type: 'object',
properties: {
count: {
type: 'number',
description: 'The number of sounds returned in this response.',
},
offset: {
type: 'number',
description: 'The number of sounds that were skipped in this response.',
},
total: {
type: 'number',
description: 'The total number of sounds that match the query.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
sounds: {
type: 'array',
items: {
$ref: '#/components/schemas/ICustomSound',
},
},
},
required: ['count', 'offset', 'total', 'sounds', 'success'],
}),
},
query: isCustomSoundsListProps,
authRequired: true,
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, query } = await this.parseJsonQuery();
const { name } = this.queryParams;
const filter = {
...query,
...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}),
};
const { cursor, totalCount } = CustomSounds.findPaginated(filter, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [sounds, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
sounds,
count: sounds.length,
offset,
total,
});
},
)
.get(
'custom-sounds.getOne',
{
response: {
200: ajv.compile<{ sound: ICustomSound; success: boolean }>({
additionalProperties: false,
type: 'object',
properties: {
sound: {
$ref: '#/components/schemas/ICustomSound',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['sound', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
query: isCustomSoundsGetOneProps,
authRequired: true,
},
async function action() {
const { _id } = this.queryParams;
const sound = await CustomSounds.findOneById(_id);
if (!sound) {
return API.v1.notFound('Custom Sound not found.');
}
return API.v1.success({ sound });
},
)
.post(
'custom-sounds.create',
{
response: {
200: createCustomSoundsResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['manage-sounds'],
},
async function action() {
const { fields, fileBuffer, filename } = await getUploadFormData(
{
request: this.request,
},
{
field: 'sound',
sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES,
validate: isCustomSoundsCreateProps,
},
);
const computedMimeType = getMimeTypeFromFileName(filename);
if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) {
return API.v1.failure('MIME type not allowed');
}
// We extract its extension from the name or use getExtension() as fallback.
const soundExtension =
(filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType);
try {
const _id = await insertOrUpdateSound({
name: fields.name,
extension: soundExtension,
});
await uploadCustomSound(fileBuffer, computedMimeType, { _id, name: fields.name, extension: soundExtension });
return API.v1.success({ sound: { _id } });
} catch (error) {
SystemLogger.error({ error });
return API.v1.failure(error instanceof Error ? error.message : 'Unknown error');
}
},
)
.post(
'custom-sounds.update',
{
response: {
200: updateCustomSoundsResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['manage-sounds'],
},
async function action() {
const { fields, fileBuffer, filename } = await getUploadFormData(
{
request: this.request,
},
{
field: 'sound',
fileOptional: true,
sizeLimit: MAX_CUSTOM_SOUND_SIZE_BYTES,
validate: isCustomSoundsUpdateProps,
},
);
let computedMimeType = '';
if (fileBuffer) {
computedMimeType = getMimeTypeFromFileName(filename);
if (!CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(computedMimeType)) {
return API.v1.failure('MIME type not allowed');
}
}
const soundToUpdate = await CustomSounds.findOneById<Pick<ICustomSound, '_id' | 'name' | 'extension'>>(fields._id, {
projection: { _id: 1, name: 1, extension: 1 },
});
if (!soundToUpdate) {
return API.v1.failure('Custom Sound not found.');
}
// If new file, we'll extract its extension or use getExtension() as fallback. If there's no new file, we will keep the current extension.
const nextExtension = fileBuffer
? (filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined) || getExtension(computedMimeType)
: soundToUpdate.extension;
try {
await insertOrUpdateSound({
_id: fields._id,
name: fields.name,
extension: nextExtension,
previousName: soundToUpdate.name,
previousExtension: soundToUpdate.extension,
});
if (fileBuffer) {
await uploadCustomSound(fileBuffer, computedMimeType, {
_id: fields._id,
name: fields.name,
previousExtension: soundToUpdate.extension,
extension: nextExtension,
});
}
return API.v1.success({});
} catch (error) {
SystemLogger.error({ error });
return API.v1.failure(error instanceof Error ? error.message : 'Unknown error');
}
},
)
.post(
'custom-sounds.delete',
{
response: {
200: deleteCustomSoundsResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
500: validateInternalErrorResponse,
},
authRequired: true,
body: isCustomSoundsDeleteProps,
permissionsRequired: ['manage-sounds'],
},
async function action() {
const { _id } = this.bodyParams;
try {
await deleteCustomSound(_id);
return API.v1.success();
} catch (error: unknown) {
this.logger.error({ error });
if (error instanceof Meteor.Error && error.error === 'Custom_Sound_Error_Invalid_Sound') {
return API.v1.failure(error.error);
}
return API.v1.internalError();
}
},
);
export type CustomSoundEndpoints = ExtractRoutesFromAPI<typeof customSoundsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends CustomSoundEndpoints {}
}

View File

@@ -0,0 +1,283 @@
import type { ICustomUserStatus } from '@rocket.chat/core-typings';
import { CustomUserStatus } from '@rocket.chat/models';
import {
ajv,
ajvQuery,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import { deleteCustomUserStatus } from '../../meteor-methods/users/deleteCustomUserStatus';
import { insertOrUpdateUserStatus } from '../../meteor-methods/users/insertOrUpdateUserStatus';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { getPaginationItems } from '../lib/getPaginationItems';
type CustomUserStatusListProps = PaginatedRequest<{ name?: string; _id?: string; query?: string }>;
const CustomUserStatusListSchema = {
type: 'object',
properties: {
count: {
type: 'number',
nullable: true,
},
offset: {
type: 'number',
nullable: true,
},
sort: {
type: 'string',
nullable: true,
},
name: {
type: 'string',
nullable: true,
},
_id: {
type: 'string',
nullable: true,
},
query: {
type: 'string',
nullable: true,
},
},
required: [],
additionalProperties: false,
};
const isCustomUserStatusListProps = ajvQuery.compile<CustomUserStatusListProps>(CustomUserStatusListSchema);
const customUserStatusEndpoints = API.v1.get(
'custom-user-status.list',
{
authRequired: true,
query: isCustomUserStatusListProps,
response: {
200: ajv.compile<
PaginatedResult<{
statuses: ICustomUserStatus[];
}>
>({
type: 'object',
properties: {
statuses: {
type: 'array',
items: {
$ref: '#/components/schemas/ICustomUserStatus',
},
},
count: {
type: 'number',
description: 'The number of custom user statuses returned in this response.',
},
offset: {
type: 'number',
description: 'The number of custom user statuses that were skipped in this response.',
},
total: {
type: 'number',
description: 'The total number of custom user statuses that match the query.',
},
success: {
type: 'boolean',
enum: [true],
description: 'Indicates if the request was successful.',
},
},
required: ['success', 'statuses', 'count', 'offset', 'total'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, query } = await this.parseJsonQuery();
const { name, _id } = this.queryParams;
const filter = {
...query,
...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}),
...(_id ? { _id } : {}),
};
const { cursor, totalCount } = CustomUserStatus.findPaginated(filter, {
sort: sort || { name: 1 },
skip: offset,
limit: count,
});
const [statuses, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
statuses,
count: statuses.length,
offset,
total,
});
},
);
const isCustomUserStatusCreateProps = ajv.compile<{ name: string; statusType?: string }>({
type: 'object',
properties: {
name: { type: 'string' },
statusType: { type: 'string', nullable: true },
},
required: ['name'],
additionalProperties: false,
});
const customUserStatusCreateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({
type: 'object',
properties: {
customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' },
success: { type: 'boolean', enum: [true] },
},
required: ['customUserStatus', 'success'],
additionalProperties: false,
});
API.v1.post(
'custom-user-status.create',
{
authRequired: true,
body: isCustomUserStatusCreateProps,
response: {
200: customUserStatusCreateResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const userStatusData = {
name: this.bodyParams.name,
statusType: this.bodyParams.statusType || '',
};
await insertOrUpdateUserStatus(this.userId, userStatusData);
const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name);
if (!customUserStatus) {
throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status');
}
return API.v1.success({
customUserStatus,
});
},
);
const isCustomUserStatusDeleteProps = ajv.compile<{ customUserStatusId: string }>({
type: 'object',
properties: {
customUserStatusId: { type: 'string', minLength: 1 },
},
required: ['customUserStatusId'],
additionalProperties: false,
});
const customUserStatusDeleteResponseSchema = ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});
API.v1.post(
'custom-user-status.delete',
{
authRequired: true,
body: isCustomUserStatusDeleteProps,
response: {
200: customUserStatusDeleteResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { customUserStatusId } = this.bodyParams;
await deleteCustomUserStatus(this.userId, customUserStatusId);
return API.v1.success();
},
);
const isCustomUserStatusUpdateProps = ajv.compile<{ _id: string; name: string; statusType?: string }>({
type: 'object',
properties: {
_id: { type: 'string' },
name: { type: 'string' },
statusType: { type: 'string', nullable: true },
},
required: ['_id', 'name'],
additionalProperties: false,
});
const customUserStatusUpdateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({
type: 'object',
properties: {
customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' },
success: { type: 'boolean', enum: [true] },
},
required: ['customUserStatus', 'success'],
additionalProperties: false,
});
API.v1.post(
'custom-user-status.update',
{
authRequired: true,
body: isCustomUserStatusUpdateProps,
response: {
200: customUserStatusUpdateResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const userStatusData = {
_id: this.bodyParams._id,
name: this.bodyParams.name,
statusType: this.bodyParams.statusType || '',
};
const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id);
// Ensure the message exists
if (!customUserStatusToUpdate) {
return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`);
}
await insertOrUpdateUserStatus(this.userId, userStatusData);
const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id);
if (!customUserStatus) {
throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status');
}
return API.v1.success({
customUserStatus,
});
},
);
export type CustomUserStatusEndpoints = ExtractRoutesFromAPI<typeof customUserStatusEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends CustomUserStatusEndpoints {}
}

View File

@@ -0,0 +1,533 @@
import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import {
ajv,
ajvQuery,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
ise2eSetUserPublicAndPrivateKeysParamsPOST,
} from '@rocket.chat/rest-typings';
import ExpiryMap from 'expiry-map';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { handleSuggestedGroupKey } from '../../lib/e2e/functions/handleSuggestedGroupKey';
import { provideUsersSuggestedGroupKeys } from '../../lib/e2e/functions/provideUsersSuggestedGroupKeys';
import { resetRoomKey } from '../../lib/e2e/functions/resetRoomKey';
import { getUsersOfRoomWithoutKeyMethod } from '../../meteor-methods/platform/getUsersOfRoomWithoutKey';
import { requestSubscriptionKeysMethod } from '../../meteor-methods/platform/requestSubscriptionKeys';
import { setRoomKeyIDMethod } from '../../meteor-methods/platform/setRoomKeyID';
import { setUserPublicAndPrivateKeysMethod } from '../../meteor-methods/platform/setUserPublicAndPrivateKeys';
import { updateGroupKey } from '../../meteor-methods/platform/updateGroupKey';
import { settings } from '../../settings';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
// After 10s the room lock will expire, meaning that if for some reason the process never completed
// The next reset will be available 10s after
const LockMap = new ExpiryMap<string, boolean>(10000);
type E2eSetRoomKeyIdProps = {
rid: string;
keyID: string;
};
type e2eGetUsersOfRoomWithoutKeyParamsGET = {
rid: string;
};
type e2eUpdateGroupKeyParamsPOST = {
uid: string;
rid: string;
key: string;
};
type E2EFetchUsersWaitingForGroupKeyProps = { roomIds: string[] };
type E2EProvideUsersGroupKeyProps = {
usersSuggestedGroupKeys: Record<IRoom['_id'], { _id: IUser['_id']; key: string; oldKeys: ISubscription['suggestedOldRoomKeys'] }[]>;
};
type E2EResetRoomKeyProps = {
rid: string;
e2eKey: string;
e2eKeyId: string;
};
const E2eSetRoomKeyIdSchema = {
type: 'object',
properties: {
rid: {
type: 'string',
},
keyID: {
type: 'string',
},
},
required: ['rid', 'keyID'],
additionalProperties: false,
};
const e2eGetUsersOfRoomWithoutKeyParamsGETSchema = {
type: 'object',
properties: {
rid: {
type: 'string',
},
},
additionalProperties: false,
required: ['rid'],
};
const e2eUpdateGroupKeyParamsPOSTSchema = {
type: 'object',
properties: {
uid: {
type: 'string',
},
rid: {
type: 'string',
},
key: {
type: 'string',
},
},
additionalProperties: false,
required: ['uid', 'rid', 'key'],
};
const E2EFetchUsersWaitingForGroupKeySchema = {
type: 'object',
properties: {
roomIds: {
type: 'array',
items: {
type: 'string',
},
},
},
required: ['roomIds'],
additionalProperties: false,
};
const E2EProvideUsersGroupKeySchema = {
type: 'object',
properties: {
usersSuggestedGroupKeys: {
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
key: { type: 'string' },
oldKeys: {
type: 'array',
items: {
type: 'object',
properties: { e2eKeyId: { type: 'string' }, ts: { type: 'string' }, E2EKey: { type: 'string' } },
},
},
},
required: ['_id', 'key'],
additionalProperties: false,
},
},
},
},
required: ['usersSuggestedGroupKeys'],
additionalProperties: false,
};
const E2EResetRoomKeySchema = {
type: 'object',
properties: {
rid: {
type: 'string',
},
e2eKey: {
type: 'string',
},
e2eKeyId: {
type: 'string',
},
},
required: ['rid', 'e2eKey', 'e2eKeyId'],
additionalProperties: false,
};
const isE2eSetRoomKeyIdProps = ajv.compile<E2eSetRoomKeyIdProps>(E2eSetRoomKeyIdSchema);
const ise2eGetUsersOfRoomWithoutKeyParamsGET = ajv.compile<e2eGetUsersOfRoomWithoutKeyParamsGET>(
e2eGetUsersOfRoomWithoutKeyParamsGETSchema,
);
const ise2eUpdateGroupKeyParamsPOST = ajv.compile<e2eUpdateGroupKeyParamsPOST>(e2eUpdateGroupKeyParamsPOSTSchema);
const isE2EFetchUsersWaitingForGroupKeyProps = ajvQuery.compile<E2EFetchUsersWaitingForGroupKeyProps>(
E2EFetchUsersWaitingForGroupKeySchema,
);
const isE2EProvideUsersGroupKeyProps = ajv.compile<E2EProvideUsersGroupKeyProps>(E2EProvideUsersGroupKeySchema);
const isE2EResetRoomKeyProps = ajv.compile<E2EResetRoomKeyProps>(E2EResetRoomKeySchema);
const e2eEndpoints = API.v1
.post(
'e2e.setRoomKeyID',
{
authRequired: true,
body: isE2eSetRoomKeyIdProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
},
},
async function action() {
const { rid, keyID } = this.bodyParams;
await setRoomKeyIDMethod(this.userId, rid, keyID);
return API.v1.success();
},
)
.post(
'e2e.requestSubscriptionKeys',
{
authRequired: true,
response: {
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
},
},
async function action() {
await requestSubscriptionKeysMethod(this.userId);
return API.v1.success();
},
)
.get(
'e2e.fetchMyKeys',
{
authRequired: true,
query: undefined,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<{ public_key?: string; private_key?: string }>({
type: 'object',
properties: {
public_key: { type: 'string' },
private_key: { type: 'string' },
},
}),
},
},
async function action() {
const result = await Users.fetchKeysByUserId(this.userId);
return API.v1.success(result as { public_key?: string; private_key?: string });
},
)
.get(
'e2e.getUsersOfRoomWithoutKey',
{
authRequired: true,
query: ise2eGetUsersOfRoomWithoutKeyParamsGET,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<{
users: Pick<IUser, '_id' | 'e2e'>[];
}>({
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
e2e: {
type: 'object',
properties: {
private_key: { type: 'string' },
public_key: { type: 'string' },
},
},
},
required: ['_id'],
},
},
},
required: ['users'],
}),
},
},
async function action() {
const { rid } = this.queryParams;
const result = await getUsersOfRoomWithoutKeyMethod(this.userId, rid);
return API.v1.success(result);
},
)
.post(
'e2e.rejectSuggestedGroupKey',
{
authRequired: true,
body: ise2eGetUsersOfRoomWithoutKeyParamsGET,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
},
},
async function action() {
const { rid } = this.bodyParams;
await handleSuggestedGroupKey('reject', rid, this.userId, 'e2e.rejectSuggestedGroupKey');
return API.v1.success();
},
)
.post(
'e2e.acceptSuggestedGroupKey',
{
authRequired: true,
body: ise2eGetUsersOfRoomWithoutKeyParamsGET,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
},
},
async function action() {
const { rid } = this.bodyParams;
await handleSuggestedGroupKey('accept', rid, this.userId, 'e2e.acceptSuggestedGroupKey');
return API.v1.success();
},
)
.get(
'e2e.fetchUsersWaitingForGroupKey',
{
authRequired: true,
query: isE2EFetchUsersWaitingForGroupKeyProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<{
usersWaitingForE2EKeys: Record<IRoom['_id'], { _id: IUser['_id']; public_key: string }[]>;
}>({
type: 'object',
properties: {
usersWaitingForE2EKeys: {
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
public_key: { type: 'string' },
},
required: ['_id', 'public_key'],
},
},
},
},
required: ['usersWaitingForE2EKeys'],
}),
},
},
async function action() {
if (!settings.get('E2E_Enable')) {
return API.v1.success({ usersWaitingForE2EKeys: {} });
}
const { roomIds = [] } = this.queryParams;
const usersWaitingForE2EKeys = (await Subscriptions.findUsersWithPublicE2EKeyByRids(roomIds, this.userId).toArray()).reduce<
Record<string, { _id: string; public_key: string }[]>
>((acc, { rid, users }) => ({ [rid]: users, ...acc }), {});
return API.v1.success({
usersWaitingForE2EKeys,
});
},
)
.post(
'e2e.updateGroupKey',
{
authRequired: true,
body: ise2eUpdateGroupKeyParamsPOST,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
},
},
async function action() {
const { uid, rid, key } = this.bodyParams;
await updateGroupKey(rid, uid, key, this.userId);
return API.v1.success();
},
)
.post(
'e2e.provideUsersSuggestedGroupKeys',
{
authRequired: true,
body: isE2EProvideUsersGroupKeyProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
},
},
async function action() {
if (!settings.get('E2E_Enable')) {
return API.v1.success();
}
await provideUsersSuggestedGroupKeys(this.userId, this.bodyParams.usersSuggestedGroupKeys);
return API.v1.success();
},
)
// This should have permissions
.post(
'e2e.resetRoomKey',
{
authRequired: true,
body: isE2EResetRoomKeyProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<void>({
type: 'object',
}),
},
},
async function action() {
const { rid, e2eKey, e2eKeyId } = this.bodyParams;
if (!(await hasPermissionAsync(this.user, 'toggle-room-e2e-encryption', rid))) {
return API.v1.forbidden('error-not-allowed');
}
if (LockMap.has(rid)) {
throw new Error('error-e2e-key-reset-in-progress');
}
LockMap.set(rid, true);
if (!(await canAccessRoomIdAsync(rid, this.userId))) {
throw new Error('error-not-allowed');
}
try {
await resetRoomKey(rid, this.userId, e2eKey, e2eKeyId);
return API.v1.success();
} catch (e) {
console.error(e);
return API.v1.failure('error-e2e-key-reset-failed');
} finally {
LockMap.delete(rid);
}
},
)
.post(
'e2e.setUserPublicAndPrivateKeys',
{
authRequired: true,
body: ise2eSetUserPublicAndPrivateKeysParamsPOST,
response: {
200: ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
}),
401: validateUnauthorizedErrorResponse,
400: validateBadRequestErrorResponse,
},
},
async function action() {
const { public_key, private_key, force } = this.bodyParams;
await setUserPublicAndPrivateKeysMethod(this.userId, {
public_key,
private_key,
force,
});
return API.v1.success();
},
);
/**
* @openapi
* /api/v1/e2e.setUserPublicAndPrivateKeys:
* post:
* description: Sets the end-to-end encryption keys for the authenticated user
* security:
* - autenticated: {}
* requestBody:
* description: A tuple containing the public and the private keys
* content:
* application/json:
* schema:
* type: object
* properties:
* public_key:
* type: string
* private_key:
* type: string
* force:
* type: boolean
* responses:
* 200:
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiSuccessV1'
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
type E2eEndpoints = ExtractRoutesFromAPI<typeof e2eEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends E2eEndpoints {}
}

View File

@@ -0,0 +1,258 @@
import type { IEmailInbox } from '@rocket.chat/core-typings';
import { EmailInbox, Users } from '@rocket.chat/models';
import {
ajv,
isEmailInboxList,
isEmailInbox,
isEmailInboxSearch,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateNotFoundErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { sendTestEmailToInbox } from '../../features/EmailInbox/EmailInbox_Outgoing';
import { API } from '../api';
import { findEmailInboxes, insertOneEmailInbox, removeEmailInbox, updateEmailInbox } from '../lib/emailInbox';
import { getPaginationItems } from '../lib/getPaginationItems';
const paginatedEmailInboxesResponseSchema = ajv.compile<{ emailInboxes: IEmailInbox[]; total: number; count: number; offset: number }>({
type: 'object',
properties: {
emailInboxes: { type: 'array', items: { $ref: '#/components/schemas/IEmailInbox' } },
total: { type: 'number' },
count: { type: 'number' },
offset: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['emailInboxes', 'total', 'count', 'offset', 'success'],
additionalProperties: false,
});
API.v1.get(
'email-inbox.list',
{
authRequired: true,
permissionsRequired: ['manage-email-inbox'],
query: isEmailInboxList,
response: {
200: paginatedEmailInboxesResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, query } = await this.parseJsonQuery();
const emailInboxes = await findEmailInboxes({ query, pagination: { offset, count, sort } });
return API.v1.success(emailInboxes);
},
);
API.v1.post(
'email-inbox',
{
authRequired: true,
permissionsRequired: ['manage-email-inbox'],
body: isEmailInbox,
response: {
200: ajv.compile<{ _id: string }>({
type: 'object',
properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
required: ['_id', 'success'],
additionalProperties: false,
}),
400: ajv.compile({
type: 'object',
properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } },
required: ['success', 'error'],
additionalProperties: false,
}),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const body = this.bodyParams;
const maxRetries =
'maxRetries' in body.imap && typeof (body.imap as { maxRetries?: number }).maxRetries === 'number'
? (body.imap as { maxRetries: number }).maxRetries
: 5;
const emailInboxParams = {
...body,
imap: {
...body.imap,
maxRetries,
},
};
let _id: string;
if (!emailInboxParams?._id) {
const { insertedId } = await insertOneEmailInbox(this.userId, emailInboxParams);
if (!insertedId) {
return API.v1.failure('Failed to create email inbox');
}
_id = insertedId;
} else {
const emailInbox = await updateEmailInbox({ ...emailInboxParams, _id: emailInboxParams._id });
if (!emailInbox?._id) {
return API.v1.failure('Failed to update email inbox');
}
_id = emailInbox._id;
}
return API.v1.success({ _id });
},
);
API.v1.get(
'email-inbox/:_id',
{
authRequired: true,
permissionsRequired: ['manage-email-inbox'],
response: {
200: ajv.compile<IEmailInbox | null>({
oneOf: [
{
allOf: [
{ $ref: '#/components/schemas/IEmailInbox' },
{
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
},
],
},
{ type: 'null' },
],
}),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
if (!_id) {
throw new Error('error-invalid-param');
}
const emailInbox = await EmailInbox.findOneById(_id);
if (!emailInbox) {
return API.v1.notFound();
}
return API.v1.success(emailInbox);
},
);
API.v1.delete(
'email-inbox/:_id',
{
authRequired: true,
permissionsRequired: ['manage-email-inbox'],
response: {
200: ajv.compile<{ _id: string }>({
type: 'object',
properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
required: ['_id', 'success'],
additionalProperties: false,
}),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
if (!_id) {
throw new Error('error-invalid-param');
}
const { deletedCount } = await removeEmailInbox(_id);
if (!deletedCount) {
return API.v1.notFound();
}
return API.v1.success({ _id });
},
);
API.v1.get(
'email-inbox.search',
{
authRequired: true,
permissionsRequired: ['manage-email-inbox'],
query: isEmailInboxSearch,
response: {
200: ajv.compile<{ emailInbox: IEmailInbox | null }>({
type: 'object',
properties: {
emailInbox: { oneOf: [{ $ref: '#/components/schemas/IEmailInbox' }, { type: 'null' }] },
success: { type: 'boolean', enum: [true] },
},
required: ['emailInbox', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { email } = this.queryParams;
const emailInbox = await EmailInbox.findByEmail(email);
return API.v1.success({ emailInbox });
},
);
API.v1.post(
'email-inbox.send-test/:_id',
{
authRequired: true,
permissionsRequired: ['manage-email-inbox'],
response: {
200: ajv.compile<{ _id: string }>({
type: 'object',
properties: { _id: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
required: ['_id', 'success'],
additionalProperties: false,
}),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
async function action() {
const { _id } = this.urlParams;
if (!_id) {
throw new Error('error-invalid-param');
}
const emailInbox = await EmailInbox.findOneById(_id);
if (!emailInbox) {
return API.v1.notFound();
}
const user = await Users.findOneById(this.userId);
if (!user) {
return API.v1.notFound();
}
await sendTestEmailToInbox(emailInbox, user);
return API.v1.success({ _id });
},
);

View File

@@ -0,0 +1,318 @@
import { Media } from '@rocket.chat/core-services';
import type { IEmojiCustom, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import { EmojiCustom } from '@rocket.chat/models';
import { ajv, isEmojiCustomList, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import type { WithId } from 'mongodb';
import type { EmojiData } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji';
import { insertOrUpdateEmoji } from '../../lib/media/emoji-custom/lib/insertOrUpdateEmoji';
import { uploadEmojiCustomWithBuffer } from '../../lib/media/emoji-custom/lib/uploadEmojiCustom';
import { deleteEmojiCustom } from '../../meteor-methods/media/deleteEmojiCustom';
import { settings } from '../../settings';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { findEmojisCustom } from '../lib/emoji-custom';
import { getPaginationItems } from '../lib/getPaginationItems';
import { getUploadFormData } from '../lib/getUploadFormData';
const emojiDeleteBodySchema = ajv.compile({
type: 'object',
properties: { emojiId: { type: 'string', minLength: 1 } },
required: ['emojiId'],
additionalProperties: false,
});
const emojiCustomAllResponseSchema = ajv.compile<{ emojis: IEmojiCustom[]; count: number; offset: number; total: number }>({
type: 'object',
properties: {
emojis: {
type: 'array',
items: { $ref: '#/components/schemas/IEmojiCustom' },
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['emojis', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
const emojiCustomDeleteResponseSchema = ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});
function validateDateParam(paramName: string, paramValue: string | undefined): Date | undefined {
if (!paramValue) {
return undefined;
}
const date = new Date(paramValue);
if (isNaN(date.getTime())) {
throw new Meteor.Error('error-roomId-param-invalid', `The "${paramName}" query parameter must be a valid date.`);
}
return date;
}
const emojiCustomListResponseSchema = ajv.compile<{
emojis: { update: IEmojiCustom[]; remove: WithId<RocketChatRecordDeleted<IEmojiCustom>>[] };
}>({
type: 'object',
properties: {
emojis: {
type: 'object',
properties: {
update: { type: 'array', items: { type: 'object' } },
remove: { type: 'array', items: { type: 'object' } },
},
required: ['update', 'remove'],
},
success: { type: 'boolean', enum: [true] },
},
required: ['emojis', 'success'],
additionalProperties: false,
});
const emojiCustomCreateEndpoints = API.v1
.get(
'emoji-custom.list',
{
authRequired: true,
query: isEmojiCustomList,
response: {
200: emojiCustomListResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { query } = await this.parseJsonQuery();
const { updatedSince, _updatedAt, _id } = this.queryParams;
const updatedSinceDate = validateDateParam('updatedSince', updatedSince);
const _updatedAtDate = validateDateParam('_updatedAt', _updatedAt);
if (updatedSinceDate) {
const [update, remove] = await Promise.all([
EmojiCustom.find({
...query,
...(_id ? { _id } : {}),
...(_updatedAtDate ? { _updatedAt: { $gt: _updatedAtDate } } : {}),
_updatedAt: { $gt: updatedSinceDate },
}).toArray(),
EmojiCustom.trashFindDeletedAfter(updatedSinceDate).toArray(),
]);
return API.v1.success({
emojis: {
update,
remove,
},
});
}
return API.v1.success({
emojis: {
update: await EmojiCustom.find({
...query,
...(_id ? { _id } : {}),
...(_updatedAtDate ? { _updatedAt: { $gt: _updatedAtDate } } : {}),
}).toArray(),
remove: [],
},
});
},
)
.get(
'emoji-custom.all',
{
authRequired: true,
response: {
200: emojiCustomAllResponseSchema,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, query } = await this.parseJsonQuery();
const { name } = this.queryParams;
return API.v1.success(
await findEmojisCustom({
query: name
? {
name: {
$regex: escapeRegExp(name),
$options: 'i',
},
}
: query,
pagination: {
offset,
count,
sort,
},
}),
);
},
)
.post(
'emoji-custom.create',
{
authRequired: true,
response: {
200: ajv.compile<void>({
type: 'object',
properties: {
success: {
type: 'boolean',
enum: [true],
},
},
required: ['success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const emoji = await getUploadFormData(
{
request: this.request,
},
{
field: 'emoji',
sizeLimit: settings.get('FileUpload_MaxFileSize'),
},
);
const { fields, fileBuffer, mimetype } = emoji;
const isUploadable = await Media.isImage(fileBuffer);
if (!isUploadable) {
throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image");
}
const [, extension] = mimetype.split('/');
fields.extension = extension;
const emojiData = await insertOrUpdateEmoji(this.userId, {
...fields,
newFile: true,
aliases: fields.aliases || '',
name: fields.name,
extension: fields.extension,
});
await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData);
return API.v1.success();
},
)
.post(
'emoji-custom.update',
{
authRequired: true,
response: {
200: ajv.compile({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const emoji = await getUploadFormData(
{
request: this.request,
},
{ field: 'emoji', sizeLimit: settings.get('FileUpload_MaxFileSize'), fileOptional: true },
);
const { fields, fileBuffer, mimetype } = emoji;
if (!fields._id) {
throw new Meteor.Error('The required "_id" query param is missing.');
}
const emojiToUpdate = await EmojiCustom.findOneById<Pick<IEmojiCustom, 'name' | 'extension'>>(fields._id, {
projection: { name: 1, extension: 1 },
});
if (!emojiToUpdate) {
throw new Meteor.Error('Emoji not found.');
}
const emojiData: EmojiData = {
previousName: emojiToUpdate.name,
previousExtension: emojiToUpdate.extension,
aliases: fields.aliases || '',
name: fields.name,
extension: fields.extension,
_id: fields._id,
newFile: false,
};
const isNewFile = fileBuffer?.length && !!mimetype;
if (isNewFile) {
emojiData.newFile = isNewFile;
const isUploadable = await Media.isImage(fileBuffer);
if (!isUploadable) {
throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image");
}
const [, extension] = mimetype.split('/');
emojiData.extension = extension;
} else {
emojiData.extension = emojiToUpdate.extension;
}
const updatedEmojiData = await insertOrUpdateEmoji(this.userId, emojiData);
if (isNewFile) {
await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, updatedEmojiData);
}
return API.v1.success();
},
)
.post(
'emoji-custom.delete',
{
authRequired: true,
body: emojiDeleteBodySchema,
response: {
200: emojiCustomDeleteResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { emojiId } = this.bodyParams;
if (!emojiId) {
return API.v1.failure('The "emojiId" params is required!');
}
await deleteEmojiCustom(this.userId, emojiId);
return API.v1.success();
},
);
type EmojiCustomCreateEndpoints = ExtractRoutesFromAPI<typeof emojiCustomCreateEndpoints>;
export type EmojiCustomEndpoints = EmojiCustomCreateEndpoints;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends EmojiCustomCreateEndpoints {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,393 @@
import { Import } from '@rocket.chat/core-services';
import type { IImport } from '@rocket.chat/core-typings';
import { Imports } from '@rocket.chat/models';
import {
ajv,
isUploadImportFileParamsPOST,
isDownloadPublicImportFileParamsPOST,
isStartImportParamsPOST,
isGetImportFileDataParamsGET,
isGetImportProgressParamsGET,
isGetLatestImportOperationsParamsGET,
isDownloadPendingFilesParamsPOST,
isDownloadPendingAvatarsParamsPOST,
isGetCurrentImportOperationParamsGET,
isImportersListParamsGET,
isImportAddUsersParamsPOST,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { Importers } from '../../lib/import';
import { PendingAvatarImporter } from '../../lib/import/pending-avatars/PendingAvatarImporter';
import { PendingFileImporter } from '../../lib/import/pending-files/PendingFileImporter';
import { executeDownloadPublicImportFile } from '../../meteor-methods/import/downloadPublicImportFile';
import { executeGetImportFileData } from '../../meteor-methods/import/getImportFileData';
import { executeGetImportProgress } from '../../meteor-methods/import/getImportProgress';
import { executeGetLatestImportOperations } from '../../meteor-methods/import/getLatestImportOperations';
import { executeStartImport } from '../../meteor-methods/import/startImport';
import { executeUploadImportFile } from '../../meteor-methods/import/uploadImportFile';
import { API } from '../api';
const successResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
const uploadImportFileResponseSchema = ajv.compile<void>({
type: 'object',
additionalProperties: true,
});
const countResponseSchema = ajv.compile<{ count: number }>({
type: 'object',
properties: {
count: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['count', 'success'],
additionalProperties: false,
});
const operationResponseSchema = ajv.compile<{ operation: IImport | undefined }>({
type: 'object',
properties: {
operation: { $ref: '#/components/schemas/IImport' },
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});
const importersListResponseSchema = ajv.compile<Array<{ key: string; name: string }>>({
type: 'array',
items: { type: 'object', properties: { key: { type: 'string' }, name: { type: 'string' } } },
});
API.v1.post(
'uploadImportFile',
{
authRequired: true,
permissionsRequired: ['run-import'],
body: isUploadImportFileParamsPOST,
response: {
200: uploadImportFileResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { binaryContent, contentType, fileName, importerKey } = this.bodyParams;
await executeUploadImportFile(this.userId, binaryContent, contentType, fileName, importerKey);
return API.v1.success();
},
);
API.v1.post(
'downloadPublicImportFile',
{
authRequired: true,
permissionsRequired: ['run-import'],
body: isDownloadPublicImportFileParamsPOST,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { fileUrl, importerKey } = this.bodyParams;
await executeDownloadPublicImportFile(this.userId, fileUrl, importerKey);
return API.v1.success();
},
);
API.v1.post(
'startImport',
{
authRequired: true,
permissionsRequired: ['run-import'],
body: isStartImportParamsPOST,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { input } = this.bodyParams;
await executeStartImport({ input }, this.userId);
return API.v1.success();
},
);
API.v1.get(
'getImportFileData',
{
authRequired: true,
permissionsRequired: ['run-import'],
query: isGetImportFileDataParamsGET,
response: {
200: ajv.compile<Record<string, unknown>>({ type: 'object', additionalProperties: true }),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const result = await executeGetImportFileData();
return API.v1.success(typeof result === 'object' ? result : {});
},
);
API.v1.get(
'getImportProgress',
{
authRequired: true,
permissionsRequired: ['run-import'],
query: isGetImportProgressParamsGET,
response: {
200: ajv.compile<Record<string, unknown>>({ type: 'object', additionalProperties: true }),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const result = await executeGetImportProgress();
return API.v1.success(typeof result === 'object' ? result : {});
},
);
API.v1.get(
'getLatestImportOperations',
{
authRequired: true,
permissionsRequired: ['view-import-operations'],
query: isGetLatestImportOperationsParamsGET,
response: {
200: ajv.compile<Array<IImport>>({
type: 'array',
items: {
type: 'object',
},
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const operations = await executeGetLatestImportOperations();
return API.v1.success(operations);
},
);
API.v1.post(
'downloadPendingFiles',
{
authRequired: true,
permissionsRequired: ['run-import'],
body: isDownloadPendingFilesParamsPOST,
response: {
200: countResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const importer = Importers.get('pending-files');
if (!importer) {
throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingFiles');
}
const operation = await Import.newOperation(this.userId, importer.name, importer.key);
const instance = new PendingFileImporter(importer, operation);
const count = await instance.prepareFileCount();
return API.v1.success({
count,
});
},
);
API.v1.post(
'downloadPendingAvatars',
{
authRequired: true,
permissionsRequired: ['run-import'],
body: isDownloadPendingAvatarsParamsPOST,
response: {
200: countResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const importer = Importers.get('pending-avatars');
if (!importer) {
throw new Meteor.Error('error-importer-not-defined', 'The Pending File Importer was not found.', 'downloadPendingAvatars');
}
const operation = await Import.newOperation(this.userId, importer.name, importer.key);
const instance = new PendingAvatarImporter(importer, operation);
const count = await instance.prepareFileCount();
return API.v1.success({
count,
});
},
);
API.v1.get(
'getCurrentImportOperation',
{
authRequired: true,
permissionsRequired: ['run-import'],
query: isGetCurrentImportOperationParamsGET,
response: {
200: operationResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const operation = await Imports.findLastImport();
return API.v1.success({
operation,
});
},
);
API.v1.get(
'importers.list',
{
authRequired: true,
permissionsRequired: ['run-import'],
query: isImportersListParamsGET,
response: {
200: importersListResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const importers = Importers.getAllVisible().map(({ key, name }) => ({ key, name }));
return API.v1.success(importers);
},
);
API.v1.post(
'import.clear',
{
authRequired: true,
permissionsRequired: ['run-import'],
response: {
200: successResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
await Import.clear();
return API.v1.success();
},
);
API.v1.post(
'import.new',
{
authRequired: true,
permissionsRequired: ['run-import'],
response: {
200: operationResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const operation = await Import.newOperation(this.userId, 'api', 'api');
return API.v1.success({ operation });
},
);
API.v1.get(
'import.status',
{
authRequired: true,
permissionsRequired: ['run-import'],
response: {
200: ajv.compile<Record<string, unknown>>({ type: 'object', additionalProperties: true }),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const status = await Import.status();
return API.v1.success(typeof status === 'object' ? status : {});
},
);
API.v1.post(
'import.addUsers',
{
authRequired: true,
permissionsRequired: ['run-import'],
body: isImportAddUsersParamsPOST,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { users } = this.bodyParams;
await Import.addUsers(users);
return API.v1.success();
},
);
API.v1.post(
'import.run',
{
authRequired: true,
permissionsRequired: ['run-import'],
response: {
200: successResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
await Import.run(this.userId);
return API.v1.success();
},
);

View File

@@ -0,0 +1,94 @@
import { InstanceStatus } from '@rocket.chat/models';
import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';
import { isRunningMs } from '../../lib/isRunningMs';
import { API } from '../api';
import { getInstanceList } from '../lib/getInstanceList';
const getConnections = (() => {
if (isRunningMs()) {
return () => [];
}
return () => getInstanceList();
})();
API.v1.get(
'instances.get',
{
authRequired: true,
permissionsRequired: ['view-statistics'],
response: {
200: ajv.compile<{
instances: {
address?: string;
currentStatus: {
connected: boolean;
lastHeartbeatTime?: number;
local?: boolean;
};
instanceRecord: object;
broadcastAuth: boolean;
}[];
success: true;
}>({
type: 'object',
properties: {
instances: {
type: 'array',
items: {
type: 'object',
properties: {
address: { type: 'string' },
currentStatus: {
type: 'object',
properties: {
connected: { type: 'boolean' },
lastHeartbeatTime: { type: 'number' },
local: { type: 'boolean' },
},
required: ['connected'],
},
instanceRecord: { type: 'object' },
broadcastAuth: { type: 'boolean' },
},
required: ['currentStatus', 'instanceRecord', 'broadcastAuth'],
},
},
success: {
type: 'boolean',
enum: [true],
},
},
required: ['instances', 'success'],
additionalProperties: false,
}),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const instanceRecords = await InstanceStatus.find().toArray();
const connections = await getConnections();
const result = instanceRecords.map((instanceRecord) => {
const connection = connections.find((c) => c.id === instanceRecord._id);
return {
address: connection?.ipList[0],
currentStatus: {
connected: connection?.available || false,
lastHeartbeatTime: connection?.lastHeartbeatTime,
local: connection?.local,
},
instanceRecord,
broadcastAuth: true,
};
});
return API.v1.success({
instances: result,
});
},
);

View File

@@ -0,0 +1,412 @@
import type { IIntegration, IIntegrationHistory, INewIncomingIntegration, INewOutgoingIntegration } from '@rocket.chat/core-typings';
import { Integrations, IntegrationHistory } from '@rocket.chat/models';
import {
ajv,
isIntegrationsCreateProps,
isIntegrationsHistoryProps,
isIntegrationsRemoveProps,
isIntegrationsGetProps,
isIntegrationsUpdateProps,
isIntegrationsListProps,
isIntegrationsClearHistoryProps,
isIntegrationsReplayProps,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { Match, check } from 'meteor/check';
import type { Filter } from 'mongodb';
import { clearIntegrationHistoryMethod, replayOutgoingIntegrationMethod } from '../../lib/integrations/functions/clearIntegrationHistory';
import {
mountIntegrationHistoryQueryBasedOnPermissions,
mountIntegrationQueryBasedOnPermissions,
} from '../../lib/integrations/lib/mountQueriesBasedOnPermission';
import { addIncomingIntegration } from '../../meteor-methods/integrations/incoming/addIncomingIntegration';
import { deleteIncomingIntegration } from '../../meteor-methods/integrations/incoming/deleteIncomingIntegration';
import { updateIncomingIntegration } from '../../meteor-methods/integrations/incoming/updateIncomingIntegration';
import { addOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/addOutgoingIntegration';
import { deleteOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/deleteOutgoingIntegration';
import { updateOutgoingIntegration } from '../../meteor-methods/integrations/outgoing/updateOutgoingIntegration';
import { API } from '../api';
import { getPaginationItems } from '../lib/getPaginationItems';
import { findOneIntegration } from '../lib/integrations';
const integrationSuccessSchema = ajv.compile<{ integration: IIntegration | null }>({
type: 'object',
properties: {
integration: { oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }] },
success: { type: 'boolean', enum: [true] },
},
required: ['integration', 'success'],
additionalProperties: false,
});
API.v1.post(
'integrations.create',
{
authRequired: true,
body: isIntegrationsCreateProps,
response: {
200: integrationSuccessSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
switch (this.bodyParams.type) {
case 'webhook-outgoing':
return API.v1.success({
integration: await addOutgoingIntegration(this.userId, this.bodyParams as INewOutgoingIntegration),
});
case 'webhook-incoming':
return API.v1.success({
integration: await addIncomingIntegration(this.userId, this.bodyParams as INewIncomingIntegration),
});
default:
return API.v1.failure('Invalid integration type.');
}
},
);
API.v1.get(
'integrations.history',
{
authRequired: true,
query: isIntegrationsHistoryProps,
permissionsRequired: {
GET: { permissions: ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'], operation: 'hasAny' },
},
response: {
200: ajv.compile<{ history: IIntegrationHistory[]; offset: number; items: number; count: number; total: number }>({
type: 'object',
properties: {
history: { type: 'array', items: { $ref: '#/components/schemas/IIntegrationHistory' } },
offset: { type: 'number' },
items: { type: 'number' },
count: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['history', 'offset', 'items', 'count', 'total', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { userId, queryParams } = this;
if (!queryParams.id || queryParams.id.trim() === '') {
return API.v1.failure('Invalid integration id.');
}
const { id } = queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields: projection, query } = await this.parseJsonQuery();
const ourQuery = Object.assign(await mountIntegrationHistoryQueryBasedOnPermissions(userId, id), query);
const { cursor, totalCount } = IntegrationHistory.findPaginated(ourQuery, {
sort: sort || { _updatedAt: -1 },
skip: offset,
limit: count,
projection,
});
const [history, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
history,
offset,
items: history.length,
count: history.length,
total,
});
},
);
API.v1.get(
'integrations.list',
{
authRequired: true,
query: isIntegrationsListProps,
permissionsRequired: {
GET: {
permissions: [
'manage-outgoing-integrations',
'manage-own-outgoing-integrations',
'manage-incoming-integrations',
'manage-own-incoming-integrations',
],
operation: 'hasAny',
},
},
response: {
200: ajv.compile<{ integrations: IIntegration[]; offset: number; items: number; count: number; total: number }>({
type: 'object',
properties: {
integrations: {
type: 'array',
items: {
oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }],
},
},
offset: { type: 'number' },
items: { type: 'number' },
count: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['integrations', 'offset', 'items', 'count', 'total', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields, query } = await this.parseJsonQuery();
const { name, type } = this.queryParams;
const filter = {
...query,
...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}),
...(type ? { type } : {}),
};
const ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), filter) as Filter<IIntegration>;
const { cursor, totalCount } = Integrations.findPaginated(ourQuery, {
sort: sort || { ts: -1 },
skip: offset,
limit: count,
projection: fields,
});
const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
integrations,
offset,
items: integrations.length,
count: integrations.length,
total,
});
},
);
API.v1.post(
'integrations.remove',
{
authRequired: true,
body: isIntegrationsRemoveProps,
permissionsRequired: {
POST: {
permissions: [
'manage-outgoing-integrations',
'manage-own-outgoing-integrations',
'manage-incoming-integrations',
'manage-own-incoming-integrations',
],
operation: 'hasAny',
},
},
response: {
200: integrationSuccessSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { bodyParams } = this;
let integration: IIntegration | null = null;
switch (bodyParams.type) {
case 'webhook-outgoing':
if (!bodyParams.target_url && !bodyParams.integrationId) {
return API.v1.failure('An integrationId or target_url needs to be provided.');
}
if (bodyParams.target_url) {
integration = await Integrations.findOne({ urls: bodyParams.target_url });
} else if (bodyParams.integrationId) {
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
}
if (!integration) {
return API.v1.failure('No integration found.');
}
const outgoingId = integration._id;
await deleteOutgoingIntegration(outgoingId, this.userId);
return API.v1.success({
integration,
});
case 'webhook-incoming':
check(
bodyParams,
Match.ObjectIncluding({
integrationId: String,
}),
);
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
if (!integration) {
return API.v1.failure('No integration found.');
}
const incomingId = integration._id;
await deleteIncomingIntegration(incomingId, this.userId);
return API.v1.success({
integration,
});
default:
return API.v1.failure('Invalid integration type.');
}
},
);
API.v1.get(
'integrations.get',
{
authRequired: true,
query: isIntegrationsGetProps,
response: {
200: ajv.compile<{ integration: IIntegration | null }>({
type: 'object',
properties: {
integration: {
oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }],
},
success: { type: 'boolean', enum: [true] },
},
required: ['integration', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { integrationId, createdBy } = this.queryParams;
if (!integrationId) {
return API.v1.failure('The query parameter "integrationId" is required.');
}
return API.v1.success({
integration: await findOneIntegration({
userId: this.userId,
integrationId,
createdBy,
}),
});
},
);
API.v1.put(
'integrations.update',
{
authRequired: true,
body: isIntegrationsUpdateProps,
response: {
200: integrationSuccessSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { bodyParams } = this;
let integration: IIntegration | null = null;
switch (bodyParams.type) {
case 'webhook-outgoing':
if (bodyParams.target_url) {
integration = await Integrations.findOne({ urls: bodyParams.target_url });
} else if (bodyParams.integrationId) {
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
}
if (!integration) {
return API.v1.failure('No integration found.');
}
await updateOutgoingIntegration(this.userId, integration._id, bodyParams as Parameters<typeof updateOutgoingIntegration>[2]);
return API.v1.success({
integration: await Integrations.findOne({ _id: integration._id }),
});
case 'webhook-incoming':
integration = await Integrations.findOne({ _id: bodyParams.integrationId });
if (!integration) {
return API.v1.failure('No integration found.');
}
await updateIncomingIntegration(
this.userId,
integration._id,
bodyParams as unknown as Parameters<typeof updateIncomingIntegration>[2],
);
return API.v1.success({
integration: await Integrations.findOne({ _id: integration._id }),
});
default:
return API.v1.failure('Invalid integration type.');
}
},
);
const voidIntegrationsResponse = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
API.v1.post(
'integrations.clearHistory',
{
authRequired: true,
body: isIntegrationsClearHistoryProps,
response: {
200: voidIntegrationsResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
await clearIntegrationHistoryMethod(this.userId, this.bodyParams.integrationId);
return API.v1.success();
},
);
API.v1.post(
'integrations.replayOutgoing',
{
authRequired: true,
body: isIntegrationsReplayProps,
response: {
200: voidIntegrationsResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { integrationId, historyId } = this.bodyParams;
await replayOutgoingIntegrationMethod(this.userId, { integrationId, historyId });
return API.v1.success();
},
);

View File

@@ -0,0 +1,301 @@
import type { IInvite } from '@rocket.chat/core-typings';
import {
ajv,
isFindOrCreateInviteParams,
isUseInviteTokenProps,
isValidateInviteTokenProps,
isSendInvitationEmailParams,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { findOrCreateInvite } from '../../lib/rooms/invites/findOrCreateInvite';
import { listInvites } from '../../lib/rooms/invites/listInvites';
import { removeInvite } from '../../lib/rooms/invites/removeInvite';
import { sendInvitationEmail } from '../../lib/rooms/invites/sendInvitationEmail';
import { useInviteToken } from '../../lib/rooms/invites/useInviteToken';
import { validateInviteToken } from '../../lib/rooms/invites/validateInviteToken';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
const removeInviteResponseSchema = ajv.compile({
type: 'boolean',
enum: [true],
});
const useInviteTokenResponseSchema = ajv.compile({
type: 'object',
properties: {
room: {
type: 'object',
properties: {
rid: { type: 'string' },
prid: { type: 'string', nullable: true },
fname: { type: 'string', nullable: true },
name: { type: 'string', nullable: true },
t: { type: 'string' },
},
required: ['rid', 't'],
additionalProperties: false,
},
success: { type: 'boolean', enum: [true] },
},
required: ['room', 'success'],
additionalProperties: false,
});
const validateInviteTokenResponseSchema = ajv.compile<{ valid: boolean }>({
type: 'object',
properties: {
valid: { type: 'boolean' },
success: { type: 'boolean', enum: [true] },
},
required: ['valid', 'success'],
additionalProperties: false,
});
const sendInvitationEmailResponseSchema = ajv.compile<{ success: boolean }>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});
const invites = API.v1
.get(
'listInvites',
{
authRequired: true,
response: {
200: ajv.compile<IInvite[]>({
type: 'array',
items: {
$ref: '#/components/schemas/IInvite',
},
}),
401: ajv.compile({
additionalProperties: false,
type: 'object',
properties: {
error: {
type: 'string',
},
status: {
type: 'string',
nullable: true,
},
message: {
type: 'string',
nullable: true,
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success', 'error'],
}),
},
},
async function () {
const result = await listInvites(this.userId);
return API.v1.success(result);
},
)
.post(
'findOrCreateInvite',
{
authRequired: true,
body: isFindOrCreateInviteParams,
response: {
200: ajv.compile({
additionalProperties: false,
type: 'object',
properties: {
_id: {
type: 'string',
},
rid: {
type: 'string',
},
userId: {
type: 'string',
},
createdAt: {
type: 'string',
},
_updatedAt: {
type: 'string',
},
expires: {
type: 'string',
nullable: true,
},
url: {
type: 'string',
},
maxUses: {
type: 'number',
},
days: {
type: 'number',
},
uses: {
type: 'number',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['_id', 'rid', 'createdAt', 'maxUses', 'uses', 'userId', '_updatedAt', 'days', 'success'],
}),
400: ajv.compile({
additionalProperties: false,
type: 'object',
properties: {
error: {
type: 'string',
},
stack: {
type: 'string',
nullable: true,
},
errorType: {
type: 'string',
},
details: {
type: 'object',
nullable: true,
properties: {
rid: {
type: 'string',
},
method: {
type: 'string',
},
},
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success', 'errorType', 'error'],
}),
401: ajv.compile({
additionalProperties: false,
type: 'object',
properties: {
error: {
type: 'string',
},
status: {
type: 'string',
nullable: true,
},
message: {
type: 'string',
nullable: true,
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success', 'error'],
}),
},
},
async function () {
const { rid, days, maxUses } = this.bodyParams;
return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite);
},
)
.delete(
'removeInvite/:_id',
{
authRequired: true,
response: {
200: removeInviteResponseSchema,
400: validateBadRequestErrorResponse,
401: ajv.compile({
type: 'object',
properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } },
required: ['success', 'error'],
additionalProperties: false,
}),
},
},
async function action() {
const { _id } = this.urlParams;
return API.v1.success(await removeInvite(this.userId, { _id }));
},
)
.post(
'useInviteToken',
{
authRequired: true,
body: isUseInviteTokenProps,
response: {
200: useInviteTokenResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { token } = this.bodyParams;
// eslint-disable-next-line react-hooks/rules-of-hooks
return API.v1.success(await useInviteToken(this.userId, token));
},
)
.post(
'validateInviteToken',
{
authRequired: false,
body: isValidateInviteTokenProps,
response: {
200: validateInviteTokenResponseSchema,
},
},
async function action() {
const { token } = this.bodyParams;
try {
return API.v1.success({ valid: Boolean(await validateInviteToken(token)) });
} catch (_) {
return API.v1.success({ valid: false });
}
},
)
.post(
'sendInvitationEmail',
{
authRequired: true,
body: isSendInvitationEmailParams,
response: {
200: sendInvitationEmailResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { emails } = this.bodyParams;
try {
return API.v1.success({ success: Boolean(await sendInvitationEmail(this.userId, emails)) });
} catch (e: unknown) {
return API.v1.failure({ error: e instanceof Error ? e.message : String(e) });
}
},
);
type InvitesEndpoints = ExtractRoutesFromAPI<typeof invites>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends InvitesEndpoints {}
}

View File

@@ -0,0 +1,86 @@
import { LDAP } from '@rocket.chat/core-services';
import { ajv, isLdapTestSearch, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse } from '@rocket.chat/rest-typings';
import { SystemLogger } from '../../lib/logger/system';
import { settings } from '../../settings';
import { API } from '../api';
const messageResponseSchema = {
type: 'object' as const,
properties: {
message: { type: 'string' as const },
success: {
type: 'boolean' as const,
enum: [true] as const,
},
},
required: ['message', 'success'] as const,
additionalProperties: false,
};
API.v1.post(
'ldap.testConnection',
{
authRequired: true,
permissionsRequired: ['test-admin-options'],
response: {
200: ajv.compile<{ message: string; success: true }>(messageResponseSchema),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
if (!this.userId) {
throw new Error('error-invalid-user');
}
if (settings.get<boolean>('LDAP_Enable') !== true) {
throw new Error('LDAP_disabled');
}
try {
await LDAP.testConnection();
} catch (err) {
SystemLogger.error({ err });
throw new Error('Connection_failed');
}
return API.v1.success({
message: 'LDAP_Connection_successful' as const,
});
},
);
API.v1.post(
'ldap.testSearch',
{
authRequired: true,
permissionsRequired: ['test-admin-options'],
body: isLdapTestSearch,
response: {
200: ajv.compile<{ message: string; success: true }>(messageResponseSchema),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
if (!this.userId) {
throw new Error('error-invalid-user');
}
if (settings.get<boolean>('LDAP_Enable') !== true) {
throw new Error('LDAP_disabled');
}
try {
await LDAP.testSearch(this.bodyParams.username);
} catch (err) {
SystemLogger.error({ err });
throw new Error('LDAP_search_failed');
}
return API.v1.success({
message: 'LDAP_User_Found' as const,
});
},
);

View File

@@ -0,0 +1,71 @@
import { LoginCodes } from '@rocket.chat/models';
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
import type { JSONSchemaType } from 'ajv';
import { Accounts } from 'meteor/accounts-base';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
const loginCodeRedeemResponse = ajv.compile<{ loginToken: string; userId: string }>({
type: 'object',
properties: {
loginToken: { type: 'string' },
userId: { type: 'string' },
success: { type: 'boolean', enum: [true] },
},
required: ['loginToken', 'userId', 'success'],
additionalProperties: false,
});
type LoginCodeRedeemParams = { code: string };
const LoginCodeRedeemSchema: JSONSchemaType<LoginCodeRedeemParams> = {
type: 'object',
properties: {
code: { type: 'string', minLength: 64, maxLength: 64 },
},
required: ['code'],
additionalProperties: false,
};
const isLoginCodeRedeemParamsPOST = ajv.compile<LoginCodeRedeemParams>(LoginCodeRedeemSchema);
const loginCodeEndpoints = API.v1.post(
'loginCode.redeem',
{
authRequired: false,
body: isLoginCodeRedeemParamsPOST,
rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 10 },
response: {
200: loginCodeRedeemResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { code } = this.bodyParams;
const loginCode = await LoginCodes.findOneNotExpiredByCodeAndDelete(code);
if (!loginCode) {
return API.v1.failure('error-invalid-code');
}
const { userId } = loginCode;
const stampedToken = Accounts._generateStampedLoginToken();
await Accounts._insertLoginToken(userId, stampedToken);
return API.v1.success({
loginToken: stampedToken.token,
userId,
});
},
);
type LoginCodeEndpoints = ExtractRoutesFromAPI<typeof loginCodeEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LoginCodeEndpoints {}
}

View File

@@ -0,0 +1,72 @@
import {
ajv,
isMailerProps,
isMailerUnsubscribeProps,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
import { sendMail } from '../../lib/notifications/mail-messages/functions/sendMail';
import { Mailer } from '../../lib/notifications/mail-messages/lib/Mailer';
import { API } from '../api';
const mailerResponseSchema = ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: true,
});
const mailerUnsubscribeResponseSchema = ajv.compile<void>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
additionalProperties: false,
});
API.v1.post(
'mailer',
{
authRequired: true,
body: isMailerProps,
permissionsRequired: ['send-mail'],
response: {
200: mailerResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { from, subject, body, dryrun, query } = this.bodyParams;
const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query });
return API.v1.success(result);
},
);
API.v1.post(
'mailer.unsubscribe',
{
authRequired: true,
body: isMailerUnsubscribeProps,
rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 1 },
response: {
200: mailerUnsubscribeResponseSchema,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { _id, createdAt } = this.bodyParams;
await Mailer.unsubscribe(_id, createdAt);
return API.v1.success();
},
);

View File

@@ -0,0 +1,277 @@
import { MediaCall } from '@rocket.chat/core-services';
import type { IMediaCall } from '@rocket.chat/core-typings';
import type { CallAnswer, CallFeature, ServerMediaCallSignal } from '@rocket.chat/media-signaling';
import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling';
import { MediaCalls } from '@rocket.chat/models';
import {
ajv,
validateNotFoundErrorResponse,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import type { JSONSchemaType } from 'ajv';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
type MediaCallsAnswer = {
callId: string;
contractId: string;
answer: CallAnswer;
supportedFeatures?: CallFeature[];
};
const MediaCallsAnswerSchema: JSONSchemaType<MediaCallsAnswer> = {
type: 'object',
properties: {
callId: {
type: 'string',
},
contractId: {
type: 'string',
},
answer: {
type: 'string',
enum: callAnswerList,
},
supportedFeatures: {
type: 'array',
items: {
type: 'string',
enum: callFeatureList,
},
nullable: true,
},
},
required: ['callId', 'contractId', 'answer'],
additionalProperties: false,
};
export const isMediaCallsAnswerProps = ajv.compile<MediaCallsAnswer>(MediaCallsAnswerSchema);
const mediaCallsAnswerEndpoints = API.v1.post(
'media-calls.answer',
{
response: {
200: ajv.compile<{
call: IMediaCall;
}>({
additionalProperties: false,
type: 'object',
properties: {
call: {
type: 'object',
$ref: '#/components/schemas/IMediaCall',
description: 'The updated call information.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['call', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
body: isMediaCallsAnswerProps,
authRequired: true,
},
async function action() {
const call = await MediaCall.answerCall(this.userId, this.bodyParams);
return API.v1.success({
call,
});
},
);
type MediaCallsAnswerEndpoints = ExtractRoutesFromAPI<typeof mediaCallsAnswerEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends MediaCallsAnswerEndpoints {}
}
type MediaCallsStateSignalsParams = {
contractId: string;
};
const MediaCallsStateSignalsSchema: JSONSchemaType<MediaCallsStateSignalsParams> = {
type: 'object',
properties: {
contractId: {
type: 'string',
},
},
required: ['contractId'],
additionalProperties: false,
};
export const isMediaCallsStateSignalsProps = ajv.compile<MediaCallsStateSignalsParams>(MediaCallsStateSignalsSchema);
const mediaCallsStateEndpoints = API.v1
.get(
'media-calls.state',
{
response: {
200: ajv.compile<{
calls: IMediaCall[];
}>({
additionalProperties: false,
type: 'object',
properties: {
calls: {
type: 'array',
items: {
type: 'object',
$ref: '#/components/schemas/IMediaCall',
},
description: 'The list of active calls.',
},
success: {
type: 'boolean',
description: 'Indicates the request was successful.',
},
},
required: ['calls', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
authRequired: true,
},
async function action() {
const calls = await MediaCalls.findAllNotOverByUid(this.userId).toArray();
return API.v1.success({
calls,
});
},
)
.get(
'media-calls.stateSignals',
{
response: {
200: ajv.compile<{
signals: ServerMediaCallSignal[];
}>({
additionalProperties: false,
type: 'object',
properties: {
signals: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string' },
callId: { type: 'string' },
},
required: ['callId', 'type'],
additionalProperties: true,
},
description: 'The list of signals that were already sent for the active calls.',
},
success: {
type: 'boolean',
description: 'Indicates the request was successful.',
},
},
required: ['signals', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
query: isMediaCallsStateSignalsProps,
authRequired: true,
},
async function action() {
const { contractId } = this.queryParams;
const signals = await MediaCall.getUserStateSignals(this.userId, contractId);
return API.v1.success({
signals,
});
},
);
type MediaCallsStateEndpoints = ExtractRoutesFromAPI<typeof mediaCallsStateEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends MediaCallsStateEndpoints {}
}
type MediaCallsInfoParams = {
callId: string;
};
const MediaCallsInfoSchema: JSONSchemaType<MediaCallsInfoParams> = {
type: 'object',
properties: {
callId: {
type: 'string',
},
},
required: ['callId'],
additionalProperties: false,
};
export const isMediaCallsInfoProps = ajv.compile<MediaCallsInfoParams>(MediaCallsInfoSchema);
const mediaCallsInfoEndpoints = API.v1.get(
'media-calls.info',
{
response: {
200: ajv.compile<{
call: IMediaCall;
}>({
additionalProperties: false,
type: 'object',
properties: {
call: {
type: 'object',
$ref: '#/components/schemas/IMediaCall',
},
success: {
type: 'boolean',
description: 'Indicates the request was successful.',
},
},
required: ['call', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
404: validateNotFoundErrorResponse,
},
query: isMediaCallsInfoProps,
authRequired: true,
},
async function action() {
const call = await MediaCalls.findOneById(this.queryParams.callId);
if (!call?.uids.includes(this.userId)) {
return API.v1.notFound();
}
return API.v1.success({
call,
});
},
);
type MediaCallsInfoEndpoints = ExtractRoutesFromAPI<typeof mediaCallsInfoEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends MediaCallsInfoEndpoints {}
}

View File

@@ -0,0 +1,73 @@
import { hashLoginToken } from '@rocket.chat/account-utils';
import { Authorization } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import type { Request, Response, NextFunction } from 'express';
import { oAuth2ServerAuth } from '../../../lib/auth/oauth2-server/oauth2-server';
type AuthenticationMiddlewareConfig = {
rejectUnauthorized?: boolean;
cookies?: boolean;
};
export function authenticationMiddleware(
config: AuthenticationMiddlewareConfig = {
rejectUnauthorized: true,
cookies: false,
},
) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (config.cookies) {
req.headers['x-auth-token'] = req.cookies.rc_token ?? req.headers['x-auth-token'];
req.headers['x-user-id'] = req.cookies.rc_uid ?? req.headers['x-user-id'];
}
const { 'x-user-id': userId, 'x-auth-token': authToken } = req.headers;
if (userId && authToken) {
const user = await Users.findOneByIdAndLoginToken(userId as string, hashLoginToken(authToken as string));
req.user = user || undefined;
} else {
const { authorization } = req.headers;
const accessToken = typeof req.query.access_token === 'string' ? req.query.access_token : undefined;
delete req.query.access_token;
req.user = await oAuth2ServerAuth({ authorization, accessToken });
}
if (config.rejectUnauthorized && !req.user) {
res.status(401).send('Unauthorized');
return;
}
req.userId = req?.user?._id;
next();
};
}
export function hasPermissionMiddleware(
permission: string,
{ rejectUnauthorized } = {
rejectUnauthorized: true,
},
) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.userId) {
if (rejectUnauthorized) {
res.status(401).send('Unauthorized');
return;
}
req.unauthorized = true;
return next();
}
if (!(await Authorization.hasPermission(req.userId, permission))) {
if (rejectUnauthorized) {
res.status(403).send('Forbidden');
return;
}
req.unauthorized = true;
}
next();
};
}

View File

@@ -0,0 +1,48 @@
import { type IUser, type RequiredField } from '@rocket.chat/core-typings';
import { type Logger } from '@rocket.chat/logger';
import type { MiddlewareHandler } from 'hono';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { applyBreakingChanges, type APIClass } from '../../ApiClass';
import { convertHonoContextToApiActionContext, type HonoContext } from '../../router';
const isUserWithUsername = (user: IUser | null): user is RequiredField<IUser, 'username'> => {
return user !== null && typeof user === 'object' && 'username' in user && user.username !== undefined;
};
export function authenticationMiddlewareForHono(
api: APIClass<string, Record<string, unknown>>,
options: {
authRequired?: boolean;
authOrAnonRequired?: boolean;
userWithoutUsername?: boolean;
logger: Logger;
},
): MiddlewareHandler {
return async (c: HonoContext, next) => {
const user = await api.authenticatedRoute(convertHonoContextToApiActionContext(c, { logger: options.logger }));
const shouldPreventAnonymousRead = !user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead');
const shouldPreventUserRead = !user && options.authRequired;
if (shouldPreventAnonymousRead || shouldPreventUserRead) {
const result = api.unauthorized('You must be logged in to do this.');
// TODO: MAJOR
if (!applyBreakingChanges) {
Object.assign(result.body, {
status: 'error',
message: 'You must be logged in to do this.',
});
}
return c.json(result.body, result.statusCode);
}
if (user && !options.userWithoutUsername && !isUserWithUsername(user)) {
throw new Meteor.Error('error-unauthorized', 'Users must have a username');
}
c.set('user', user);
return next();
};
}

View File

@@ -0,0 +1,326 @@
import { Router } from '@rocket.chat/http-router';
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import { cors } from './cors';
import { CachedSettings } from '../../../settings/CachedSettings';
describe('Cors middleware', () => {
it('should return allow-origin header for GET if CORS enabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: '*',
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('origin', 'http://localhost');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
expect(response.headers).not.toHaveProperty('access-control-allow-methods');
expect(response.headers).toHaveProperty('access-control-allow-headers');
});
it('should return allow-origin header for GET if CORS disabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: false,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('origin', 'http://localhost');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
expect(response.headers).not.toHaveProperty('access-control-allow-methods');
expect(response.headers).toHaveProperty('access-control-allow-headers');
});
it('should handle CORS if enabled to *', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: '*',
} as any);
settings.set({
_id: 'MCP_Enabled',
value: true,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app)
.options('/api/test')
.set('origin', 'http://localhost')
.set('access-control-request-method', 'POST')
.set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version');
expect(response.statusCode).toBe(200);
expect(response.body).not.toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH');
expect(response.headers).toHaveProperty(
'access-control-allow-headers',
'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version',
);
});
it('should handle CORS if enabled to specific origin', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: 'http://localhost',
} as any);
settings.set({
_id: 'MCP_Enabled',
value: true,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app)
.options('/api/test')
.set('origin', 'http://localhost')
.set('access-control-request-method', 'POST')
.set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version');
expect(response.statusCode).toBe(200);
expect(response.body).not.toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', 'http://localhost');
expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH');
expect(response.headers).toHaveProperty(
'access-control-allow-headers',
'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version',
);
});
it('should not advertise the MCP protocol header while MCP is disabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({ _id: 'API_Enable_CORS', value: true } as any);
settings.set({ _id: 'API_CORS_Origin', value: '*' } as any);
settings.set({ _id: 'MCP_Enabled', value: false } as any);
api
.use(cors(settings))
.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200, body: {} }));
app.use(api.router);
const response = await request(app).options('/api/test').set('origin', 'http://localhost').set('access-control-request-method', 'POST');
expect(response.statusCode).toBe(200);
expect(response.headers['access-control-allow-headers']).not.toContain('MCP-Protocol-Version');
});
it('should not handle CORS if origin is not allowed', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: 'http://localhost',
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).options('/api/test').set('origin', 'http://example.com');
expect(response.statusCode).toBe(403);
});
it('should not handle CORS if disabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: false,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).options('/api/test').set('origin', 'http://localhost');
expect(response.statusCode).toBe(405);
expect(response.text).toBe('CORS not enabled. Go to "Admin > General > REST Api" to enable it.');
expect(response.headers).not.toHaveProperty('access-control-allow-origin');
});
});

View File

@@ -0,0 +1,62 @@
import type { MiddlewareHandler } from 'hono';
import type { CachedSettings } from '../../../settings/CachedSettings';
const defaultHeaders = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH',
'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization',
};
const getAllowedHeaders = (settings: CachedSettings): string =>
`${defaultHeaders['Access-Control-Allow-Headers']}${settings.get('MCP_Enabled') ? ', MCP-Protocol-Version' : ''}`;
export const cors =
(settings: CachedSettings): MiddlewareHandler =>
async (c, next) => {
const { req, res } = c;
const allowedHeaders = getAllowedHeaders(settings);
if (req.method !== 'OPTIONS') {
res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Access-Control-Allow-Headers', allowedHeaders);
await next();
return;
}
// check if a pre-flight request
if (!req.header('access-control-request-method') && !req.header('origin')) {
await next();
return;
}
if (!settings.get('API_Enable_CORS')) {
return c.body('CORS not enabled. Go to "Admin > General > REST Api" to enable it.', 405);
}
const CORSOriginSetting = String(settings.get('API_CORS_Origin'));
if (CORSOriginSetting === '*') {
res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']);
res.headers.set('Access-Control-Allow-Headers', allowedHeaders);
await next();
return;
}
const origins = CORSOriginSetting.trim()
.split(',')
.map((origin) => String(origin).trim().toLocaleLowerCase());
const originHeader = req.header('origin');
// if invalid origin reply without required CORS headers
if (!originHeader || !origins.includes(originHeader)) {
return c.body('Invalid origin', 403);
}
res.headers.set('Vary', 'Origin');
res.headers.set('Access-Control-Allow-Origin', originHeader);
res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']);
res.headers.set('Access-Control-Allow-Headers', allowedHeaders);
await next();
};

View File

@@ -0,0 +1,86 @@
import { Router } from '@rocket.chat/http-router';
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import { cors } from './cors';
import { experimentalWarningMiddleware } from './experimental';
import { CachedSettings } from '../../../settings/CachedSettings';
const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
const buildApp = ({ corsEnabled }: { corsEnabled: boolean }) => {
const ajv = new Ajv();
const settings = new CachedSettings();
settings.set({ _id: 'API_Enable_CORS', value: corsEnabled } as any);
settings.set({ _id: 'API_CORS_Origin', value: 'https://allowed.example' } as any);
const route = (router: Router<any, any, any>) =>
router.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({
statusCode: 200 as const,
body: {},
}));
const api = new Router('/api')
.use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) }))
.use(cors(settings))
.use(route(new Router('/v1')))
.use(route(new Router('/experimental')));
const app = express();
app.use(api.router);
return app;
};
const preflight = (app: express.Express, path: string, origin: string) =>
request(app).options(path).set('Origin', origin).set('Access-Control-Request-Method', 'GET');
describe('Experimental middleware', () => {
it('should stamp the unstable signal headers on experimental responses', async () => {
const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/test');
expect(res.statusCode).toBe(200);
expect(res.headers['x-experimental']).toBe('true');
expect(res.headers.warning).toBe(WARNING_HEADER);
});
it('should not stamp responses from other versions', async () => {
const res = await request(buildApp({ corsEnabled: true })).get('/api/v1/test');
expect(res.statusCode).toBe(200);
expect(res.headers['x-experimental']).toBeUndefined();
expect(res.headers.warning).toBeUndefined();
});
it('should stamp 404s for unmatched experimental paths', async () => {
const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/nope');
expect(res.statusCode).toBe(404);
expect(res.headers['x-experimental']).toBe('true');
});
// cors answers rejected preflights without calling next(), so these only carry the headers
// while the middleware stays registered ahead of it
it('should stamp preflight rejections when CORS is disabled', async () => {
const res = await preflight(buildApp({ corsEnabled: false }), '/api/experimental/test', 'https://allowed.example');
expect(res.statusCode).toBe(405);
expect(res.headers['x-experimental']).toBe('true');
expect(res.headers.warning).toBe(WARNING_HEADER);
});
it('should stamp preflight rejections from disallowed origins', async () => {
const res = await preflight(buildApp({ corsEnabled: true }), '/api/experimental/test', 'https://evil.example');
expect(res.statusCode).toBe(403);
expect(res.headers['x-experimental']).toBe('true');
expect(res.headers.warning).toBe(WARNING_HEADER);
});
it('should not stamp preflight rejections from other versions', async () => {
const res = await preflight(buildApp({ corsEnabled: true }), '/api/v1/test', 'https://evil.example');
expect(res.statusCode).toBe(403);
expect(res.headers['x-experimental']).toBeUndefined();
});
});

View File

@@ -0,0 +1,29 @@
import type { MiddlewareHandler } from 'hono';
// `x-experimental` is the supported programmatic signal. `Warning: 299` is emitted for
// legacy tooling only — warn code 299 came from RFC 7234, which RFC 9111 has obsoleted
// along with the `Warning` header itself.
const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
/**
* Stamps every experimental response with the unstable signal headers.
*
* Registered on the shared `/api` mount ahead of `cors`, and scoped by path rather than by
* router: `cors` answers rejected preflights with 403/405 without calling `next()`, so a
* middleware living on `API.experimental.router` would never run for those responses.
*
* The headers are set on `c.res.headers` before the downstream handlers run; Hono merges them
* into whatever response is produced later, so 404s and CORS rejections are covered too.
*/
export const experimentalWarningMiddleware =
({ basePathRegex }: { basePathRegex: RegExp }): MiddlewareHandler =>
async (c, next) => {
if (!basePathRegex.test(c.req.path)) {
return next();
}
c.res.headers.set('x-experimental', 'true');
c.res.headers.set('Warning', WARNING_HEADER);
await next();
};

View File

@@ -0,0 +1,37 @@
import type { Logger } from '@rocket.chat/logger';
import { censorUrl } from '@rocket.chat/tools';
import type { MiddlewareHandler } from 'hono';
import { getRestPayload } from '../../../lib/logger/logPayloads';
export const loggerMiddleware =
(logger: Logger): MiddlewareHandler =>
async (c, next) => {
const startTime = Date.now();
const log = logger.logger.child(
{
method: c.req.method,
url: censorUrl(c.req.url),
userId: c.req.header('x-user-id'),
userAgent: c.req.header('user-agent'),
length: c.req.header('content-length'),
host: c.req.header('host'),
referer: c.req.header('referer'),
remoteIP: c.get('remoteAddress'),
...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method) && (await getRestPayload(c.req))),
},
{
redact: [
'payload.password', // Potentially logged by v1/login
],
},
);
await next();
log.http({
status: c.res.status,
responseTime: Date.now() - startTime,
});
};

View File

@@ -0,0 +1,349 @@
import { Router } from '@rocket.chat/http-router';
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import { metricsMiddleware } from './metrics';
import { CachedSettings } from '../../../settings/CachedSettings';
describe('Metrics middleware', () => {
it('should handle metrics', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'Prometheus_API_User_Agent',
value: true,
} as any);
const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};
// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
const responseSizeHistogram = { observe: jest.fn() };
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
api
.use(
metricsMiddleware({
api: { version: 1 } as any,
settings,
endpointTimeSummary: summary as any,
endpointTimeHistogram: histogram as any,
responseSizeHistogram: responseSizeHistogram as any,
activeRequestsGauge: activeRequestsGauge as any,
}),
)
.get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'Metrics test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');
expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: '/api/test' });
});
it('should strip path from metrics', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'Prometheus_API_User_Agent',
value: true,
} as any);
const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};
// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
const responseSizeHistogram = { observe: jest.fn() };
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
api
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\//),
api: { version: 1 } as any,
settings,
endpointTimeSummary: summary as any,
endpointTimeHistogram: histogram as any,
responseSizeHistogram: responseSizeHistogram as any,
activeRequestsGauge: activeRequestsGauge as any,
}),
)
.get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'Metrics test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');
expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: 'test' });
});
it('should decode path for method.call endpoints', async () => {
const ajv = new Ajv();
const app = express();
const settings = new CachedSettings();
const api = new Router('/api');
const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};
// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
const responseSizeHistogram = { observe: jest.fn() };
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
api
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\//),
api: { version: 1 } as any,
settings,
endpointTimeSummary: summary as any,
endpointTimeHistogram: histogram as any,
responseSizeHistogram: responseSizeHistogram as any,
activeRequestsGauge: activeRequestsGauge as any,
}),
)
.get(
'/method.call/:id',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: `Metrics test successful`,
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/method.call/get%3Aparam').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');
expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({
status: 200,
method: 'get',
version: 1,
entrypoint: 'method.call/get:param',
});
});
it('should only record requests matching its own base path', async () => {
const ajv = new Ajv();
const app = express();
const settings = new CachedSettings();
const makeMetrics = () => {
const endTimer = jest.fn();
return {
endTimer,
summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
responseSizeHistogram: { observe: jest.fn() },
activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
};
};
const v1Metrics = makeMetrics();
const experimentalMetrics = makeMetrics();
const route = (router: Router<any, any, any>) =>
router.get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => ({
statusCode: 200,
body: { message: 'Metrics test successful' },
}),
);
const api = new Router('/api');
api
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\/v1\//),
api: { version: 'v1' } as any,
settings,
endpointTimeSummary: v1Metrics.summary as any,
endpointTimeHistogram: v1Metrics.histogram as any,
responseSizeHistogram: v1Metrics.responseSizeHistogram as any,
activeRequestsGauge: v1Metrics.activeRequestsGauge as any,
}),
)
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\/experimental\//),
api: { version: 'experimental' } as any,
settings,
endpointTimeSummary: experimentalMetrics.summary as any,
endpointTimeHistogram: experimentalMetrics.histogram as any,
responseSizeHistogram: experimentalMetrics.responseSizeHistogram as any,
activeRequestsGauge: experimentalMetrics.activeRequestsGauge as any,
}),
)
.use(route(new Router('/v1')))
.use(route(new Router('/experimental')));
app.use(api.router);
expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
expect(v1Metrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'v1', entrypoint: 'test' });
expect(experimentalMetrics.summary.startTimer).not.toHaveBeenCalled();
expect(experimentalMetrics.activeRequestsGauge.inc).not.toHaveBeenCalled();
expect((await request(app).get('/api/experimental/test')).statusCode).toBe(200);
expect(experimentalMetrics.summary.startTimer).toHaveBeenCalledTimes(1);
expect(experimentalMetrics.endTimer).toHaveBeenCalledWith({
status: 200,
method: 'get',
version: 'experimental',
entrypoint: 'test',
});
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
});
it('should sample the default router and unmatched paths exactly once', async () => {
const ajv = new Ajv();
const app = express();
const settings = new CachedSettings();
const makeMetrics = () => {
const endTimer = jest.fn();
return {
endTimer,
summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
responseSizeHistogram: { observe: jest.fn() },
activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
};
};
const v1Metrics = makeMetrics();
const defaultMetrics = makeMetrics();
const wire = (metrics: ReturnType<typeof makeMetrics>, extra: { basePathRegex?: RegExp; excludePathRegex?: RegExp }, version: string) =>
metricsMiddleware({
...extra,
api: { version },
settings,
endpointTimeSummary: metrics.summary as any,
endpointTimeHistogram: metrics.histogram as any,
responseSizeHistogram: metrics.responseSizeHistogram as any,
activeRequestsGauge: metrics.activeRequestsGauge as any,
});
const api = new Router('/api')
.use(wire(v1Metrics, { basePathRegex: new RegExp(/^\/api\/v1\//) }, 'v1'))
.use(wire(defaultMetrics, { excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//) }, 'default'));
const route = (router: Router<any, any, any>, subpath: string) =>
router.get(subpath, { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200 as const, body: {} }));
// the catch-all router is mounted first on purpose: the exclusion guard has to hold
// regardless of the order the versioned routers happen to be registered in
api.use(route(new Router(''), 'info'));
api.use(route(new Router('/v1'), '/test'));
expect((await request(app.use(api.router)).get('/api/info')).statusCode).toBe(200);
expect(v1Metrics.summary.startTimer).not.toHaveBeenCalled();
expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'default', entrypoint: '/api/info' });
defaultMetrics.endTimer.mockClear();
expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
expect(defaultMetrics.summary.startTimer).toHaveBeenCalledTimes(1); // still just the /api/info call
expect(defaultMetrics.endTimer).not.toHaveBeenCalled();
expect((await request(app).get('/api/bogus')).statusCode).toBe(404);
expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 404, method: 'get', version: 'default', entrypoint: '/api/*' });
});
});

View File

@@ -0,0 +1,71 @@
import type { MiddlewareHandler } from 'hono';
import type { Gauge, Histogram, Summary } from 'prom-client';
import type { CachedSettings } from '../../../settings/CachedSettings';
export const metricsMiddleware =
({
basePathRegex,
excludePathRegex,
api,
settings,
endpointTimeSummary,
endpointTimeHistogram,
responseSizeHistogram,
activeRequestsGauge,
}: {
basePathRegex?: RegExp;
excludePathRegex?: RegExp;
api: { version?: string };
settings: CachedSettings;
endpointTimeSummary: Summary;
endpointTimeHistogram: Histogram;
responseSizeHistogram: Histogram;
activeRequestsGauge: Gauge;
}): MiddlewareHandler =>
async (c, next) => {
// Several metrics middlewares share the same `/api` mount (v1, experimental, apps, default), so
// each one has to ignore the paths that belong to the others or a request gets sampled more than
// once. The versioned ones opt in by prefix; the catch-all opts out of the prefixes it does not own.
if (basePathRegex && !basePathRegex.test(c.req.path)) {
return next();
}
if (excludePathRegex?.test(c.req.path)) {
return next();
}
const rocketchatRestApiEnd = endpointTimeSummary.startTimer();
const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer();
const methodLabel = { method: c.req.method.toLowerCase() };
activeRequestsGauge.inc(methodLabel);
await next();
activeRequestsGauge.dec(methodLabel);
const { method, path, routePath } = c.req;
// get rid of the base path (i.e.: /api/v1/)
const entrypoint = basePathRegex ? routePath.replace(basePathRegex, '') : routePath;
const histogramLabels = {
status: c.res.status,
method: method.toLowerCase(),
version: api.version,
entrypoint: basePathRegex && entrypoint.startsWith('method.call') ? decodeURIComponent(path.replace(basePathRegex, '')) : entrypoint,
};
rocketchatRestApiEnd({
...histogramLabels,
...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }),
});
rocketchatRestApiHistEnd(histogramLabels);
const contentLength = parseInt(c.res.headers.get('content-length') || '0', 10);
if (contentLength > 0) {
responseSizeHistogram.observe(histogramLabels, contentLength);
}
};

View File

@@ -0,0 +1,56 @@
import { Logger } from '@rocket.chat/logger';
import type { Method } from '@rocket.chat/rest-typings';
import type { MiddlewareHandler } from 'hono';
import { applyBreakingChanges } from '../../ApiClass';
import { API } from '../../api';
import { type PermissionsPayload, checkPermissionsForInvocation } from '../../api.helpers';
import type { TypedOptions } from '../../definition';
import type { HonoContext } from '../../router';
const logger = new Logger('PermissionsMiddleware');
export const permissionsMiddleware =
(options: TypedOptions): MiddlewareHandler =>
async (c: HonoContext, next) => {
if (!options.permissionsRequired) {
return next();
}
const user = c.get('user');
if (!user) {
if (applyBreakingChanges) {
const unauthorized = API.v1.unauthorized('You must be logged in to do this');
return c.json(unauthorized.body, unauthorized.statusCode);
}
const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(failure.body, failure.statusCode);
}
let hasPermission: boolean;
try {
hasPermission = await checkPermissionsForInvocation(
user._id,
options.permissionsRequired as PermissionsPayload,
c.req.method as Method,
);
} catch (e) {
logger.error({ msg: 'Error checking permissions for invocation', err: e });
const error = API.v1.internalError();
return c.json(error.body, error.statusCode);
}
if (!hasPermission) {
if (applyBreakingChanges) {
const forbidden = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(forbidden.body, forbidden.statusCode);
}
const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(failure.body, failure.statusCode);
}
return next();
};

View File

@@ -0,0 +1,131 @@
import type { Context } from 'hono';
import { remoteAddressMiddleware } from './remoteAddressMiddleware';
describe('remoteAddressMiddleware', () => {
it('should set remoteAddress from x-real-ip header', async () => {
const mockContext = {
req: {
header: (name: string) => (name === 'x-real-ip' ? '192.168.1.1' : undefined),
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1');
expect(next).toHaveBeenCalled();
});
it('should set remoteAddress from socket.remoteAddress when no headers are present', async () => {
const mockContext = {
req: {
header: () => undefined,
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
expect(next).toHaveBeenCalled();
});
it('should handle x-forwarded-for header with HTTP_FORWARDED_COUNT', async () => {
process.env.HTTP_FORWARDED_COUNT = '2';
const mockContext = {
req: {
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1, 172.16.0.1' : undefined),
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
expect(next).toHaveBeenCalled();
});
it('should fallback to socket.remoteAddress when x-forwarded-for count is invalid', async () => {
process.env.HTTP_FORWARDED_COUNT = '5';
const mockContext = {
req: {
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1' : undefined),
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
expect(next).toHaveBeenCalled();
});
it('should handle missing socket information', async () => {
const mockContext = {
req: {
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1' : undefined),
},
env: {
server: {
incoming: {
socket: {},
connection: {},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1');
expect(next).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,40 @@
import type { IncomingMessage } from 'node:http';
import type { Context, MiddlewareHandler } from 'hono';
type HttpBindings = {
incoming: IncomingMessage;
};
const getRemoteAddress = (c: Context) => {
const bindings = (c.env?.server ? c.env.server : c.env) as HttpBindings;
const forwardedFor = c.req.header('x-forwarded-for');
const socket = bindings.incoming.socket.remoteAddress || bindings.incoming.connection.remoteAddress;
const remoteAddress = c.req.header('x-real-ip') || socket;
if (!socket) {
return remoteAddress || forwardedFor;
}
const httpForwardedCount = parseInt(String(process.env.HTTP_FORWARDED_COUNT)) || 0;
if (httpForwardedCount <= 0) {
return remoteAddress;
}
if (!forwardedFor || typeof forwardedFor.valueOf() !== 'string') {
return remoteAddress;
}
const forwardedForIPs = forwardedFor.split(',').map((ip) => ip.trim());
if (httpForwardedCount > forwardedForIPs.length) {
return remoteAddress;
}
return forwardedForIPs[forwardedForIPs.length - httpForwardedCount];
};
export const remoteAddressMiddleware: MiddlewareHandler = async function (c, next) {
const remoteAddress = getRemoteAddress(c);
c.set('remoteAddress', remoteAddress);
return next();
};

View File

@@ -0,0 +1,25 @@
import { tracerSpan } from '@rocket.chat/tracing';
import type { MiddlewareHandler } from 'hono';
export const tracerSpanMiddleware: MiddlewareHandler = async (c, next) => {
return tracerSpan(
`${c.req.method} ${c.req.url}`,
{
attributes: {
url: c.req.url,
// route: c.req.route?.path,
method: c.req.method,
userId: (c.req.raw.clone() as any).userId, // Assuming userId is attached to the request object
},
},
async (span) => {
if (span) {
c.header('X-Trace-Id', span.spanContext().traceId);
}
await next();
span?.setAttribute('status', c.res.status);
},
);
};

View File

@@ -0,0 +1,878 @@
import crypto from 'node:crypto';
import type { IDirectoryChannelResult, IDirectoryUserResult, IRoom, IUser } from '@rocket.chat/core-typings';
import { Settings, Users, WorkspaceCredentials } from '@rocket.chat/models';
import {
ajv,
isShieldSvgProps,
isSpotlightProps,
parseSpotlightUsernames,
parseSpotlightType,
isDirectoryProps,
isFingerprintProps,
isMeteorCall,
meSuccessResponseSchema,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings';
import { escapeHTML } from '@rocket.chat/tools';
import EJSON from 'ejson';
import { check } from 'meteor/check';
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
import { Meteor } from 'meteor/meteor';
import { passwordPolicy } from '../../lib/auth/passwordPolicy';
import { i18n } from '../../lib/i18n';
import { notifyOnSettingChangedById } from '../../lib/notifyListener';
import { getBaseUserFields } from '../../lib/utils/functions/getBaseUserFields';
import { isSMTPConfigured } from '../../lib/utils/functions/isSMTPConfigured';
import { getURL } from '../../lib/utils/getURL';
import { browseChannelsMethod } from '../../meteor-methods/rooms/browseChannels';
import { spotlightMethod } from '../../publications/spotlight';
import { settings } from '../../settings';
import { resetAuditedSettingByUser, updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates';
import { API } from '../api';
import { getPaginationItems } from '../lib/getPaginationItems';
import { getUserFromParams } from '../lib/getUserFromParams';
import { getUserInfo } from '../lib/getUserInfo';
import { logMethodCallError } from '../lib/logMethodCallError';
/**
* @openapi
* /api/v1/me:
* get:
* description: Gets user data of the authenticated user
* security:
* - authenticated: []
* responses:
* 200:
* description: The user data of the authenticated user
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiSuccessV1'
* - type: object
* properties:
* name:
* type: string
* username:
* type: string
* nickname:
* type: string
* emails:
* type: array
* items:
* type: object
* properties:
* address:
* type: string
* verified:
* type: boolean
* email:
* type: string
* status:
* $ref: '#/components/schemas/UserStatus'
* statusDefault:
* $ref: '#/components/schemas/UserStatus'
* statusText:
* $ref: '#/components/schemas/UserStatus'
* statusConnection:
* $ref: '#/components/schemas/UserStatus'
* bio:
* type: string
* avatarOrigin:
* type: string
* enum: [none, local, upload, url]
* utcOffset:
* type: number
* language:
* type: string
* settings:
* type: object
* properties:
* preferences:
* type: object
* enableAutoAway:
* type: boolean
* idleTimeLimit:
* type: number
* roles:
* type: array
* active:
* type: boolean
* defaultRoom:
* type: string
* customFields:
* type: array
* requirePasswordChange:
* type: boolean
* requirePasswordChangeReason:
* type: string
* services:
* type: object
* properties:
* github:
* type: object
* gitlab:
* type: object
* password:
* type: object
* properties:
* exists:
* type: boolean
* totp:
* type: object
* properties:
* enabled:
* type: boolean
* email2fa:
* type: object
* properties:
* enabled:
* type: boolean
* statusLivechat:
* type: string
* enum: [available, 'not-available']
* banners:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* title:
* type: string
* text:
* type: string
* textArguments:
* type: array
* items: {}
* modifiers:
* type: array
* items:
* type: string
* infoUrl:
* type: string
* oauth:
* type: object
* properties:
* authorizedClients:
* type: array
* items:
* type: string
* _updatedAt:
* type: string
* format: date-time
* avatarETag:
* type: string
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
API.v1.get(
'me',
{
authRequired: true,
userWithoutUsername: true,
response: {
200: ajv.compile<MeApiSuccessResponse>(meSuccessResponseSchema),
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const userFields = { ...getBaseUserFields(), services: 1 };
const user = (await Users.findOneById(this.userId, { projection: userFields })) as IUser;
return API.v1.success(await getUserInfo(user));
},
);
let onlineCache = 0;
let onlineCacheDate = 0;
const cacheInvalid = 60000; // 1 minute
const shieldSvgResponseSchema = ajv.compile<string>({
type: 'string',
description: 'SVG image markup',
});
API.v1.get(
'shield.svg',
{
authRequired: false,
rateLimiterOptions: {
numRequestsAllowed: 60,
intervalTimeInMS: 60000,
},
query: isShieldSvgProps,
response: {
200: shieldSvgResponseSchema,
400: validateBadRequestErrorResponse,
},
},
async function action() {
const { type, icon } = this.queryParams;
let { channel, name } = this.queryParams;
if (!settings.get('API_Enable_Shields')) {
throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', {
route: '/api/v1/shield.svg',
});
}
const types = settings.get<string>('API_Shield_Types');
if (
type &&
types !== '*' &&
!types
.split(',')
.map((t: string) => t.trim())
.includes(type)
) {
throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', {
route: '/api/v1/shield.svg',
});
}
const hideIcon = icon === 'false';
if (hideIcon && !name?.trim()) {
return API.v1.failure('Name cannot be empty when icon is hidden');
}
let text;
let backgroundColor = '#4c1';
switch (type) {
case 'online':
if (Date.now() - onlineCacheDate > cacheInvalid) {
onlineCache = await Users.countUsersNotOffline();
onlineCacheDate = Date.now();
}
text = `${onlineCache} ${i18n.t('Online')}`;
break;
case 'channel':
if (!channel) {
return API.v1.failure('Shield channel is required for type "channel"');
}
text = `#${channel}`;
break;
case 'user':
if (settings.get('API_Shield_user_require_auth') && !this.user) {
return API.v1.failure('You must be logged in to do this.');
}
const user = await getUserFromParams(this.queryParams);
// Respect the server's choice for using their real names or not
if (user.name && settings.get('UI_Use_Real_Name')) {
text = `${user.name}`;
} else {
text = `@${user.username}`;
}
switch (user.status) {
case 'online':
backgroundColor = '#1fb31f';
break;
case 'away':
backgroundColor = '#dc9b01';
break;
case 'busy':
backgroundColor = '#bc2031';
break;
case 'offline':
backgroundColor = '#a5a1a1';
}
break;
default:
text = i18n.t('Join_Chat').toUpperCase();
}
const iconSize = hideIcon ? 7 : 24;
const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize;
const rightSize = text.length * 6 + 20;
const width = leftSize + rightSize;
const height = 20;
channel = escapeHTML(channel);
text = escapeHTML(text);
name = escapeHTML(name);
const svgBody = `
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}">
<linearGradient id="b" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="a">
<rect width="${width}" height="${height}" rx="3" fill="#fff"/>
</mask>
<g mask="url(#a)">
<path fill="#555" d="M0 0h${leftSize}v${height}H0z"/>
<path fill="${backgroundColor}" d="M${leftSize} 0h${rightSize}v${height}H${leftSize}z"/>
<path fill="url(#b)" d="M0 0h${width}v${height}H0z"/>
</g>
${hideIcon ? '' : `<image x="5" y="3" width="14" height="14" xlink:href="${getURL('/assets/favicon.svg', { full: true })}"/>`}
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
${
name
? `<text x="${iconSize}" y="15" fill="#010101" fill-opacity=".3">${name}</text>
<text x="${iconSize}" y="14">${name}</text>`
: ''
}
<text x="${leftSize + 7}" y="15" fill="#010101" fill-opacity=".3">${text}</text>
<text x="${leftSize + 7}" y="14">${text}</text>
</g>
</svg>
`
.trim()
.replace(/\>[\s]+\</gm, '><');
return {
statusCode: 200 as const,
body: svgBody,
headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' },
};
},
);
const spotlightResponseSchema = ajv.compile<{
users: (Pick<IUser, 'name' | '_id' | 'username'> &
Partial<Pick<IUser, 'status' | 'statusText' | 'avatarETag'>> & {
nickname?: string;
outside?: boolean;
})[];
rooms: Pick<IRoom, 't' | 'name' | 'lastMessage' | '_id'>[];
}>({
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
name: { type: 'string' },
username: { type: 'string' },
status: { type: 'string' },
statusText: { type: 'string' },
avatarETag: { type: 'string' },
},
required: ['_id', 'name', 'username'],
additionalProperties: true,
},
},
rooms: {
type: 'array',
items: {
type: 'object',
properties: {
_id: { type: 'string' },
t: { type: 'string' },
name: { type: 'string' },
lastMessage: { $ref: '#/components/schemas/IMessage' },
},
required: ['_id', 't', 'name'],
additionalProperties: true,
},
},
success: { type: 'boolean', enum: [true] },
},
required: ['users', 'rooms', 'success'],
additionalProperties: false,
});
API.v1.get(
'spotlight',
{
// DDP `spotlight` accepts anonymous calls (Accounts_AllowAnonymousRead).
// Keep parity so anonymous-user / embedded-layout flows can still
// resolve a public channel through the navbar search.
authRequired: false,
rateLimiterOptions: { numRequestsAllowed: 100, intervalTimeInMS: 100000 },
query: isSpotlightProps,
response: {
200: spotlightResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { query, usernames, type, rid } = this.queryParams;
const result = await spotlightMethod({
text: query,
userId: this.userId,
usernames: parseSpotlightUsernames(usernames),
type: parseSpotlightType(type),
rid,
});
return API.v1.success(result);
},
);
const directoryResponseSchema = ajv.compile<{
result: (IDirectoryUserResult | IDirectoryChannelResult)[];
count: number;
offset: number;
total: number;
}>({
type: 'object',
properties: {
result: {
type: 'array',
items: {
oneOf: [{ $ref: '#/components/schemas/IDirectoryUserResult' }, { $ref: '#/components/schemas/IDirectoryChannelResult' }],
},
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['result', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
API.v1.get(
'directory',
{
authRequired: true,
rateLimiterOptions: { numRequestsAllowed: 100, intervalTimeInMS: 100000 },
query: isDirectoryProps,
response: {
200: directoryResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, query } = await this.parseJsonQuery();
const { text, type, workspace = 'local' } = this.queryParams;
const filter = {
...(query ? { ...query } : {}),
...(text ? { text } : {}),
...(type ? { type } : {}),
...(workspace ? { workspace } : {}),
};
if (sort && Object.keys(sort).length > 1) {
return API.v1.failure('This method support only one "sort" parameter');
}
const sortBy = sort ? Object.keys(sort)[0] : undefined;
const sortDirection = sort && Object.values(sort)[0] === 1 ? 'asc' : 'desc';
const user = await Users.findOneById(this.userId, { projection: { __rooms: 1, roles: 1 } });
const result = await browseChannelsMethod(
{
...filter,
sortBy,
sortDirection,
offset: Math.max(0, offset),
limit: Math.max(0, count),
},
user,
);
if (!result) {
return API.v1.failure('Please verify the parameters');
}
return API.v1.success({
result: result.results as (IDirectoryUserResult | IDirectoryChannelResult)[],
count: result.results.length,
offset,
total: result.total,
});
},
);
const pwGetPolicyResponseSchema = ajv.compile<{ enabled: boolean; policy: [string, Record<string, number | boolean>?][] }>({
type: 'object',
properties: {
enabled: { type: 'boolean' },
policy: { type: 'array', items: { type: 'array' } },
},
additionalProperties: true,
});
API.v1.get(
'pw.getPolicy',
{
authRequired: false,
response: {
200: pwGetPolicyResponseSchema,
},
},
function action() {
return API.v1.success(passwordPolicy.getPasswordPolicy());
},
);
/**
* @openapi
* /api/v1/stdout.queue:
* get:
* description: Retrieves last 1000 lines of server logs
* security:
* - authenticated: ['view-logs']
* responses:
* 200:
* description: The user data of the authenticated user
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiSuccessV1'
* - type: object
* properties:
* queue:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* string:
* type: string
* ts:
* type: string
* format: date-time
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'method.call/:method': {
POST: (params: { method: string; args: any[] }) => any;
};
'method.callAnon/:method': {
POST: (params: { method: string; args: any[] }) => any;
};
}
}
const methodCallResponseSchema = ajv.compile<{ message: string }>({
type: 'object',
properties: { message: { type: 'string' }, success: { type: 'boolean', enum: [true] } },
required: ['message'],
additionalProperties: false,
});
const methodCallErrorResponseSchema = ajv.compile<{ message: string }>({
type: 'object',
oneOf: [
{
properties: {
message: { type: 'string' },
success: { type: 'boolean', enum: [false] },
},
required: ['message', 'success'],
additionalProperties: false,
},
{
properties: {
success: { type: 'boolean', enum: [false] },
error: { type: 'string' },
errorType: { type: 'string' },
stack: { type: 'string' },
details: { anyOf: [{ type: 'string' }, { type: 'object' }] },
},
required: ['success'],
additionalProperties: false,
},
],
});
const mountResult = ({
id,
error,
result,
}: {
id: string;
error?: unknown;
result?: unknown;
}): {
message: string;
} => ({
message: EJSON.stringify({
msg: 'result',
id,
error: error as any,
result: result as any,
}),
});
// had to create two different endpoints for authenticated and non-authenticated calls
// because restivus does not provide 'this.userId' if 'authRequired: false'
API.v1.post(
'method.call/:method',
{
authRequired: true,
userWithoutUsername: true,
rateLimiterOptions: false,
body: isMeteorCall,
applyMeteorContext: true,
response: {
200: methodCallResponseSchema,
400: methodCallErrorResponseSchema,
401: validateUnauthorizedErrorResponse,
429: ajv.compile({
type: 'object',
properties: { success: { type: 'boolean', enum: [false] }, error: { type: 'string' } },
required: ['success'],
additionalProperties: true,
}),
},
},
async function action() {
check(this.bodyParams, {
message: String,
});
const data = EJSON.parse(this.bodyParams.message);
const { method, params, id } = data;
const connectionId =
this.token ||
crypto
.createHash('sha256')
.update((this.requestIp ?? '') + this.user._id)
.digest('hex');
const rateLimiterInput = {
userId: this.userId,
clientAddress: this.requestIp,
type: 'method',
name: method,
connectionId,
};
try {
DDPRateLimiter._increment(rateLimiterInput);
const rateLimitResult = DDPRateLimiter._check(rateLimiterInput);
if (!rateLimitResult.allowed) {
throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), {
timeToReset: rateLimitResult.timeToReset,
});
}
return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) }));
} catch (err) {
logMethodCallError(method, err);
return API.v1.failure(mountResult({ id, error: err }));
}
},
);
API.v1.post(
'method.callAnon/:method',
{
authRequired: false,
userWithoutUsername: true,
rateLimiterOptions: false,
body: isMeteorCall,
applyMeteorContext: true,
response: {
200: methodCallResponseSchema,
400: methodCallErrorResponseSchema,
},
},
async function action() {
check(this.bodyParams, {
message: String,
});
const data = EJSON.parse(this.bodyParams.message);
const { method, params, id } = data;
const connectionId =
this.token ||
crypto
.createHash('sha256')
.update(this.requestIp ?? '')
.digest('hex');
const rateLimiterInput = {
userId: this.userId || undefined,
clientAddress: this.requestIp,
type: 'method',
name: method,
connectionId,
};
try {
DDPRateLimiter._increment(rateLimiterInput);
const rateLimitResult = DDPRateLimiter._check(rateLimiterInput);
if (!rateLimitResult.allowed) {
throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), {
timeToReset: rateLimitResult.timeToReset,
});
}
return API.v1.success(mountResult({ id, result: await Meteor.callAsync(method, ...params) }));
} catch (err) {
logMethodCallError(method, err);
return API.v1.failure(mountResult({ id, error: err }));
}
},
);
const smtpCheckResponseSchema = ajv.compile<{ isSMTPConfigured: boolean }>({
type: 'object',
properties: {
isSMTPConfigured: { type: 'boolean' },
success: { type: 'boolean', enum: [true] },
},
required: ['isSMTPConfigured', 'success'],
additionalProperties: false,
});
API.v1.get(
'smtp.check',
{
authRequired: true,
response: {
200: smtpCheckResponseSchema,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
return API.v1.success({ isSMTPConfigured: isSMTPConfigured() });
},
);
const fingerprintResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
/**
* @openapi
* /api/v1/fingerprint:
* post:
* description: Update Fingerprint definition as a new workspace or update of configuration
* security:
* $ref: '#/security/authenticated'
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* setDeploymentAs:
* type: string
* example: |
* {
* "setDeploymentAs": "new-workspace"
* }
* responses:
* 200:
* description: Workspace successfully configured
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiSuccessV1'
* default:
* description: Unexpected error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiFailureV1'
*/
API.v1.post(
'fingerprint',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
body: isFingerprintProps,
response: {
200: fingerprintResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
400: validateBadRequestErrorResponse,
},
},
async function action() {
check(this.bodyParams, {
setDeploymentAs: String,
});
const settingsIds: string[] = [];
if (this.bodyParams.setDeploymentAs === 'new-workspace') {
await WorkspaceCredentials.removeAllCredentials();
settingsIds.push(
'Cloud_Service_Agree_PrivacyTerms',
'Cloud_Workspace_Id',
'Cloud_Workspace_Name',
'Cloud_Workspace_Client_Id',
'Cloud_Workspace_Client_Secret',
'Cloud_Workspace_Client_Secret_Expires_At',
'Cloud_Workspace_Registration_Client_Uri',
'Cloud_Workspace_PublicKey',
'Cloud_Workspace_License',
'Cloud_Workspace_Had_Trial',
'uniqueID',
);
}
settingsIds.push('Deployment_FingerPrint_Verified');
const auditSettingOperation = updateAuditedByUser({
_id: this.userId,
username: this.user.username ?? '',
ip: this.requestIp ?? '',
useragent: this.request.headers.get('user-agent') ?? '',
});
const promises = settingsIds.map((settingId) => {
if (settingId === 'uniqueID') {
return auditSettingOperation(Settings.resetValueById, 'uniqueID', process.env.DEPLOYMENT_ID || crypto.randomUUID());
}
if (settingId === 'Cloud_Workspace_Access_Token_Expires_At') {
return auditSettingOperation(Settings.resetValueById, 'Cloud_Workspace_Access_Token_Expires_At', new Date(0));
}
if (settingId === 'Deployment_FingerPrint_Verified') {
return auditSettingOperation(Settings.updateValueById, 'Deployment_FingerPrint_Verified', true);
}
return resetAuditedSettingByUser({
_id: this.userId,
username: this.user.username ?? '',
ip: this.requestIp ?? '',
useragent: this.request.headers.get('user-agent') ?? '',
})(Settings.resetValueById, settingId);
});
(await Promise.all(promises)).forEach((value, index) => {
if (value?.modifiedCount) {
void notifyOnSettingChangedById(settingsIds[index]);
}
});
return API.v1.success();
},
);

View File

@@ -0,0 +1,654 @@
import type { IModerationAudit, IModerationReport, IUser, IUserEmail, UserReport } from '@rocket.chat/core-typings';
import { ModerationReports, Users } from '@rocket.chat/models';
import {
ajv,
isReportHistoryProps,
isArchiveReportProps,
isReportInfoParams,
isGetUserReportsParams,
isModerationReportUserPost,
isModerationDeleteMsgHistoryParams,
isReportsByMsgIdParams,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { deleteReportedMessages } from '../../lib/moderation/deleteReportedMessages';
import { API } from '../api';
import { getPaginationItems } from '../lib/getPaginationItems';
type ReportMessage = Pick<IModerationReport, '_id' | 'message' | 'ts' | 'room'>;
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.
const paginatedReportsResponseSchema = ajv.compile<{ reports: IModerationAudit[]; count: number; offset: number; total: number }>({
type: 'object',
properties: {
reports: {
type: 'array',
items: {
type: 'object',
properties: {
userId: { type: 'string' },
username: { type: 'string' },
name: { type: 'string' },
message: { type: 'string' },
msgId: { type: 'string' },
ts: { type: 'string' },
rooms: { type: 'array', items: { type: 'object' } },
roomIds: { type: 'array', items: { type: 'string' } },
count: { type: 'number' },
isUserDeleted: { type: 'boolean' },
},
required: ['userId', 'ts', 'rooms', 'roomIds', 'count', 'isUserDeleted'],
additionalProperties: false,
},
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['reports', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.
const paginatedUserReportsResponseSchema = ajv.compile<{
reports: (Pick<UserReport, '_id' | 'reportedUser' | 'ts'> & { count: number })[];
count: number;
offset: number;
total: number;
}>({
type: 'object',
properties: {
reports: {
type: 'array',
items: {
type: 'object',
properties: {
reportedUser: { type: 'object' },
ts: { type: 'string' },
count: { type: 'number' },
},
required: ['reportedUser', 'ts', 'count'],
additionalProperties: false,
},
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['reports', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
const reportedMessagesResponseSchema = ajv.compile<{
user: Pick<IUser, 'username' | 'name' | '_id'> | null;
messages: Pick<IModerationReport, '_id' | 'message' | 'ts' | 'room'>[];
count: number;
total: number;
offset: number;
}>({
type: 'object',
properties: {
user: {
oneOf: [
{
type: 'object',
properties: {
_id: { type: 'string' },
username: { type: 'string' },
name: { type: 'string' },
},
required: ['_id', 'username'],
additionalProperties: false,
},
{ type: 'null' },
],
},
messages: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
total: { type: 'number' },
offset: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['user', 'messages', 'count', 'total', 'offset', 'success'],
additionalProperties: false,
});
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.
const reportsByUserIdResponseSchema = ajv.compile<{
user: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag' | 'active' | 'roles' | 'emails' | 'createdAt'> | null;
reports: IModerationReport[];
count: number;
total: number;
offset: number;
}>({
type: 'object',
properties: {
user: {
oneOf: [
{
type: 'object',
properties: {
_id: { type: 'string' },
username: { type: 'string' },
name: { type: 'string' },
avatarETag: { type: 'string' },
active: { type: 'boolean' },
roles: { type: 'array', items: { type: 'string' } },
emails: { type: 'array', items: { type: 'object' } },
createdAt: { type: 'string' },
},
required: ['_id', 'username'],
additionalProperties: false,
},
{ type: 'null' },
],
},
reports: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
total: { type: 'number' },
offset: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['user', 'reports', 'count', 'total', 'offset', 'success'],
additionalProperties: false,
});
const successResponseSchema = ajv.compile<void>({
type: 'object',
properties: { success: { type: 'boolean', enum: [true] } },
required: ['success'],
additionalProperties: false,
});
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.
const reportInfoResponseSchema = ajv.compile<{ report: IModerationReport | null }>({
type: 'object',
properties: {
report: { type: ['object', 'null'] },
success: { type: 'boolean', enum: [true] },
},
required: ['report', 'success'],
additionalProperties: false,
});
API.v1.get(
'moderation.reportsByUsers',
{
authRequired: true,
permissionsRequired: ['view-moderation-console'],
query: isReportHistoryProps,
response: {
200: paginatedReportsResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams;
const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const latest = _latest ? new Date(_latest) : new Date();
const oldest = _oldest ? new Date(_oldest) : new Date(0);
const escapedSelector = escapeRegExp(selector);
const reports = await ModerationReports.findMessageReportsGroupedByUser(latest, oldest, escapedSelector, {
offset,
count,
sort,
}).toArray();
if (reports.length === 0) {
return API.v1.success({
reports,
count: 0,
offset,
total: 0,
});
}
const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true);
return API.v1.success({
reports,
count: reports.length,
offset,
total,
});
},
);
API.v1.get(
'moderation.userReports',
{
authRequired: true,
permissionsRequired: ['view-moderation-console'],
query: isReportHistoryProps,
response: {
200: paginatedUserReportsResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams;
const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const latest = _latest ? new Date(_latest) : new Date();
const oldest = _oldest ? new Date(_oldest) : new Date(0);
const escapedSelector = escapeRegExp(selector);
const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, {
offset,
count,
sort,
}).toArray();
if (reports.length === 0) {
return API.v1.success({
reports,
count: 0,
offset,
total: 0,
});
}
const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector);
return API.v1.success({
reports,
count: reports.length,
offset,
total,
});
},
);
API.v1.get(
'moderation.user.reportedMessages',
{
authRequired: true,
permissionsRequired: ['view-moderation-console'],
query: isGetUserReportsParams,
response: {
200: reportedMessagesResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { userId, selector = '' } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
const user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name'>>(userId, {
projection: { _id: 1, username: 1, name: 1 },
});
const escapedSelector = escapeRegExp(selector);
const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, escapedSelector, {
offset,
count,
sort,
});
const [reports, total] = await Promise.all([cursor.toArray(), totalCount]);
const uniqueMessages: ReportMessage[] = [];
const visited = new Set<string>();
for (const report of reports) {
if (visited.has(report.message._id)) {
continue;
}
visited.add(report.message._id);
uniqueMessages.push(report);
}
return API.v1.success({
user,
messages: uniqueMessages,
count: reports.length,
total,
offset,
});
},
);
API.v1.get(
'moderation.user.reportsByUserId',
{
authRequired: true,
permissionsRequired: ['view-moderation-console'],
query: isGetUserReportsParams,
response: {
200: reportsByUserIdResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { userId, selector = '' } = this.queryParams;
const { sort } = await this.parseJsonQuery();
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
const user = await Users.findOneById<IUser>(userId, {
projection: {
_id: 1,
username: 1,
name: 1,
avatarETag: 1,
active: 1,
roles: 1,
emails: 1,
createdAt: 1,
},
});
const escapedSelector = escapeRegExp(selector);
const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, {
offset,
count,
sort,
});
const [reports, total] = await Promise.all([cursor.toArray(), totalCount]);
const emailSet = new Map<IUserEmail['address'], IUserEmail>();
reports.forEach((report) => {
const email = report.reportedUser?.emails?.[0];
if (email) {
emailSet.set(email.address, email);
}
});
if (user) {
user.emails = Array.from(emailSet.values());
}
return API.v1.success({
user,
reports,
count: reports.length,
total,
offset,
});
},
);
API.v1.post(
'moderation.user.deleteReportedMessages',
{
authRequired: true,
permissionsRequired: ['manage-moderation-actions'],
body: isModerationDeleteMsgHistoryParams,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { userId, reason } = this.bodyParams;
const sanitizedReason = reason?.trim() ? reason : 'No reason provided';
const { user: moderator } = this;
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
const { cursor, totalCount } = ModerationReports.findReportedMessagesByReportedUserId(userId, '', {
offset,
count,
sort: { ts: -1 },
});
const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
if (total === 0) {
return API.v1.failure('No reported messages found for this user.');
}
await deleteReportedMessages(
messages.map((message) => message.message),
moderator,
);
await ModerationReports.hideMessageReportsByUserId(userId, this.userId, sanitizedReason, 'DELETE Messages');
return API.v1.success();
},
);
API.v1.post(
'moderation.dismissReports',
{
authRequired: true,
permissionsRequired: ['manage-moderation-actions'],
body: isArchiveReportProps,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { userId, msgId, reason, action: actionParam } = this.bodyParams;
if (userId) {
const report = await ModerationReports.findOne({ 'message.u._id': userId, '_hidden': { $ne: true } }, { projection: { _id: 1 } });
if (!report) {
return API.v1.failure('no-reports-found');
}
}
if (msgId) {
const report = await ModerationReports.findOne({ 'message._id': msgId, '_hidden': { $ne: true } }, { projection: { _id: 1 } });
if (!report) {
return API.v1.failure('no-reports-found');
}
}
const sanitizedReason: string = reason?.trim() ? reason : 'No reason provided';
const action: string = actionParam ?? 'None';
const { userId: moderatorId } = this;
if (userId) {
await ModerationReports.hideMessageReportsByUserId(userId, moderatorId, sanitizedReason, action);
} else {
await ModerationReports.hideMessageReportsByMessageId(msgId as string, moderatorId, sanitizedReason, action);
}
return API.v1.success();
},
);
API.v1.post(
'moderation.dismissUserReports',
{
authRequired: true,
permissionsRequired: ['manage-moderation-actions'],
body: isArchiveReportProps,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { userId, reason, action: actionParam } = this.bodyParams;
if (!userId) {
return API.v1.failure('error-user-id-param-not-provided');
}
const sanitizedReason: string = reason ?? 'No reason provided';
const action: string = actionParam ?? 'None';
const { userId: moderatorId } = this;
await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action);
return API.v1.success();
},
);
// TODO: IModerationAudit defines `ts` as `Date | string` which generates a oneOf in JSON Schema.
// When the aggregation returns `ts` as an ISO date string, it matches both `Date` (format: "date-time")
// and `string` schemas simultaneously, causing AJV oneOf validation to fail with "passingSchemas: 0,1".
// Until the core-typings type is revised (either narrowing `ts` to `string` to match what MongoDB
// aggregation actually returns, or adjusting the AJV schema generation for union types), we use a
// relaxed inline schema here that accepts `ts` as a string.
const reportsByMsgIdResponseSchema = ajv.compile<{
reports: Pick<IModerationReport, '_id' | 'description' | 'reportedBy' | 'ts' | 'room'>[];
count: number;
offset: number;
total: number;
}>({
type: 'object',
properties: {
reports: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['reports', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
});
API.v1.get(
'moderation.reports',
{
authRequired: true,
permissionsRequired: ['view-moderation-console'],
query: isReportsByMsgIdParams,
response: {
200: reportsByMsgIdResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { msgId } = this.queryParams;
const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { selector = '' } = this.queryParams;
const escapedSelector = escapeRegExp(selector);
const { cursor, totalCount } = ModerationReports.findReportsByMessageId(msgId, escapedSelector, { count, sort, offset });
const [reports, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
reports,
count: reports.length,
offset,
total,
});
},
);
API.v1.get(
'moderation.reportInfo',
{
authRequired: true,
permissionsRequired: ['view-moderation-console'],
query: isReportInfoParams,
response: {
200: reportInfoResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { reportId } = this.queryParams;
const report = await ModerationReports.findOneById(reportId);
if (!report) {
return API.v1.failure('error-report-not-found');
}
return API.v1.success({ report });
},
);
API.v1.post(
'moderation.reportUser',
{
authRequired: true,
body: isModerationReportUserPost,
response: {
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { userId, description } = this.bodyParams;
const {
user: { _id, name, username, createdAt },
} = this;
const reportedUser = await Users.findOneById(userId, {
projection: { _id: 1, name: 1, username: 1, emails: 1, createdAt: 1 },
});
if (!reportedUser) {
return API.v1.failure('Invalid user id provided.');
}
await ModerationReports.createWithDescriptionAndUser(reportedUser, description, { _id, name, username, createdAt });
return API.v1.success();
},
);

View File

@@ -0,0 +1,296 @@
import type { IOAuthApps } from '@rocket.chat/core-typings';
import { OAuthApps } from '@rocket.chat/models';
import {
ajv,
ajvQuery,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { addOAuthApp } from '../../lib/auth/oauth2-server/addOAuthApp';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { deleteOAuthApp } from '../../meteor-methods/auth/deleteOAuthApp';
import { updateOAuthApp } from '../../meteor-methods/auth/updateOAuthApp';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
type DeleteOAuthAppParams = {
appId: string;
};
const DeleteOAuthAppParamsSchema = {
type: 'object',
properties: {
appId: {
type: 'string',
},
},
required: ['appId'],
additionalProperties: false,
};
const isDeleteOAuthAppParams = ajv.compile<DeleteOAuthAppParams>(DeleteOAuthAppParamsSchema);
export type OauthAppsAddParams = {
name: string;
active: boolean;
redirectUri: string;
};
const OauthAppsAddParamsSchema = {
type: 'object',
properties: {
name: {
type: 'string',
},
active: {
type: 'boolean',
},
redirectUri: {
type: 'string',
},
},
required: ['name', 'active', 'redirectUri'],
additionalProperties: false,
};
const isOauthAppsAddParams = ajv.compile<OauthAppsAddParams>(OauthAppsAddParamsSchema);
type UpdateOAuthAppParams = {
appId: string;
name: string;
active: boolean;
clientId?: string | undefined;
clientSecret?: string | undefined;
redirectUri: string;
};
const UpdateOAuthAppParamsSchema = {
type: 'object',
properties: {
appId: {
type: 'string',
},
name: {
type: 'string',
},
active: {
type: 'boolean',
},
redirectUri: {
type: 'string',
},
},
required: ['appId', 'name', 'active', 'redirectUri'],
additionalProperties: false,
};
const isUpdateOAuthAppParams = ajv.compile<UpdateOAuthAppParams>(UpdateOAuthAppParamsSchema);
type OauthAppsGetParams = { clientId: string } | { _id: string };
const oauthAppsGetParamsSchema = {
oneOf: [
{
type: 'object',
properties: {
_id: {
type: 'string',
},
},
required: ['_id'],
additionalProperties: false,
},
{
type: 'object',
properties: {
clientId: {
type: 'string',
},
},
required: ['clientId'],
additionalProperties: false,
},
],
};
const isOauthAppsGetParams = ajvQuery.compile<OauthAppsGetParams>(oauthAppsGetParamsSchema);
const oauthAppsEndpoints = API.v1
.get(
'oauth-apps.list',
{
authRequired: true,
query: ajvQuery.compile<{ uid?: string }>({
type: 'object',
properties: {
uid: {
type: 'string',
},
},
additionalProperties: false,
}),
permissionsRequired: ['manage-oauth-apps'],
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<{ oauthApps: IOAuthApps[] }>({
type: 'object',
properties: {
oauthApps: {
type: 'array',
items: {
$ref: '#/components/schemas/IOAuthApps',
},
},
success: {
type: 'boolean',
enum: [true],
},
},
required: ['oauthApps', 'success'],
additionalProperties: false,
}),
},
},
async function action() {
return API.v1.success({
oauthApps: await OAuthApps.find().toArray(),
});
},
)
.post(
'oauth-apps.delete',
{
authRequired: true,
body: isDeleteOAuthAppParams,
permissionsRequired: ['manage-oauth-apps'],
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<boolean>({ type: 'boolean' }),
},
},
async function action() {
const { appId } = this.bodyParams;
const result = await deleteOAuthApp(this.userId, appId);
return API.v1.success(result);
},
)
.post(
'oauth-apps.create',
{
authRequired: true,
body: isOauthAppsAddParams,
permissionsRequired: ['manage-oauth-apps'],
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<{ application: IOAuthApps }>({
type: 'object',
properties: {
application: { $ref: '#/components/schemas/IOAuthApps' },
success: {
type: 'boolean',
enum: [true],
},
},
required: ['application', 'success'],
additionalProperties: false,
}),
},
},
async function action() {
const application = await addOAuthApp(this.bodyParams, this.userId);
return API.v1.success({ application });
},
)
.post(
'oauth-apps.update',
{
authRequired: true,
body: isUpdateOAuthAppParams,
permissionsRequired: ['manage-oauth-apps'],
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
200: ajv.compile<IOAuthApps | null>({
allOf: [
{ anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] },
{
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
},
],
}),
},
},
async function action() {
const { appId } = this.bodyParams;
const result = await updateOAuthApp(this.userId, appId, this.bodyParams);
return API.v1.success(result);
},
)
.get(
'oauth-apps.get',
{
authRequired: true,
query: isOauthAppsGetParams,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
200: ajv.compile<{ oauthApp: IOAuthApps }>({
type: 'object',
properties: {
oauthApp: { anyOf: [{ $ref: '#/components/schemas/IOAuthApps' }, { type: 'null' }] },
success: {
type: 'boolean',
enum: [true],
},
},
required: ['oauthApp', 'success'],
additionalProperties: false,
}),
},
},
async function action() {
const isOAuthAppsManager = await hasPermissionAsync(this.user, 'manage-oauth-apps');
const oauthApp = await OAuthApps.findOneAuthAppByIdOrClientId(
this.queryParams,
!isOAuthAppsManager ? { projection: { clientSecret: 0 } } : {},
);
if (!oauthApp) {
return API.v1.failure('OAuth app not found.');
}
return API.v1.success({
oauthApp,
});
},
);
export type OauthAppsEndpoints = ExtractRoutesFromAPI<typeof oauthAppsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends OauthAppsEndpoints {}
}

View File

@@ -0,0 +1,170 @@
import type { ILivechatAgent } from '@rocket.chat/core-typings';
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import {
isGETAgentNextToken,
isPOSTLivechatAgentSaveInfoParams,
isPOSTLivechatAgentStatusProps,
POSTLivechatAgentSaveInfoSuccessResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { API } from '../..';
import { findRoom, findGuest, findAgent, findOpenRoom } from './lib/livechat';
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { hasRoleAsync } from '../../../lib/authorization/hasRole';
import { RoutingManager } from '../../../lib/omnichannel/RoutingManager';
import { getRequiredDepartment } from '../../../lib/omnichannel/departmentsLib';
import { saveAgentInfo } from '../../../lib/omnichannel/omni-users';
import { setUserStatusLivechat, allowAgentChangeServiceStatus } from '../../../lib/omnichannel/utils';
import type { ExtractRoutesFromAPI } from '../../ApiClass';
API.v1.addRoute('livechat/agent.info/:rid/:token', {
async get() {
const visitor = await findGuest(this.urlParams.token);
if (!visitor) {
throw new Error('invalid-token');
}
const room = await findRoom(this.urlParams.token, this.urlParams.rid);
if (!room) {
throw new Error('invalid-room');
}
const agent = room?.servedBy && (await findAgent(room.servedBy._id));
if (!agent) {
throw new Error('invalid-agent');
}
return API.v1.success({ agent });
},
});
API.v1.addRoute(
'livechat/agent.next/:token',
{ validateParams: isGETAgentNextToken },
{
async get() {
const { token } = this.urlParams;
const room = await findOpenRoom(token, undefined, this.userId);
if (room) {
return API.v1.success();
}
let { department } = this.queryParams;
if (!department) {
const requireDepartment = await getRequiredDepartment();
if (requireDepartment) {
department = requireDepartment._id;
}
}
const agentData = await RoutingManager.getNextAgent(department);
if (!agentData) {
throw new Error('agent-not-found');
}
const agent = await findAgent(agentData.agentId);
if (!agent) {
throw new Error('invalid-agent');
}
return API.v1.success({ agent });
},
},
);
API.v1.addRoute(
'livechat/agent.status',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatAgentStatusProps },
{
async post() {
const { status, agentId: inputAgentId } = this.bodyParams;
const agentId = inputAgentId || this.userId;
const agent = await Users.findOneAgentById<Pick<ILivechatAgent, 'status' | 'statusLivechat' | 'active'>>(agentId, {
projection: {
status: 1,
statusLivechat: 1,
active: 1,
},
});
if (!agent) {
return API.v1.notFound('Agent not found');
}
if (!agent.active) {
return API.v1.failure('error-user-deactivated');
}
const newStatus: ILivechatAgentStatus =
status ||
(agent.statusLivechat === ILivechatAgentStatus.AVAILABLE ? ILivechatAgentStatus.NOT_AVAILABLE : ILivechatAgentStatus.AVAILABLE);
if (newStatus === agent.statusLivechat) {
return API.v1.success({ status: agent.statusLivechat });
}
const canChangeStatus = await allowAgentChangeServiceStatus(newStatus, agentId);
if (agentId !== this.userId) {
if (!(await hasPermissionAsync(this.user, 'manage-livechat-agents'))) {
return API.v1.forbidden();
}
// Silent fail for admins when BH is closed
// Next version we'll update this to return an error
// And update the FE accordingly
if (canChangeStatus) {
await setUserStatusLivechat(agentId, newStatus);
return API.v1.success({ status: newStatus });
}
return API.v1.success({ status: agent.statusLivechat });
}
if (!canChangeStatus) {
return API.v1.failure('error-business-hours-are-closed');
}
await setUserStatusLivechat(agentId, newStatus);
return API.v1.success({ status: newStatus });
},
},
);
const livechatAgentsEndpoints = API.v1.post(
'livechat/agents.saveInfo',
{
response: {
200: POSTLivechatAgentSaveInfoSuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['manage-livechat-agents'],
body: isPOSTLivechatAgentSaveInfoParams,
},
async function action() {
const { agentId, agentData, agentDepartments } = this.bodyParams;
if (!(await hasRoleAsync(agentId, 'livechat-agent'))) {
return API.v1.failure('error-user-is-not-agent');
}
await saveAgentInfo(agentId, agentData, agentDepartments);
return API.v1.success();
},
);
type LivechatAgentsEndpoints = ExtractRoutesFromAPI<typeof livechatAgentsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatAgentsEndpoints {}
}

View File

@@ -0,0 +1,19 @@
import { isGETLivechatAgentsAgentIdDepartmentsParams } from '@rocket.chat/rest-typings';
import { API } from '../..';
import { findAgentDepartments } from './lib/agents';
API.v1.addRoute(
'livechat/agents/:agentId/departments',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatAgentsAgentIdDepartmentsParams },
{
async get() {
const departments = await findAgentDepartments({
enabledDepartmentsOnly: this.queryParams.enabledDepartmentsOnly && this.queryParams.enabledDepartmentsOnly === 'true',
agentId: this.urlParams.agentId,
});
return API.v1.success(departments);
},
},
);

View File

@@ -0,0 +1,140 @@
import type { ISettingSelectOption } from '@rocket.chat/core-typings';
import { Settings } from '@rocket.chat/models';
import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings';
import { isTruthy } from '@rocket.chat/tools';
import { API } from '../..';
import { findAppearance } from './lib/appearance';
import { notifyOnSettingChangedById } from '../../../lib/notifyListener';
import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates';
API.v1.addRoute(
'livechat/appearance',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: {
POST: isPOSTLivechatAppearanceParams,
},
},
{
async get() {
const { appearance } = await findAppearance();
return API.v1.success({
appearance,
});
},
async post() {
const settings = this.bodyParams;
const validSettingList = [
'Livechat_title',
'Livechat_title_color',
'Livechat_enable_message_character_limit',
'Livechat_message_character_limit',
'Livechat_show_agent_info',
'Livechat_show_agent_email',
'Livechat_display_offline_form',
'Livechat_offline_form_unavailable',
'Livechat_offline_message',
'Livechat_offline_success_message',
'Livechat_offline_title',
'Livechat_offline_title_color',
'Livechat_offline_email',
'Livechat_conversation_finished_message',
'Livechat_conversation_finished_text',
'Livechat_registration_form',
'Livechat_name_field_registration_form',
'Livechat_email_field_registration_form',
'Livechat_registration_form_message',
'Livechat_hide_watermark',
'Livechat_background',
'Livechat_widget_position',
'Livechat_hide_system_messages',
'Omnichannel_allow_visitors_to_close_conversation',
'Livechat_hide_expand_chat',
];
const valid = settings.every((setting) => validSettingList.includes(setting._id));
if (!valid) {
throw new Error('invalid-setting');
}
const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1, values: 1 } })
.map((dbSetting) => {
const setting = settings.find(({ _id }) => _id === dbSetting._id);
if (!setting || dbSetting.value === setting.value) {
return;
}
if (dbSetting.type === 'multiSelect' && (!Array.isArray(setting.value) || !validateValues(setting.value, dbSetting.values))) {
return;
}
switch (dbSetting?.type) {
case 'boolean':
return {
_id: dbSetting._id,
value: setting.value === 'true' || setting.value === true,
};
case 'int':
return {
_id: dbSetting._id,
value: coerceInt(setting.value),
};
default:
return {
_id: dbSetting._id,
value: setting?.value,
};
}
})
.toArray();
const eligibleSettings = dbSettings.filter(isTruthy);
const auditSettingOperation = updateAuditedByUser({
_id: this.userId,
username: this.user.username,
ip: this.requestIp,
useragent: this.request.headers.get('user-agent') || '',
});
const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value));
(await Promise.all(promises)).forEach((value, index) => {
if (value?.modifiedCount) {
void notifyOnSettingChangedById(eligibleSettings[index]._id);
}
});
return API.v1.success();
},
},
);
function validateValues(values: string[], allowedValues: ISettingSelectOption[] = []): boolean {
return values.every((value) => allowedValues.some((allowedValue) => allowedValue.key === value));
}
function coerceInt(value: string | number | boolean | string[]): number {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'boolean') {
return 0;
}
if (Array.isArray(value)) {
return 0;
}
const parsedValue = parseInt(value, 10);
if (Number.isNaN(parsedValue)) {
return 0;
}
return parsedValue;
}

View File

@@ -0,0 +1,77 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import {
isGETBusinessHourParams,
isPOSTLivechatBusinessHoursSaveParams,
isPOSTLivechatBusinessHoursRemoveParams,
POSTLivechatBusinessHoursRemoveSuccessResponse,
POSTLivechatBusinessHoursSaveSuccessResponse,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { API } from '../..';
import { businessHourManager } from '../../../lib/omnichannel/business-hour';
import type { ExtractRoutesFromAPI } from '../../ApiClass';
import { findLivechatBusinessHour } from './lib/businessHours';
API.v1.addRoute(
'livechat/business-hour',
{ authRequired: true, permissionsRequired: ['view-livechat-business-hours'], validateParams: isGETBusinessHourParams },
{
async get() {
const { _id, type } = this.queryParams;
const { businessHour } = await findLivechatBusinessHour(_id, type);
return API.v1.success({
businessHour,
});
},
},
);
const livechatBusinessHoursEndpoints = API.v1
.post(
'livechat/business-hours.save',
{
response: {
200: POSTLivechatBusinessHoursSaveSuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
authRequired: true,
body: isPOSTLivechatBusinessHoursSaveParams,
},
async function action() {
const params = this.bodyParams;
// TODO: Remove typecasting after refactoring saveBusinessHour logic with proper type logic. See: CORE-1552
const result = await businessHourManager.saveBusinessHour(params as unknown as ILivechatBusinessHour);
return API.v1.success(result);
},
)
.post(
'livechat/business-hours.remove',
{
response: {
200: POSTLivechatBusinessHoursRemoveSuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
authRequired: true,
body: isPOSTLivechatBusinessHoursRemoveParams,
},
async function action() {
const { _id, type } = this.bodyParams;
await businessHourManager.removeBusinessHourByIdAndType(_id, type);
return API.v1.success();
},
);
type LivechatBusinessHoursEndpoints = ExtractRoutesFromAPI<typeof livechatBusinessHoursEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatBusinessHoursEndpoints {}
}

View File

@@ -0,0 +1,67 @@
import { GETLivechatConfigRouting, isGETLivechatConfigParams } from '@rocket.chat/rest-typings';
import mem from 'mem';
import { API } from '../..';
import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from './lib/livechat';
import { RoutingManager } from '../../../lib/omnichannel/RoutingManager';
import { online } from '../../../lib/omnichannel/service-status';
import { settings as serverSettings } from '../../../settings';
import type { ExtractRoutesFromAPI } from '../../ApiClass';
const cachedSettings = mem(settings, {
maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 1000,
cacheKey: JSON.stringify,
});
API.v1.addRoute(
'livechat/config',
{ validateParams: isGETLivechatConfigParams },
{
async get() {
const enabled = serverSettings.get<boolean>('Livechat_enabled');
if (!enabled) {
return API.v1.success({ config: { enabled: false } });
}
const { token, department, businessUnit } = this.queryParams;
const [config, status, guest] = await Promise.all([
cachedSettings({ businessUnit }),
online(department),
token ? findGuestWithoutActivity(token) : null,
]);
const room = guest ? await findOpenRoom(guest.token, undefined, this.userId) : undefined;
const agentPromise = room?.servedBy ? findAgent(room.servedBy._id) : null;
const extraInfoPromise = getExtraConfigInfo({ room });
const [agent, extraInfo] = await Promise.all([agentPromise, extraInfoPromise]);
return API.v1.success({
config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) },
});
},
},
);
const livechatConfigEndpoints = API.v1.get(
'livechat/config/routing',
{
response: {
200: GETLivechatConfigRouting,
},
authRequired: true,
},
async function action() {
const config = RoutingManager.getConfig();
return API.v1.success({ config });
},
);
type LivechatConfigEndpoints = ExtractRoutesFromAPI<typeof livechatConfigEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatConfigEndpoints {}
}

View File

@@ -0,0 +1,268 @@
import { LivechatContacts, LivechatCustomField, LivechatVisitors } from '@rocket.chat/models';
import {
isPOSTOmnichannelContactsProps,
isPOSTUpdateOmnichannelContactsProps,
isGETOmnichannelContactsProps,
isGETOmnichannelContactHistoryProps,
isGETOmnichannelContactsChannelsProps,
isGETOmnichannelContactsSearchProps,
isGETOmnichannelContactsCheckExistenceProps,
isPOSTOmnichannelContactsConflictsProps,
isPOSTOmnichannelContactDeleteProps,
POSTOmnichannelContactDeleteSuccessSchema,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp, removeEmpty } from '@rocket.chat/tools';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { API } from '../..';
import { createContact } from '../../../lib/omnichannel/contacts/createContact';
import { disableContactById } from '../../../lib/omnichannel/contacts/disableContact';
import { getContactChannelsGrouped } from '../../../lib/omnichannel/contacts/getContactChannelsGrouped';
import { getContactHistory } from '../../../lib/omnichannel/contacts/getContactHistory';
import { getContacts } from '../../../lib/omnichannel/contacts/getContacts';
import { registerContact } from '../../../lib/omnichannel/contacts/registerContact';
import { resolveContactConflicts } from '../../../lib/omnichannel/contacts/resolveContactConflicts';
import { updateContact } from '../../../lib/omnichannel/contacts/updateContact';
import type { ExtractRoutesFromAPI } from '../../ApiClass';
import { getPaginationItems } from '../../lib/getPaginationItems';
API.v1.addRoute(
'omnichannel/contact',
{
authRequired: true,
permissionsRequired: ['view-l-room'],
},
{
async post() {
check(this.bodyParams, {
_id: Match.Maybe(String),
token: String,
name: String,
email: Match.Maybe(String),
phone: Match.Maybe(String),
username: Match.Maybe(String),
customFields: Match.Maybe(Object),
contactManager: Match.Maybe({
username: String,
}),
});
const contact = await registerContact(this.bodyParams, this.userId);
return API.v1.success({ contact });
},
async get() {
check(this.queryParams, {
contactId: String,
});
const contact = await LivechatVisitors.findOneEnabledById(this.queryParams.contactId);
return API.v1.success({ contact });
},
},
);
API.v1.addRoute(
'omnichannel/contact.search',
{
authRequired: true,
permissionsRequired: ['view-l-room'],
},
{
async get() {
check(this.queryParams, {
email: Match.Maybe(String),
phone: Match.Maybe(String),
custom: Match.Maybe(String),
});
const { email, phone, custom } = this.queryParams;
let customCF: { [k: string]: string } = {};
try {
customCF = custom && JSON.parse(custom);
} catch (e) {
throw new Meteor.Error('error-invalid-params-custom');
}
if (!email && !phone && !Object.keys(customCF).length) {
throw new Meteor.Error('error-invalid-params');
}
const foundCF = await (async () => {
if (!custom) {
return {};
}
const cfIds = Object.keys(customCF);
const customFields = await LivechatCustomField.findMatchingCustomFieldsByIds(cfIds, 'visitor', true, {
projection: { _id: 1 },
}).toArray();
return Object.fromEntries(customFields.map(({ _id }) => [`livechatData.${_id}`, new RegExp(escapeRegExp(customCF[_id]), 'i')]));
})();
const contact = await LivechatVisitors.findOneByEmailAndPhoneAndCustomField(email, phone, foundCF);
return API.v1.success({ contact });
},
},
);
API.v1.addRoute(
'omnichannel/contacts',
{ authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps },
{
async post() {
const contactId = await createContact({ ...this.bodyParams, unknown: false });
return API.v1.success({ contactId });
},
},
);
API.v1.addRoute(
'omnichannel/contacts.update',
{ authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps },
{
async post() {
const contact = await updateContact(removeEmpty(this.bodyParams));
return API.v1.success({ contact });
},
},
);
API.v1.addRoute(
'omnichannel/contacts.conflicts',
{ authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTOmnichannelContactsConflictsProps },
{
async post() {
const result = await resolveContactConflicts(removeEmpty(this.bodyParams));
return API.v1.success({ result });
},
},
);
API.v1.addRoute(
'omnichannel/contacts.get',
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsProps },
{
async get() {
const { contactId } = this.queryParams;
if (!contactId) {
return API.v1.notFound();
}
const contact = await LivechatContacts.findOneEnabledById(contactId);
if (!contact) {
return API.v1.notFound();
}
return API.v1.success({ contact });
},
},
);
API.v1.addRoute(
'omnichannel/contacts.search',
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsSearchProps },
{
async get() {
const query = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const result = await getContacts({ ...query, offset, count, sort });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'omnichannel/contacts.checkExistence',
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsCheckExistenceProps },
{
async get() {
const { contactId, email, phone } = this.queryParams;
const contact = await LivechatContacts.countByContactInfo({ contactId, email, phone });
return API.v1.success({ exists: contact > 0 });
},
},
);
API.v1.addRoute(
'omnichannel/contacts.history',
{ authRequired: true, permissionsRequired: ['view-livechat-contact-history'], validateParams: isGETOmnichannelContactHistoryProps },
{
async get() {
const { contactId, source } = this.queryParams;
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const history = await getContactHistory({ contactId, source, count, offset, sort });
return API.v1.success(history);
},
},
);
API.v1.addRoute(
'omnichannel/contacts.channels',
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsChannelsProps },
{
async get() {
const { contactId } = this.queryParams;
const channels = await getContactChannelsGrouped(contactId);
return API.v1.success({ channels });
},
},
);
const omnichannelContactsEndpoints = API.v1.post(
'omnichannel/contacts.delete',
{
response: {
200: POSTOmnichannelContactDeleteSuccessSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['delete-livechat-contact'],
body: isPOSTOmnichannelContactDeleteProps,
},
async function action() {
const { contactId } = this.bodyParams;
try {
await disableContactById(contactId);
return API.v1.success();
} catch (error) {
if (!(error instanceof Error)) {
return API.v1.failure('error-invalid-contact');
}
return API.v1.failure(error.message);
}
},
);
type OmnichannelContactsEndpoints = ExtractRoutesFromAPI<typeof omnichannelContactsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends OmnichannelContactsEndpoints {}
}

View File

@@ -0,0 +1,166 @@
import { LivechatCustomField } from '@rocket.chat/models';
import {
isLivechatCustomFieldsProps,
isPOSTLivechatCustomFieldParams,
isPOSTLivechatCustomFieldsParams,
isPOSTLivechatRemoveCustomFields,
isPOSTLivechatSaveCustomFieldsParams,
POSTLivechatRemoveCustomFieldSuccess,
POSTLivechatSaveCustomFieldSuccess,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import { API } from '../..';
import { setCustomFields, setMultipleCustomFields } from '../../../lib/omnichannel/custom-fields';
import type { ExtractRoutesFromAPI } from '../../ApiClass';
import { findLivechatCustomFields, findCustomFieldById } from './lib/customFields';
import { findGuest } from './lib/livechat';
import { getPaginationItems } from '../../lib/getPaginationItems';
API.v1.addRoute(
'livechat/custom.field',
{ validateParams: isPOSTLivechatCustomFieldParams },
{
async post() {
const { token, key, value, overwrite } = this.bodyParams;
const guest = await findGuest(token);
if (!guest) {
throw new Error('invalid-token');
}
await setCustomFields({ token, key, value, overwrite });
return API.v1.success({ field: { key, value, overwrite } });
},
},
);
API.v1.addRoute(
'livechat/custom.fields',
{ validateParams: isPOSTLivechatCustomFieldsParams },
{
async post() {
const { token } = this.bodyParams;
const visitor = await findGuest(token);
if (!visitor) {
throw new Error('invalid-token');
}
const result = await setMultipleCustomFields({ visitor, customFields: this.bodyParams.customFields });
return API.v1.success({
fields: result.map(({ key, value, overwrite }) => ({ Key: key, value, overwrite })),
});
},
},
);
API.v1.addRoute(
'livechat/custom-fields',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatCustomFieldsProps },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text } = this.queryParams;
const customFields = await findLivechatCustomFields({
text,
pagination: {
offset,
count,
sort,
},
});
return API.v1.success(customFields);
},
},
);
API.v1.addRoute(
'livechat/custom-fields/:_id',
{ authRequired: true, permissionsRequired: ['view-l-room'] },
{
async get() {
const { customField } = await findCustomFieldById({ customFieldId: this.urlParams._id });
return API.v1.success({
customField,
});
},
},
);
const livechatCustomFieldsEndpoints = API.v1
.post(
'livechat/custom-fields.save',
{
response: {
200: POSTLivechatSaveCustomFieldSuccess,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action?
body: isPOSTLivechatSaveCustomFieldsParams,
},
async function action() {
const { customFieldId, customFieldData } = this.bodyParams;
if (customFieldId) {
const customField = await LivechatCustomField.findOneById(customFieldId);
if (!customField) {
return API.v1.failure('Custom Field Not found');
}
}
if (!customFieldId) {
const customField = await LivechatCustomField.findOneById(customFieldData.field);
if (customField) {
return API.v1.failure('Custom Field already exists');
}
}
const { field, label, scope, visibility, ...extraData } = customFieldData;
const result = await LivechatCustomField.createOrUpdateCustomField(customFieldId, field, label, scope, visibility, {
...extraData,
});
return API.v1.success({ customField: result });
},
)
.post(
'livechat/custom-fields.delete',
{
response: {
200: POSTLivechatRemoveCustomFieldSuccess,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action?
body: isPOSTLivechatRemoveCustomFields,
},
async function action() {
const { customFieldId } = this.bodyParams;
const result = await LivechatCustomField.removeById(customFieldId);
if (result.deletedCount === 0) {
return API.v1.failure('Custom field not found');
}
return API.v1.success();
},
);
type LivechatCustomFieldsEndpoints = ExtractRoutesFromAPI<typeof livechatCustomFieldsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatCustomFieldsEndpoints {}
}

View File

@@ -0,0 +1,296 @@
import { OmnichannelAnalytics } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import {
isGETDashboardTotalizerParams,
isGETDashboardsAgentStatusParams,
isGETLivechatAnalyticsDashboardsChartDataParams,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
GETLivechatAnalyticsDashboardsChartDataSuccessSchema,
} from '@rocket.chat/rest-typings';
import { API } from '../..';
import {
getProductivityMetricsAsyncCached,
getConversationsMetricsAsyncCached,
getAgentsProductivityMetricsAsyncCached,
getChatsMetricsAsyncCached,
findAllChatsStatusAsyncCached,
findAllChatMetricsByAgentAsyncCached,
findAllAgentsStatusAsyncCached,
findAllChatMetricsByDepartmentAsyncCached,
findAllResponseTimeMetricsAsyncCached,
} from '../../../lib/omnichannel/analytics/dashboards';
import type { ExtractRoutesFromAPI } from '../../ApiClass';
API.v1.addRoute(
'livechat/analytics/dashboards/conversation-totalizers',
{
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
validateParams: isGETDashboardTotalizerParams,
},
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
if (!user) {
return API.v1.failure('User not found');
}
const totalizers = await getConversationsMetricsAsyncCached({ start, end, departmentId, user });
return API.v1.success(totalizers);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/agents-productivity-totalizers',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
if (!user) {
return API.v1.failure('User not found');
}
const totalizers = await getAgentsProductivityMetricsAsyncCached({ start, end, departmentId, user });
return API.v1.success(totalizers);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/chats-totalizers',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const totalizers = await getChatsMetricsAsyncCached({ start: startDate, end: endDate, departmentId });
return API.v1.success(totalizers);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/productivity-totalizers',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
if (!user) {
return API.v1.failure('User not found');
}
const totalizers = await getProductivityMetricsAsyncCached({ start, end, departmentId, user });
return API.v1.success(totalizers);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/charts/chats',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const result = await findAllChatsStatusAsyncCached({ start: startDate, end: endDate, departmentId });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/charts/chats-per-agent',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const result = await findAllChatMetricsByAgentAsyncCached({ start: startDate, end: endDate, departmentId });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/charts/agents-status',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardsAgentStatusParams },
{
async get() {
const { departmentId } = this.queryParams;
const result = await findAllAgentsStatusAsyncCached({ departmentId });
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/charts/chats-per-department',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const result = (await findAllChatMetricsByDepartmentAsyncCached({ start: startDate, end: endDate, departmentId })) as {
[k: string]: { open: number; closed: number };
};
return API.v1.success(result);
},
},
);
API.v1.addRoute(
'livechat/analytics/dashboards/charts/timings',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
{
async get() {
const { start, end } = this.queryParams;
const { departmentId } = this.queryParams;
if (isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}
const startDate = new Date(start);
if (isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}
const endDate = new Date(end);
const result = await findAllResponseTimeMetricsAsyncCached({ start: startDate, end: endDate, departmentId });
return API.v1.success(result);
},
},
);
const livechatAnalyticsEndpoints = API.v1.get(
'livechat/analytics/dashboards/charts-data',
{
response: {
200: GETLivechatAnalyticsDashboardsChartDataSuccessSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['view-livechat-manager'],
query: isGETLivechatAnalyticsDashboardsChartDataParams,
},
async function action() {
const { chartName, start, end, departmentId } = this.queryParams;
const chartData = await OmnichannelAnalytics.getAnalyticsChartData({
daterange: {
from: start,
to: end,
},
chartOptions: {
name: chartName,
},
utcOffset: this.user.utcOffset,
executedBy: this.user._id,
departmentId,
});
if (!chartData) {
return API.v1.success({
chartLabel: chartName,
dataLabels: [],
dataPoints: [],
});
}
return API.v1.success(chartData);
},
);
type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI<typeof livechatAnalyticsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatAnalyticsEndpoints {}
}

View File

@@ -0,0 +1,333 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models';
import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core';
import { isGETLivechatDepartmentProps, isPOSTLivechatDepartmentProps } from '@rocket.chat/rest-typings';
import {
isLivechatDepartmentDepartmentIdAgentsGETProps,
isLivechatDepartmentDepartmentIdAgentsPOSTProps,
} from '@rocket.chat/rest-typings/src/v1/omnichannel';
import { Match, check } from 'meteor/check';
import { API } from '../..';
import {
findDepartments,
findDepartmentById,
findDepartmentsToAutocomplete,
findDepartmentsBetweenIds,
findDepartmentAgents,
findArchivedDepartments,
} from './lib/departments';
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
import { apiDeprecationLogger } from '../../../lib/deprecationWarningLogger';
import {
saveDepartment,
archiveDepartment,
unarchiveDepartment,
saveDepartmentAgents,
removeDepartment,
} from '../../../lib/omnichannel/departmentsLib';
import { settings } from '../../../settings';
import { getPaginationItems } from '../../lib/getPaginationItems';
API.v1.addRoute(
'livechat/department',
{
authRequired: true,
validateParams: { GET: isGETLivechatDepartmentProps, POST: isPOSTLivechatDepartmentProps },
permissionsRequired: {
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
POST: { permissions: ['manage-livechat-departments'], operation: 'hasAll' },
},
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text, enabled, onlyMyDepartments, excludeDepartmentId, showArchived } = this.queryParams;
const { departments, total } = await findDepartments({
userId: this.userId,
text,
enabled: enabled === 'true',
showArchived: showArchived === 'true',
onlyMyDepartments: onlyMyDepartments === 'true',
excludeDepartmentId,
pagination: {
offset,
count,
// IMO, sort type shouldn't be record, but a generic of the model we're trying to sort
// or the form { [k: keyof T]: number | string }
sort: sort as any,
},
});
return API.v1.success({ departments, count: departments.length, offset, total });
},
async post() {
check(this.bodyParams, {
department: Object,
agents: Match.Maybe(Array),
departmentUnit: Match.Maybe({ _id: Match.Optional(String) }),
});
const agents = this.bodyParams.agents ? { upsert: this.bodyParams.agents } : {};
const { departmentUnit } = this.bodyParams;
const department = await saveDepartment(
this.userId,
null,
this.bodyParams.department as ILivechatDepartment,
agents,
departmentUnit || {},
);
if (department) {
return API.v1.success({
department,
agents: await LivechatDepartmentAgents.find({ departmentId: department._id }).toArray(),
});
}
return API.v1.failure();
},
},
);
API.v1.addRoute(
'livechat/department/isDepartmentCreationAvailable',
{
authRequired: true,
permissionsRequired: {
GET: { permissions: ['view-livechat-departments', 'manage-livechat-departments'], operation: 'hasAny' },
},
},
{
async get() {
const available = await isDepartmentCreationAvailable();
return API.v1.success({ isDepartmentCreationAvailable: available });
},
},
);
API.v1.addRoute(
'livechat/department/:_id',
{
authRequired: true,
permissionsRequired: {
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
PUT: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' },
DELETE: { permissions: ['manage-livechat-departments', 'remove-livechat-department'], operation: 'hasAny' },
},
},
{
async get() {
check(this.urlParams, {
_id: String,
});
const { onlyMyDepartments } = this.queryParams;
const { department, agents } = await findDepartmentById({
userId: this.userId,
departmentId: this.urlParams._id,
includeAgents: this.queryParams.includeAgents && this.queryParams.includeAgents === 'true',
onlyMyDepartments: onlyMyDepartments === 'true',
});
// TODO: return 404 when department is not found
// Currently, FE relies on the fact that this endpoint returns an empty payload
// to show the "new" view. Returning 404 breaks it
return API.v1.success({ department, agents });
},
async put() {
const permissionToSave = await hasPermissionAsync(this.user, 'manage-livechat-departments');
const permissionToAddAgents = await hasPermissionAsync(this.user, 'add-livechat-department-agents');
check(this.bodyParams, {
department: Object,
agents: Match.Maybe(Array),
departmentUnit: Match.Maybe({ _id: Match.Optional(String) }),
});
const { _id } = this.urlParams;
const { department, agents, departmentUnit } = this.bodyParams;
if (!permissionToSave) {
throw new Error('error-not-allowed');
}
const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {};
await saveDepartment(this.userId, _id, department, agentParam, departmentUnit || {});
return API.v1.success({
department: await LivechatDepartment.findOneById(_id),
agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(),
});
},
async delete() {
check(this.urlParams, {
_id: String,
});
const isRemoveEnabled = settings.get<boolean>('Omnichannel_enable_department_removal');
if (!isRemoveEnabled) {
return API.v1.failure('error-department-removal-disabled');
}
await removeDepartment(this.urlParams._id);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/departments/archived',
{
authRequired: true,
validateParams: { GET: isGETLivechatDepartmentProps },
permissionsRequired: {
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
},
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text, onlyMyDepartments, excludeDepartmentId } = this.queryParams;
const { departments, total } = await findArchivedDepartments({
userId: this.userId,
text,
onlyMyDepartments: onlyMyDepartments === 'true',
excludeDepartmentId,
pagination: {
offset,
count,
sort: sort as any,
},
});
return API.v1.success({ departments, count: departments.length, offset, total });
},
},
);
API.v1.addRoute(
'livechat/department/:_id/archive',
{
authRequired: true,
permissionsRequired: ['manage-livechat-departments'],
},
{
async post() {
await archiveDepartment(this.urlParams._id);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/department/:_id/unarchive',
{
authRequired: true,
permissionsRequired: ['manage-livechat-departments'],
},
{
async post() {
await unarchiveDepartment(this.urlParams._id);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/department.autocomplete',
{ authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } },
{
async get() {
const { selector, onlyMyDepartments, showArchived } = this.queryParams;
if (!selector) {
return API.v1.failure("The 'selector' param is required");
}
const parsedSelector = JSON.parse(selector);
if (parsedSelector.conditions && Object.keys(parsedSelector.conditions).length > 0) {
apiDeprecationLogger.parameter(this.route, 'conditions', '9.0.0', this.response);
}
return API.v1.success(
await findDepartmentsToAutocomplete({
uid: this.userId,
selector: parsedSelector,
onlyMyDepartments: onlyMyDepartments === 'true',
showArchived: showArchived === 'true',
}),
);
},
},
);
API.v1.addRoute(
'livechat/department/:_id/agents',
{
authRequired: true,
permissionsRequired: {
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
POST: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' },
},
validateParams: { GET: isLivechatDepartmentDepartmentIdAgentsGETProps, POST: isLivechatDepartmentDepartmentIdAgentsPOSTProps },
},
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const agents = await findDepartmentAgents({
userId: this.userId,
departmentId: this.urlParams._id,
pagination: {
offset,
count,
sort,
},
});
return API.v1.success(agents);
},
async post() {
await saveDepartmentAgents(this.urlParams._id, this.bodyParams);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/department.listByIds',
{ authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } },
{
async get() {
const { ids } = this.queryParams;
const { fields } = await this.parseJsonQuery();
if (!ids) {
return API.v1.failure("The 'ids' param is required");
}
if (!Array.isArray(ids)) {
return API.v1.failure("The 'ids' param must be an array");
}
return API.v1.success(
await findDepartmentsBetweenIds({
ids,
fields,
}),
);
},
},
);

View File

@@ -0,0 +1,28 @@
import './config';
import './visitor';
import './transcript';
import './offlineMessage';
import './pageVisited';
import './agent';
import './message';
import './customField';
import './room';
import './transfer';
import './contact';
import './webhooks';
import './integration';
import './statistics';
import './agentDepartments';
import './departments';
import './sms';
import './users';
import './upload';
import './inquiries';
import './rooms';
import './appearance';
import './triggers';
import './integrations';
import './visitors';
import './dashboards';
import './queue';
import './businessHours';

View File

@@ -0,0 +1,160 @@
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
import { LivechatInquiry, LivechatDepartment, Users, LivechatRooms } from '@rocket.chat/models';
import {
isGETLivechatInquiriesListParams,
isPOSTLivechatInquiriesTakeParams,
isGETLivechatInquiriesQueuedForUserParams,
isGETLivechatInquiriesGetOneParams,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
isPOSTLivechatInquiriesReturnAsInquiry,
POSTLivechatInquiriesReturnAsInquirySuccessResponse,
} from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { API } from '../..';
import { findInquiries, findOneInquiryByRoomId } from './lib/inquiries';
import { returnRoomAsInquiry } from '../../../lib/omnichannel/rooms';
import { takeInquiry } from '../../../lib/omnichannel/takeInquiry';
import type { ExtractRoutesFromAPI } from '../../ApiClass';
import { getPaginationItems } from '../../lib/getPaginationItems';
API.v1.addRoute(
'livechat/inquiries.list',
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETLivechatInquiriesListParams },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { department } = this.queryParams;
const ourQuery: { status: string; department?: string } = { status: 'queued' };
if (department) {
const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } });
if (departmentFromDB) {
ourQuery.department = departmentFromDB._id;
}
}
// @ts-expect-error - attachments...
const { cursor, totalCount } = LivechatInquiry.findPaginated(ourQuery, {
sort: sort || { ts: -1 },
skip: offset,
limit: count,
projection: {
rid: 1,
name: 1,
ts: 1,
status: 1,
department: 1,
},
});
const [inquiries, total] = await Promise.all([cursor.toArray(), totalCount]);
return API.v1.success({
inquiries,
offset,
count: inquiries.length,
total,
});
},
},
);
API.v1.addRoute(
'livechat/inquiries.take',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatInquiriesTakeParams },
{
async post() {
if (this.bodyParams.userId && !(await Users.findOneById(this.bodyParams.userId, { projection: { _id: 1 } }))) {
return API.v1.failure('The user is invalid');
}
return API.v1.success({
inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId, this.bodyParams.options),
});
},
},
);
API.v1.addRoute(
'livechat/inquiries.queuedForUser',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesQueuedForUserParams },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { department } = this.queryParams;
return API.v1.success(
await findInquiries({
userId: this.userId,
department,
status: LivechatInquiryStatus.QUEUED,
pagination: {
offset,
count,
sort,
},
}),
);
},
},
);
API.v1.addRoute(
'livechat/inquiries.getOne',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesGetOneParams },
{
async get() {
const { roomId } = this.queryParams;
return API.v1.success(
await findOneInquiryByRoomId({
roomId,
}),
);
},
},
);
const livechatInquiriesEndpoints = API.v1.post(
'livechat/inquiries.returnAsInquiry',
{
response: {
200: POSTLivechatInquiriesReturnAsInquirySuccessResponse,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['view-l-room'],
body: isPOSTLivechatInquiriesReturnAsInquiry,
},
async function action() {
const { roomId, departmentId } = this.bodyParams;
try {
const room = await LivechatRooms.findOneById(roomId);
if (!room) {
return API.v1.failure('error-room-not-found');
}
const result = await returnRoomAsInquiry(room, departmentId);
return API.v1.success({ result });
} catch (error) {
if (error instanceof Meteor.Error && typeof error.error === 'string') {
return API.v1.failure(error.error as string);
}
return API.v1.failure('error-returning-inquiry');
}
},
);
type LivechatInquiriesEndpoints = ExtractRoutesFromAPI<typeof livechatInquiriesEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends LivechatInquiriesEndpoints {}
}

Some files were not shown because too many files have changed in this diff Show More