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(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({ 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('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 = ` ${hideIcon ? '' : ``} ${ name ? `${name} ${name}` : '' } ${text} ${text} ` .trim() .replace(/\>[\s]+\<'); return { statusCode: 200 as const, body: svgBody, headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' }, }; }, ); const spotlightResponseSchema = ajv.compile<{ users: (Pick & Partial> & { nickname?: string; outside?: boolean; })[]; rooms: Pick[]; }>({ 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?][] }>({ 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({ 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(); }, );