snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
43
apps/meteor/ee/LICENSE
Normal file
43
apps/meteor/ee/LICENSE
Normal file
@@ -0,0 +1,43 @@
|
||||
The Rocket.Chat Enterprise Edition (EE) license (the "EE License")
|
||||
Copyright (c) 2015-2022 Rocket.Chat Technologies Corp.
|
||||
|
||||
With regard to the Rocket.Chat Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Rocket.Chat Subscription Terms of Service,
|
||||
available at https://rocket.chat/terms (the "EE Terms"), or other agreement
|
||||
governing the use of the Software, as agreed by you and Rocket.Chat, and otherwise
|
||||
have a valid Rocket.Chat Enterprise Edition subscription for the correct number
|
||||
of user seats. Subject to the foregoing sentence, you are free to modify this
|
||||
Software and publish patches to the Software. You agree that Rocket.Chat and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all
|
||||
such modifications and/or patches, and all such modifications and/or patches may
|
||||
only be used, copied, modified, displayed, distributed, or otherwise exploited
|
||||
with a valid Rocket.Chat Enterprise Edition subscription for the correct number
|
||||
of user seats. Notwithstanding the foregoing, you may copy and modify the Software
|
||||
for development and testing purposes, without requiring a Subscription. You agree
|
||||
that Rocket.Chat and/or its licensors (as applicable) retain all right, title and
|
||||
interest in and to all such modifications. You are not granted any other rights
|
||||
beyond what is expressly stated herein. Subject to the foregoing, it is forbidden
|
||||
to copy, merge, publish, distribute, sublicense, and/or sell the Software.
|
||||
|
||||
This EE License applies only to the part of this Software that is not distributed
|
||||
as part of Rocket.Chat Community Edition (CE). Any part of this Software distributed
|
||||
as part of Rocket.Chat CE or is served client-side as an image, font, cascading
|
||||
stylesheet (CSS), file which produces or is compiled, arranged, augmented, or combined
|
||||
into client-side JavaScript, in whole or in part, is copyrighted under the MIT Expat
|
||||
license. The full text of this EE License shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third-party components incorporated into the Rocket.Chat Software,
|
||||
those components are licensed under the original license provided by the owner
|
||||
of the applicable component.
|
||||
96
apps/meteor/ee/lib/misc/Utilities.ts
Normal file
96
apps/meteor/ee/lib/misc/Utilities.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export class Utilities {
|
||||
static getI18nKeyForApp<TKey extends string, TAppId extends string>(key: TKey, appId: TAppId) {
|
||||
return `app-${appId}.${key}` as const;
|
||||
}
|
||||
|
||||
static curl(
|
||||
{
|
||||
method,
|
||||
params,
|
||||
auth,
|
||||
headers = {},
|
||||
url,
|
||||
query,
|
||||
content,
|
||||
}: {
|
||||
method: string;
|
||||
params?: Record<string, string>;
|
||||
auth?: string;
|
||||
headers?: Record<string, string>;
|
||||
url: string;
|
||||
query?: Record<string, string>;
|
||||
content?: unknown;
|
||||
},
|
||||
opts: {
|
||||
verbose?: boolean;
|
||||
headers?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const newLine = '\\\n ';
|
||||
|
||||
const cmd = ['curl'];
|
||||
|
||||
// curl options
|
||||
if (opts.verbose) {
|
||||
cmd.push('-v');
|
||||
}
|
||||
if (opts.headers) {
|
||||
cmd.push('-i');
|
||||
}
|
||||
|
||||
// method
|
||||
cmd.push('-X');
|
||||
cmd.push((method || 'GET').toUpperCase());
|
||||
|
||||
// URL
|
||||
let u = url;
|
||||
|
||||
if (typeof params === 'object') {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
u = u.replace(`:${key}`, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof query === 'object') {
|
||||
const queryString = Object.entries(query)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('&');
|
||||
u += `?${queryString}`;
|
||||
}
|
||||
cmd.push(u);
|
||||
|
||||
// username
|
||||
if (auth) {
|
||||
cmd.push(newLine);
|
||||
cmd.push('-u');
|
||||
cmd.push(auth);
|
||||
}
|
||||
|
||||
// headers
|
||||
const headerKeys: string[] = [];
|
||||
Object.entries(headers).forEach(([key, val]) => {
|
||||
key = key.toLowerCase();
|
||||
headerKeys.push(key);
|
||||
cmd.push(newLine);
|
||||
cmd.push('-H');
|
||||
cmd.push(`"${key}${val ? ': ' : ';'}${val || ''}"`);
|
||||
});
|
||||
|
||||
if (content) {
|
||||
if (typeof content === 'object') {
|
||||
if (!headerKeys.includes('content-type')) {
|
||||
cmd.push(newLine);
|
||||
cmd.push('-H');
|
||||
cmd.push('"content-type: application/json"');
|
||||
}
|
||||
content = JSON.stringify(content);
|
||||
}
|
||||
|
||||
cmd.push(newLine);
|
||||
cmd.push('--data-binary');
|
||||
cmd.push(`'${content}'`);
|
||||
}
|
||||
|
||||
return cmd.join(' ');
|
||||
}
|
||||
}
|
||||
19
apps/meteor/ee/lib/misc/determineFileType.js
Normal file
19
apps/meteor/ee/lib/misc/determineFileType.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import fileType from 'file-type';
|
||||
|
||||
import { mime as MIME } from '../../../app/utils/lib/mimeTypes';
|
||||
|
||||
export function determineFileType(buffer, name) {
|
||||
const mime = MIME.lookup(name);
|
||||
|
||||
if (mime) {
|
||||
return Array.isArray(mime) ? mime[0] : mime;
|
||||
}
|
||||
|
||||
const detectedType = fileType(buffer);
|
||||
|
||||
if (detectedType) {
|
||||
return detectedType.mime;
|
||||
}
|
||||
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
24
apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
Normal file
24
apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Apps } from '@rocket.chat/core-services';
|
||||
|
||||
import { isRunningMs } from '../../../server/lib/isRunningMs';
|
||||
import { Instance } from '../../server/sdk';
|
||||
|
||||
export async function fetchAppsStatusFromCluster() {
|
||||
const appsStatus = isRunningMs() ? await Apps.getAppsStatusInNodes() : await Instance.getAppsStatusInInstances();
|
||||
|
||||
Object.values(appsStatus).forEach((instances) =>
|
||||
instances.sort((a, b) => {
|
||||
if (a.instanceId > b.instanceId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.instanceId < b.instanceId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
|
||||
return appsStatus;
|
||||
}
|
||||
40
apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts
Normal file
40
apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
|
||||
import type { AppLicenseValidationResult } from '@rocket.chat/apps/dist/server/marketplace/license/AppLicenseValidationResult';
|
||||
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
|
||||
import type { AppStatusReport } from '@rocket.chat/core-services';
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getInstallationSourceFromAppStorageItem } from '../../../lib/apps/getInstallationSourceFromAppStorageItem';
|
||||
|
||||
interface IAppInfoRest extends IAppInfo {
|
||||
status: AppStatus;
|
||||
languages: IAppStorageItem['languageContent'];
|
||||
licenseValidation?: AppLicenseValidationResult;
|
||||
private: boolean;
|
||||
migrated: boolean;
|
||||
clusterStatus?: App['clusterStatus'];
|
||||
}
|
||||
|
||||
export async function formatAppInstanceForRest(app: ProxiedApp, clusterStatus?: AppStatusReport): Promise<IAppInfoRest> {
|
||||
const appRest: IAppInfoRest = {
|
||||
...app.getInfo(),
|
||||
status: await app.getStatus(),
|
||||
languages: app.getStorageItem().languageContent,
|
||||
private: getInstallationSourceFromAppStorageItem(app.getStorageItem()) === 'private',
|
||||
migrated: !!app.getStorageItem().migrated,
|
||||
};
|
||||
|
||||
if (clusterStatus?.[app.getID()]) {
|
||||
appRest.clusterStatus = clusterStatus[app.getID()];
|
||||
}
|
||||
|
||||
const licenseValidation = app.getLatestLicenseValidationResult();
|
||||
|
||||
if (licenseValidation?.hasErrors || licenseValidation?.hasWarnings) {
|
||||
appRest.licenseValidation = licenseValidation;
|
||||
}
|
||||
|
||||
return appRest;
|
||||
}
|
||||
474
apps/meteor/ee/server/api/abac/index.ts
Normal file
474
apps/meteor/ee/server/api/abac/index.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import { AbacAttributeStoreExternalError, getPdpHealthErrorCode } from '@rocket.chat/abac';
|
||||
import { Abac } from '@rocket.chat/core-services';
|
||||
import type { AbacActor } from '@rocket.chat/core-services';
|
||||
import type { IServerEvents, IUser } from '@rocket.chat/core-typings';
|
||||
import { ServerEvents } from '@rocket.chat/models';
|
||||
import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv';
|
||||
import { convertSubObjectsIntoPaths } from '@rocket.chat/tools';
|
||||
|
||||
import {
|
||||
GenericSuccessSchema,
|
||||
PUTAbacAttributeUpdateBodySchema,
|
||||
GETAbacAttributesQuerySchema,
|
||||
GETAbacAttributesResponseSchema,
|
||||
GETAbacAttributeByIdResponseSchema,
|
||||
POSTAbacAttributeDefinitionSchema,
|
||||
GETAbacAttributeIsInUseResponseSchema,
|
||||
POSTRoomAbacAttributesBodySchema,
|
||||
POSTSingleRoomAbacAttributeBodySchema,
|
||||
PUTRoomAbacAttributeValuesBodySchema,
|
||||
POSTAbacUsersSyncBodySchema,
|
||||
GenericErrorSchema,
|
||||
GETAbacRoomsListQueryValidator,
|
||||
GETAbacRoomsResponseValidator,
|
||||
GETAbacAuditEventsQuerySchema,
|
||||
GETAbacAuditEventsResponseSchema,
|
||||
GETAbacPdpHealthResponseSchema,
|
||||
GETAbacPdpHealthErrorResponseSchema,
|
||||
} from './schemas';
|
||||
import { API } from '../../../../server/api';
|
||||
import type { ExtractRoutesFromAPI } from '../../../../server/api/ApiClass';
|
||||
import { getPaginationItems } from '../../../../server/api/lib/getPaginationItems';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
const getActorFromUser = (user?: IUser | null): AbacActor | undefined =>
|
||||
user?._id
|
||||
? {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const assertLocalAttributeStore = async (): Promise<void> => {
|
||||
if (await Abac.isExternalAttributeStore()) {
|
||||
throw new AbacAttributeStoreExternalError();
|
||||
}
|
||||
};
|
||||
|
||||
const abacEndpoints = API.v1
|
||||
.post(
|
||||
'abac/rooms/:rid/attributes',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
|
||||
body: POSTRoomAbacAttributesBodySchema,
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
license: ['abac'],
|
||||
},
|
||||
async function action() {
|
||||
const { rid } = this.urlParams;
|
||||
const { attributes } = this.bodyParams;
|
||||
|
||||
if (!settings.get('ABAC_Enabled')) {
|
||||
throw new Error('error-abac-not-enabled');
|
||||
}
|
||||
|
||||
// This is a replace-all operation
|
||||
// IF you need fine grained, use the other endpoints for removing, editing & adding single attributes
|
||||
await Abac.setRoomAbacAttributes(rid, attributes, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
'abac/rooms/:rid/attributes',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { rid } = this.urlParams;
|
||||
|
||||
// We don't need to check if ABAC is enabled to clear attributes
|
||||
// Since we're always allowing this operation
|
||||
// license check is also not required
|
||||
await Abac.setRoomAbacAttributes(rid, {}, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// add an abac attribute by key
|
||||
.post(
|
||||
'abac/rooms/:rid/attributes/:key',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
|
||||
license: ['abac'],
|
||||
body: POSTSingleRoomAbacAttributeBodySchema,
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { rid, key } = this.urlParams;
|
||||
const { values } = this.bodyParams;
|
||||
|
||||
if (!settings.get('ABAC_Enabled')) {
|
||||
throw new Error('error-abac-not-enabled');
|
||||
}
|
||||
|
||||
await Abac.addRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// edit a room attribute
|
||||
.put(
|
||||
'abac/rooms/:rid/attributes/:key',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
|
||||
body: PUTRoomAbacAttributeValuesBodySchema,
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
license: ['abac'],
|
||||
},
|
||||
async function action() {
|
||||
const { rid, key } = this.urlParams;
|
||||
const { values } = this.bodyParams;
|
||||
|
||||
if (!settings.get('ABAC_Enabled')) {
|
||||
throw new Error('error-abac-not-enabled');
|
||||
}
|
||||
|
||||
await Abac.replaceRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// delete a room attribute
|
||||
.delete(
|
||||
'abac/rooms/:rid/attributes/:key',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { rid, key } = this.urlParams;
|
||||
|
||||
await Abac.removeRoomAbacAttribute(rid, key, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// attribute endpoints
|
||||
// list attributes
|
||||
.get(
|
||||
'abac/attributes',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
|
||||
query: GETAbacAttributesQuerySchema,
|
||||
response: {
|
||||
200: GETAbacAttributesResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>);
|
||||
const { key, values } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await Abac.listAbacAttributes(
|
||||
{
|
||||
key,
|
||||
values,
|
||||
offset,
|
||||
count,
|
||||
},
|
||||
getActorFromUser(this.user),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
'abac/users/sync',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
|
||||
license: ['abac'],
|
||||
body: POSTAbacUsersSyncBodySchema,
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!settings.get('ABAC_Enabled')) {
|
||||
throw new Error('error-abac-not-enabled');
|
||||
}
|
||||
|
||||
const { usernames, ids, emails, ldapIds } = this.bodyParams;
|
||||
|
||||
await Abac.reevaluateUsers({ usernames, ids, emails, ldapIds });
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'abac/attributes',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
|
||||
license: ['abac'],
|
||||
body: POSTAbacAttributeDefinitionSchema,
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!settings.get('ABAC_Enabled')) {
|
||||
throw new Error('error-abac-not-enabled');
|
||||
}
|
||||
|
||||
await assertLocalAttributeStore();
|
||||
|
||||
await Abac.addAbacAttribute(this.bodyParams, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// update attribute definition (key and/or values)
|
||||
.put(
|
||||
'abac/attributes/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
|
||||
license: ['abac'],
|
||||
body: PUTAbacAttributeUpdateBodySchema,
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
if (!settings.get('ABAC_Enabled')) {
|
||||
throw new Error('error-abac-not-enabled');
|
||||
}
|
||||
|
||||
await assertLocalAttributeStore();
|
||||
|
||||
await Abac.updateAbacAttributeById(_id, this.bodyParams, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// get single attribute with usage
|
||||
.get(
|
||||
'abac/attributes/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
|
||||
response: {
|
||||
200: GETAbacAttributeByIdResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
await assertLocalAttributeStore();
|
||||
|
||||
const result = await Abac.getAbacAttributeById(_id, getActorFromUser(this.user));
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
// delete attribute (only if not in use)
|
||||
.delete(
|
||||
'abac/attributes/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
|
||||
response: {
|
||||
200: GenericSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
await assertLocalAttributeStore();
|
||||
|
||||
await Abac.deleteAbacAttributeById(_id, getActorFromUser(this.user));
|
||||
return API.v1.success();
|
||||
},
|
||||
)
|
||||
// check if attribute is in use
|
||||
.get(
|
||||
'abac/attributes/:key/is-in-use',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-room-attributes'],
|
||||
response: {
|
||||
200: GETAbacAttributeIsInUseResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { key } = this.urlParams;
|
||||
|
||||
await assertLocalAttributeStore();
|
||||
|
||||
const inUse = await Abac.isAbacAttributeInUseByKey(key);
|
||||
return API.v1.success({ inUse });
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'abac/rooms',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-rooms'],
|
||||
response: {
|
||||
200: GETAbacRoomsResponseValidator,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: GenericErrorSchema,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
query: GETAbacRoomsListQueryValidator,
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | string[] | number | null | undefined>);
|
||||
const { filter, filterType } = this.queryParams;
|
||||
|
||||
const result = await Abac.listAbacRooms(
|
||||
{
|
||||
offset,
|
||||
count,
|
||||
filter,
|
||||
filterType,
|
||||
},
|
||||
getActorFromUser(this.user),
|
||||
);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'abac/pdp/health',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'manage-abac-admin-settings'],
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 5,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
response: {
|
||||
200: GETAbacPdpHealthResponseSchema,
|
||||
400: GETAbacPdpHealthErrorResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
try {
|
||||
await Abac.getPDPHealth();
|
||||
return API.v1.success({ available: true, message: 'ABAC_PDP_Health_OK' });
|
||||
} catch (err) {
|
||||
return API.v1.failure({ available: false, message: getPdpHealthErrorCode(err) });
|
||||
}
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'abac/audit',
|
||||
{
|
||||
response: {
|
||||
200: GETAbacAuditEventsResponseSchema,
|
||||
400: GenericErrorSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
query: GETAbacAuditEventsQuerySchema,
|
||||
authRequired: true,
|
||||
permissionsRequired: ['abac-management', 'view-abac-admin-audit'],
|
||||
license: ['abac', 'auditing'],
|
||||
},
|
||||
async function action() {
|
||||
const { start, end, actor } = this.queryParams;
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | number | null | undefined>);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const _sort = { ts: sort?.ts ? sort?.ts : -1 };
|
||||
|
||||
const { cursor, totalCount } = ServerEvents.findPaginated(
|
||||
{
|
||||
...(actor && convertSubObjectsIntoPaths({ actor })),
|
||||
ts: {
|
||||
$gte: start ? new Date(start) : new Date(0),
|
||||
$lte: end ? new Date(end) : new Date(),
|
||||
},
|
||||
t: {
|
||||
$in: [
|
||||
'abac.attribute.changed',
|
||||
'abac.object.attribute.changed',
|
||||
'abac.object.attributes.removed',
|
||||
'abac.action.performed',
|
||||
'abac.attribute.store.switched',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
sort: _sort,
|
||||
skip: offset,
|
||||
limit: count,
|
||||
allowDiskUse: true,
|
||||
},
|
||||
);
|
||||
|
||||
const [events, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
events: events as (
|
||||
| IServerEvents['abac.action.performed']
|
||||
| IServerEvents['abac.attribute.changed']
|
||||
| IServerEvents['abac.object.attribute.changed']
|
||||
| IServerEvents['abac.object.attributes.removed']
|
||||
| IServerEvents['abac.attribute.store.switched']
|
||||
)[],
|
||||
count: events.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export type AbacEndpoints = ExtractRoutesFromAPI<typeof abacEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends AbacEndpoints {}
|
||||
}
|
||||
422
apps/meteor/ee/server/api/abac/schemas.ts
Normal file
422
apps/meteor/ee/server/api/abac/schemas.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import type {
|
||||
IAbacAttribute,
|
||||
IAbacAttributeDefinition,
|
||||
IAuditServerActor,
|
||||
IRoom,
|
||||
IRoomAbacRedaction,
|
||||
IServerEvents,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
|
||||
const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$';
|
||||
const MAX_ROOM_ATTRIBUTE_VALUES = 10;
|
||||
const MAX_USERS_SYNC_ITEMS = 100;
|
||||
const MAX_ROOM_ATTRIBUTE_KEYS = 10;
|
||||
|
||||
const GenericSuccess = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const GenericSuccessSchema = ajv.compile<void>(GenericSuccess);
|
||||
|
||||
// Update ABAC attribute (request body)
|
||||
const UpdateAbacAttributeBody = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
values: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
anyOf: [{ required: ['key'] }, { required: ['values'] }],
|
||||
};
|
||||
|
||||
export const PUTAbacAttributeUpdateBodySchema = ajv.compile<IAbacAttributeDefinition>(UpdateAbacAttributeBody);
|
||||
|
||||
const AbacAttributeDefinition = {
|
||||
type: 'object',
|
||||
|
||||
properties: {
|
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
values: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ['key', 'values'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const POSTAbacAttributeDefinitionSchema = ajv.compile<IAbacAttributeDefinition>(AbacAttributeDefinition);
|
||||
|
||||
const GetAbacAttributesQuery = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
values: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
offset: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const GETAbacAttributesQuerySchema = ajvQuery.compile<{ key?: string; values?: string; offset: number; count?: number }>(
|
||||
GetAbacAttributesQuery,
|
||||
);
|
||||
|
||||
const AbacAttributeRecord = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string', minLength: 1 },
|
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
values: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ['_id', 'key', 'values'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const GetAbacAttributesResponse = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
attributes: {
|
||||
type: 'array',
|
||||
items: AbacAttributeRecord,
|
||||
},
|
||||
offset: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
},
|
||||
required: ['attributes', 'offset', 'count', 'total'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const GETAbacAttributesResponseSchema = ajv.compile<{
|
||||
attributes: Pick<IAbacAttribute, '_id' | 'key' | 'values'>[];
|
||||
offset: number;
|
||||
count: number;
|
||||
total: number;
|
||||
}>(GetAbacAttributesResponse);
|
||||
|
||||
const GetAbacAttributeByIdResponse = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
_id: { type: 'string', minLength: 1 },
|
||||
key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
values: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
minItems: 1,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ['key', 'values'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const GETAbacAttributeByIdResponseSchema = ajv.compile<{
|
||||
key: string;
|
||||
values: string[];
|
||||
}>(GetAbacAttributeByIdResponse);
|
||||
|
||||
const GetAbacAttributeIsInUseResponse = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
inUse: { type: 'boolean' },
|
||||
},
|
||||
required: ['inUse'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse);
|
||||
|
||||
const GetAbacAuditEventsQuerySchemaObject = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
start: { type: 'string', format: 'date-time', nullable: true },
|
||||
end: { type: 'string', format: 'date-time', nullable: true },
|
||||
offset: { type: 'number', nullable: true },
|
||||
count: { type: 'number', nullable: true },
|
||||
actor: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
_id: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
ip: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
useragent: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const GETAbacAuditEventsQuerySchema = ajvQuery.compile<
|
||||
PaginatedRequest<{
|
||||
start?: string;
|
||||
end?: string;
|
||||
actor?: IAuditServerActor;
|
||||
}>
|
||||
>(GetAbacAuditEventsQuerySchemaObject);
|
||||
|
||||
const GetAbacAuditEventsResponseSchemaObject = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'The number of events returned in this response.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'The number of events that were skipped in this response.',
|
||||
},
|
||||
total: {
|
||||
type: 'number',
|
||||
description: 'The total number of events that match the query.',
|
||||
},
|
||||
},
|
||||
required: ['events', 'count', 'offset', 'total'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const GETAbacAuditEventsResponseSchema = ajv.compile<{
|
||||
events: (
|
||||
| IServerEvents['abac.action.performed']
|
||||
| IServerEvents['abac.attribute.changed']
|
||||
| IServerEvents['abac.object.attribute.changed']
|
||||
| IServerEvents['abac.object.attributes.removed']
|
||||
| IServerEvents['abac.attribute.store.switched']
|
||||
)[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}>(GetAbacAuditEventsResponseSchemaObject);
|
||||
|
||||
const PostRoomAbacAttributesBody = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
attributes: {
|
||||
type: 'object',
|
||||
propertyNames: { type: 'string', pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
minProperties: 1,
|
||||
maxProperties: MAX_ROOM_ATTRIBUTE_KEYS,
|
||||
additionalProperties: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['attributes'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: Record<string, string[]> }>(PostRoomAbacAttributesBody);
|
||||
|
||||
const PostSingleRoomAbacAttributeBody = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
values: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
minItems: 1,
|
||||
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ['values'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const POSTSingleRoomAbacAttributeBodySchema = ajv.compile<{ values: string[] }>(PostSingleRoomAbacAttributeBody);
|
||||
|
||||
const PutRoomAbacAttributeValuesBody = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
values: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN },
|
||||
minItems: 1,
|
||||
maxItems: MAX_ROOM_ATTRIBUTE_VALUES,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
required: ['values'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const PUTRoomAbacAttributeValuesBodySchema = ajv.compile<{ values: string[] }>(PutRoomAbacAttributeValuesBody);
|
||||
|
||||
const GenericError = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PostAbacUsersSyncBody = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
usernames: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1 },
|
||||
minItems: 1,
|
||||
maxItems: MAX_USERS_SYNC_ITEMS,
|
||||
uniqueItems: true,
|
||||
},
|
||||
ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1 },
|
||||
minItems: 1,
|
||||
maxItems: MAX_USERS_SYNC_ITEMS,
|
||||
uniqueItems: true,
|
||||
},
|
||||
emails: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1 },
|
||||
minItems: 1,
|
||||
maxItems: MAX_USERS_SYNC_ITEMS,
|
||||
uniqueItems: true,
|
||||
},
|
||||
ldapIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1 },
|
||||
minItems: 1,
|
||||
maxItems: MAX_USERS_SYNC_ITEMS,
|
||||
uniqueItems: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
anyOf: [{ required: ['usernames'] }, { required: ['ids'] }, { required: ['emails'] }, { required: ['ldapIds'] }],
|
||||
};
|
||||
|
||||
export const POSTAbacUsersSyncBodySchema = ajv.compile<{
|
||||
usernames?: string[];
|
||||
ids?: string[];
|
||||
emails?: string[];
|
||||
ldapIds?: string[];
|
||||
}>(PostAbacUsersSyncBody);
|
||||
|
||||
export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError);
|
||||
|
||||
export const GETAbacPdpHealthResponseSchema = ajv.compile<{
|
||||
available: boolean;
|
||||
message: string;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
available: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
required: ['success', 'available', 'message'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export const GETAbacPdpHealthErrorResponseSchema = ajv.compile<{ available: boolean; message: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
available: { type: 'boolean', enum: [false] },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
required: ['success', 'available', 'message'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const GETAbacRoomsListQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filter: { type: 'string', minLength: 1 },
|
||||
filterType: { type: 'string', enum: ['all', 'roomName', 'attribute', 'value'] },
|
||||
offset: { type: 'number' },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
type GETAbacRoomsListQuery = PaginatedRequest<{ filter?: string; filterType?: 'all' | 'roomName' | 'attribute' | 'value' }>;
|
||||
|
||||
export const GETAbacRoomsListQueryValidator = ajvQuery.compile<GETAbacRoomsListQuery>(GETAbacRoomsListQuerySchema);
|
||||
|
||||
export const GETAbacRoomsResponseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
rooms: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
},
|
||||
total: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['rooms', 'offset', 'count', 'total'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
type GETAbacRoomsResponse = PaginatedResult<{
|
||||
rooms: Array<IRoom & IRoomAbacRedaction>;
|
||||
}>;
|
||||
|
||||
export const GETAbacRoomsResponseValidator = ajv.compile<GETAbacRoomsResponse>(GETAbacRoomsResponseSchema);
|
||||
37
apps/meteor/ee/server/api/api.ts
Normal file
37
apps/meteor/ee/server/api/api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
import { API } from '../../../server/api/api';
|
||||
import type { NonEnterpriseTwoFactorOptions, Options } from '../../../server/api/definition';
|
||||
import { use } from '../../../server/settings/Middleware';
|
||||
|
||||
// Overwrites two factor method to enforce 2FA check for enterprise APIs when
|
||||
// no license was provided to prevent abuse on enterprise APIs.
|
||||
|
||||
const isNonEnterpriseTwoFactorOptions = (options?: Options): options is NonEnterpriseTwoFactorOptions =>
|
||||
!!options && 'forceTwoFactorAuthenticationForNonEnterprise' in options && Boolean(options.forceTwoFactorAuthenticationForNonEnterprise);
|
||||
|
||||
API.v1.processTwoFactor = use(API.v1.processTwoFactor, ([params, ...context], next) => {
|
||||
if (isNonEnterpriseTwoFactorOptions(params.options) && !License.hasValidLicense()) {
|
||||
const options: NonEnterpriseTwoFactorOptions = {
|
||||
...params.options,
|
||||
twoFactorOptions: {
|
||||
disableRememberMe: true,
|
||||
requireSecondFactor: true,
|
||||
disablePasswordFallback: false,
|
||||
},
|
||||
twoFactorRequired: true,
|
||||
authRequired: true,
|
||||
};
|
||||
|
||||
return next(
|
||||
{
|
||||
...params,
|
||||
options,
|
||||
},
|
||||
...context,
|
||||
);
|
||||
}
|
||||
|
||||
return next(params, ...context);
|
||||
});
|
||||
504
apps/meteor/ee/server/api/audit.ts
Normal file
504
apps/meteor/ee/server/api/audit.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import type { IAuditLog, IMessage, IUser, IRoom } from '@rocket.chat/core-typings';
|
||||
import { Rooms, AuditLog, ServerEvents } from '@rocket.chat/models';
|
||||
import {
|
||||
isServerEventsAuditSettingsProps,
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateNotFoundErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { convertSubObjectsIntoPaths } from '@rocket.chat/tools';
|
||||
|
||||
import { API } from '../../../server/api/api';
|
||||
import { getPaginationItems } from '../../../server/api/lib/getPaginationItems';
|
||||
import { findUsersOfRoom } from '../../../server/lib/findUsersOfRoom';
|
||||
import { getUsersHiddenFrom, redactHiddenUsers } from '../../../server/lib/statusVisibility/hiddenUsers';
|
||||
import { auditGetAuditionsMethod, auditGetMessagesMethod, auditGetOmnichannelMessagesMethod } from '../lib/audit/functions';
|
||||
|
||||
type AuditRoomMembersParams = PaginatedRequest<{
|
||||
roomId: string;
|
||||
filter: string;
|
||||
}>;
|
||||
|
||||
const auditRoomMembersSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', minLength: 1 },
|
||||
filter: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
sort: { type: 'string' },
|
||||
},
|
||||
required: ['roomId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isAuditRoomMembersProps = ajvQuery.compile<AuditRoomMembersParams>(auditRoomMembersSchema);
|
||||
|
||||
type AuditAuditionsParams = { startDate: string; endDate: string };
|
||||
|
||||
const auditAuditionsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
startDate: { type: 'string', minLength: 1 },
|
||||
endDate: { type: 'string', minLength: 1 },
|
||||
},
|
||||
required: ['startDate', 'endDate'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isAuditAuditionsProps = ajvQuery.compile<AuditAuditionsParams>(auditAuditionsSchema);
|
||||
|
||||
type AuditMessagesPayload = {
|
||||
rid?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
users: string[];
|
||||
msg: string;
|
||||
type: string;
|
||||
visitor?: string;
|
||||
agent?: string;
|
||||
};
|
||||
|
||||
const auditMessagesSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rid: { type: 'string' },
|
||||
startDate: { type: 'string', minLength: 1 },
|
||||
endDate: { type: 'string', minLength: 1 },
|
||||
users: { type: 'array', items: { type: 'string' } },
|
||||
msg: { type: ['string', 'null'] },
|
||||
type: { type: 'string' },
|
||||
visitor: { type: ['string', 'null'] },
|
||||
agent: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['startDate', 'endDate', 'users', 'msg', 'type'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isAuditMessagesProps = ajv.compile<AuditMessagesPayload>(auditMessagesSchema);
|
||||
|
||||
type AuditOmnichannelMessagesPayload = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
users: string[];
|
||||
msg: string;
|
||||
type: string;
|
||||
visitor?: string;
|
||||
agent?: string;
|
||||
};
|
||||
|
||||
const auditOmnichannelMessagesSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
startDate: { type: 'string', minLength: 1 },
|
||||
endDate: { type: 'string', minLength: 1 },
|
||||
users: { type: 'array', items: { type: 'string' } },
|
||||
msg: { type: ['string', 'null'] },
|
||||
type: { type: 'string' },
|
||||
visitor: { type: ['string', 'null'] },
|
||||
agent: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['startDate', 'endDate', 'users', 'msg', 'type'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isAuditOmnichannelMessagesProps = ajv.compile<AuditOmnichannelMessagesPayload>(auditOmnichannelMessagesSchema);
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/audit/rooms.members': {
|
||||
GET: (
|
||||
params: AuditRoomMembersParams,
|
||||
) => PaginatedResult<{ members: Pick<IUser, '_id' | 'name' | 'username' | 'status' | '_updatedAt'>[] }>;
|
||||
};
|
||||
|
||||
'/v1/audit.auditions': {
|
||||
GET: (params: AuditAuditionsParams) => { auditions: IAuditLog[] };
|
||||
};
|
||||
|
||||
'/v1/audit.messages': {
|
||||
POST: (params: AuditMessagesPayload) => { messages: IMessage[] };
|
||||
};
|
||||
|
||||
'/v1/audit.omnichannelMessages': {
|
||||
POST: (params: AuditOmnichannelMessagesPayload) => { messages: IMessage[] };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const auditRoomMembersResponseSchema = ajv.compile<
|
||||
PaginatedResult<{ members: Pick<IUser, '_id' | 'name' | 'username' | 'status' | '_updatedAt'>[] }>
|
||||
>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
members: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
nickname: { type: 'string' },
|
||||
status: { type: 'string', enum: ['online', 'away', 'offline', 'busy', 'disabled'] },
|
||||
avatarETag: { type: 'string' },
|
||||
federated: { type: 'boolean' },
|
||||
_updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['members', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'audit/rooms.members',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-members-list-all-rooms'],
|
||||
query: isAuditRoomMembersProps,
|
||||
license: ['auditing'],
|
||||
response: {
|
||||
200: auditRoomMembersResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { roomId, filter } = this.queryParams;
|
||||
const { count: limit, offset: skip } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 'name' | 'fname'>>(roomId, { projection: { _id: 1, name: 1, fname: 1 } });
|
||||
if (!room) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
const hidden = await getUsersHiddenFrom(this.userId);
|
||||
|
||||
const { cursor, totalCount } = await findUsersOfRoom({
|
||||
rid: room._id,
|
||||
hidden,
|
||||
filter,
|
||||
skip,
|
||||
limit,
|
||||
...(sort?.username && { sort: { username: sort.username } }),
|
||||
});
|
||||
|
||||
const [members, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
await AuditLog.insertOne({
|
||||
ts: new Date(),
|
||||
results: total,
|
||||
u: {
|
||||
_id: this.user._id,
|
||||
username: this.user.username,
|
||||
name: this.user.name,
|
||||
...(this.user.avatarETag && { avatarETag: this.user.avatarETag }),
|
||||
},
|
||||
fields: {
|
||||
msg: 'Room_members_list',
|
||||
rids: [room._id],
|
||||
type: 'room_member_list',
|
||||
room: room.name || room.fname,
|
||||
filters: filter,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
members: redactHiddenUsers(members, hidden),
|
||||
count: members.length,
|
||||
offset: skip,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'audit.settings',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
additionalProperties: false,
|
||||
type: 'object',
|
||||
properties: {
|
||||
events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
description: 'The number of events returned in this response.',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'The number of events that were skipped in this response.',
|
||||
},
|
||||
total: {
|
||||
type: 'number',
|
||||
description: 'The total number of events that match the query.',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Indicates if the request was successful.',
|
||||
},
|
||||
},
|
||||
required: ['events', 'count', 'offset', 'total', 'success'],
|
||||
}),
|
||||
400: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [false],
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
errorType: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['success', 'error'],
|
||||
}),
|
||||
},
|
||||
query: isServerEventsAuditSettingsProps,
|
||||
authRequired: true,
|
||||
permissionsRequired: ['can-audit'],
|
||||
license: ['auditing'],
|
||||
},
|
||||
async function action() {
|
||||
const { start, end, settingId, actor } = this.queryParams;
|
||||
|
||||
if (start && isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
if (end && isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const _sort = { ts: sort?.ts ? sort?.ts : -1 };
|
||||
|
||||
const { cursor, totalCount } = ServerEvents.findPaginated(
|
||||
{
|
||||
...(settingId && { 'data.key': 'id', 'data.value': settingId }),
|
||||
...(actor && convertSubObjectsIntoPaths({ actor })),
|
||||
ts: {
|
||||
$gte: start ? new Date(start) : new Date(0),
|
||||
$lte: end ? new Date(end) : new Date(),
|
||||
},
|
||||
t: 'settings.changed',
|
||||
},
|
||||
{
|
||||
sort: _sort,
|
||||
skip: offset,
|
||||
limit: count,
|
||||
allowDiskUse: true,
|
||||
},
|
||||
);
|
||||
|
||||
const [events, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
events,
|
||||
count: events.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const auditAuditionsResponseSchema = ajv.compile<{ auditions: IAuditLog[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
auditions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
_updatedAt: { type: 'string', format: 'date-time' },
|
||||
ts: { type: 'string', format: 'date-time' },
|
||||
results: { type: 'number' },
|
||||
u: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
avatarETag: { type: 'string' },
|
||||
},
|
||||
required: ['_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
fields: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
msg: { type: ['string', 'null'] },
|
||||
startDate: { type: ['string', 'null'], format: 'date-time' },
|
||||
endDate: { type: ['string', 'null'], format: 'date-time' },
|
||||
rids: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
room: { type: ['string', 'null'] },
|
||||
users: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
visitor: { type: ['string', 'null'] },
|
||||
agent: { type: ['string', 'null'] },
|
||||
filters: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['type'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['_id', 'ts', 'results', 'u', 'fields'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['auditions', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
// `messages` items are full IMessage documents. A strict `$ref` to IMessage fails response
|
||||
// validation because its attachment union is a `oneOf` whose branches overlap on file/quote
|
||||
// attachments (AJV `passingSchemas: 0,1`), so real audit results with attachments would 400.
|
||||
// Kept relaxed until the IMessage attachment schema gains discriminators.
|
||||
const auditMessagesResponseSchema = ajv.compile<{ messages: IMessage[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
messages: { type: 'array', items: { type: 'object' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['messages', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const auditErrorResponseSchema = ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
error: { type: 'string' },
|
||||
errorType: { type: 'string' },
|
||||
},
|
||||
required: ['success', 'error'],
|
||||
});
|
||||
|
||||
const parseDateOrFail = (value: string, name: string): Date => {
|
||||
const ts = Date.parse(value);
|
||||
if (Number.isNaN(ts)) {
|
||||
throw new Error(`The "${name}" parameter must be a valid date.`);
|
||||
}
|
||||
return new Date(ts);
|
||||
};
|
||||
|
||||
API.v1.get(
|
||||
'audit.auditions',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['can-audit-log'],
|
||||
query: isAuditAuditionsProps,
|
||||
license: ['auditing'],
|
||||
rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 },
|
||||
response: {
|
||||
200: auditAuditionsResponseSchema,
|
||||
400: auditErrorResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const startDate = parseDateOrFail(this.queryParams.startDate, 'startDate');
|
||||
const endDate = parseDateOrFail(this.queryParams.endDate, 'endDate');
|
||||
|
||||
const auditions = await auditGetAuditionsMethod(this.userId, startDate, endDate);
|
||||
return API.v1.success({ auditions });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'audit.messages',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['can-audit'],
|
||||
body: isAuditMessagesProps,
|
||||
license: ['auditing'],
|
||||
rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 },
|
||||
response: {
|
||||
200: auditMessagesResponseSchema,
|
||||
400: auditErrorResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { rid, users, msg, type, visitor, agent } = this.bodyParams;
|
||||
const startDate = parseDateOrFail(this.bodyParams.startDate, 'startDate');
|
||||
const endDate = parseDateOrFail(this.bodyParams.endDate, 'endDate');
|
||||
|
||||
const messages = await auditGetMessagesMethod(this.userId, {
|
||||
rid,
|
||||
startDate,
|
||||
endDate,
|
||||
users,
|
||||
msg,
|
||||
type,
|
||||
visitor,
|
||||
agent,
|
||||
});
|
||||
|
||||
return API.v1.success({ messages });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'audit.omnichannelMessages',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['can-audit'],
|
||||
body: isAuditOmnichannelMessagesProps,
|
||||
license: ['auditing'],
|
||||
rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 },
|
||||
response: {
|
||||
200: auditMessagesResponseSchema,
|
||||
400: auditErrorResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { users, msg, type, visitor, agent } = this.bodyParams;
|
||||
const startDate = parseDateOrFail(this.bodyParams.startDate, 'startDate');
|
||||
const endDate = parseDateOrFail(this.bodyParams.endDate, 'endDate');
|
||||
|
||||
const messages = await auditGetOmnichannelMessagesMethod(this.userId, {
|
||||
startDate,
|
||||
endDate,
|
||||
users,
|
||||
msg,
|
||||
type,
|
||||
visitor,
|
||||
agent,
|
||||
});
|
||||
|
||||
return API.v1.success({ messages });
|
||||
},
|
||||
);
|
||||
61
apps/meteor/ee/server/api/chat.ts
Normal file
61
apps/meteor/ee/server/api/chat.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { IMessage, IReadReceiptWithUser } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import {
|
||||
ajv,
|
||||
isChatGetMessageReadReceiptsProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../../../server/api/api';
|
||||
import { getReadReceiptsFunction } from '../meteor-methods/getReadReceipts';
|
||||
|
||||
type GetMessageReadReceiptsProps = {
|
||||
messageId: IMessage['_id'];
|
||||
};
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/chat.getMessageReadReceipts': {
|
||||
GET: (params: GetMessageReadReceiptsProps) => {
|
||||
receipts: IReadReceiptWithUser[];
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getMessageReadReceiptsResponseSchema = ajv.compile<{ receipts: IReadReceiptWithUser[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
receipts: { type: 'array', items: { $ref: '#/components/schemas/IReadReceiptWithUser' } },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['receipts', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'chat.getMessageReadReceipts',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isChatGetMessageReadReceiptsProps,
|
||||
response: {
|
||||
200: getMessageReadReceiptsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('message-read-receipt')) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature');
|
||||
}
|
||||
|
||||
const { messageId } = this.queryParams;
|
||||
|
||||
return API.v1.success({
|
||||
receipts: await getReadReceiptsFunction(messageId, this.userId),
|
||||
});
|
||||
},
|
||||
);
|
||||
136
apps/meteor/ee/server/api/engagementDashboard/channels.ts
Normal file
136
apps/meteor/ee/server/api/engagementDashboard/channels.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { IDirectMessageRoom, IRoom } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { check, Match } from 'meteor/check';
|
||||
|
||||
import { API } from '../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../server/api/lib/getPaginationItems';
|
||||
import { findChannelsWithNumberOfMessages } from '../../lib/engagementDashboard/channels';
|
||||
import { isDateISOString, mapDateForAPI } from '../../lib/engagementDashboard/date';
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/engagement-dashboard/channels/list': {
|
||||
GET: (params: { start: string; end: string; offset?: number; count?: number }) => {
|
||||
channels: {
|
||||
room: {
|
||||
_id: IRoom['_id'];
|
||||
name: IRoom['name'];
|
||||
ts: IRoom['ts'];
|
||||
t: IRoom['t'];
|
||||
_updatedAt: IRoom['_updatedAt'];
|
||||
usernames?: IDirectMessageRoom['usernames'];
|
||||
};
|
||||
messages: number;
|
||||
lastWeekMessages: number;
|
||||
diffFromLastWeek: number;
|
||||
}[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const channelsListResponseSchema = ajv.compile<{
|
||||
channels: {
|
||||
room: {
|
||||
_id: IRoom['_id'];
|
||||
name: IRoom['name'];
|
||||
ts: IRoom['ts'];
|
||||
t: IRoom['t'];
|
||||
_updatedAt: IRoom['_updatedAt'];
|
||||
usernames?: IDirectMessageRoom['usernames'];
|
||||
};
|
||||
messages: number;
|
||||
lastWeekMessages: number;
|
||||
diffFromLastWeek: number;
|
||||
}[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
room: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
_id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
ts: { type: 'string', format: 'date-time' },
|
||||
t: { type: 'string' },
|
||||
_updatedAt: { type: 'string', format: 'date-time' },
|
||||
usernames: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
messages: { type: 'number' },
|
||||
lastWeekMessages: { type: 'number' },
|
||||
diffFromLastWeek: { type: 'number' },
|
||||
},
|
||||
required: ['room', 'messages', 'lastWeekMessages', 'diffFromLastWeek'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['channels', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/channels/list',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: channelsListResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
end: Match.Where(isDateISOString),
|
||||
offset: Match.Maybe(String),
|
||||
count: Match.Maybe(String),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start, end } = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
|
||||
const { channels, total } = await findChannelsWithNumberOfMessages({
|
||||
start: mapDateForAPI(start),
|
||||
end: mapDateForAPI(end),
|
||||
options: { offset, count },
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
channels,
|
||||
total,
|
||||
offset,
|
||||
count: channels.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
3
apps/meteor/ee/server/api/engagementDashboard/index.ts
Normal file
3
apps/meteor/ee/server/api/engagementDashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import './messages';
|
||||
import './channels';
|
||||
import './users';
|
||||
234
apps/meteor/ee/server/api/engagementDashboard/messages.ts
Normal file
234
apps/meteor/ee/server/api/engagementDashboard/messages.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { IDirectMessageRoom, IRoom } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { check, Match } from 'meteor/check';
|
||||
|
||||
import { API } from '../../../../server/api';
|
||||
import { isDateISOString, transformDatesForAPI } from '../../lib/engagementDashboard/date';
|
||||
import {
|
||||
findWeeklyMessagesSentData,
|
||||
findMessagesSentOrigin,
|
||||
findTopFivePopularChannelsByMessageSentQuantity,
|
||||
} from '../../lib/engagementDashboard/messages';
|
||||
|
||||
const weeklyDataResponseSchema = ajv.compile<{
|
||||
days: { day: Date; messages: number }[];
|
||||
period: { count: number; variation: number };
|
||||
yesterday: { count: number; variation: number };
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
days: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
day: { type: 'string', format: 'date-time' },
|
||||
messages: { type: 'number' },
|
||||
},
|
||||
required: ['day', 'messages'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
period: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
variation: { type: 'number' },
|
||||
},
|
||||
required: ['count', 'variation'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
yesterday: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
variation: { type: 'number' },
|
||||
},
|
||||
required: ['count', 'variation'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['days', 'period', 'yesterday', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const messagesOriginResponseSchema = ajv.compile<{ origins: { t: IRoom['t']; messages: number }[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
origins: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
t: { type: 'string' },
|
||||
messages: { type: 'number' },
|
||||
},
|
||||
required: ['t', 'messages'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['origins', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const topFivePopularChannelsResponseSchema = ajv.compile<{
|
||||
channels: {
|
||||
t: IRoom['t'];
|
||||
messages: number;
|
||||
name: IRoom['name'];
|
||||
usernames?: IDirectMessageRoom['usernames'];
|
||||
}[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
t: { type: 'string' },
|
||||
messages: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
usernames: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['t', 'messages'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['channels', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/engagement-dashboard/messages/origin': {
|
||||
GET: (params: { start: string; end: string }) => {
|
||||
origins: {
|
||||
t: IRoom['t'];
|
||||
messages: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'/v1/engagement-dashboard/messages/top-five-popular-channels': {
|
||||
GET: (params: { start: string; end: string }) => {
|
||||
channels: {
|
||||
t: IRoom['t'];
|
||||
messages: number;
|
||||
name: IRoom['name'];
|
||||
usernames?: IDirectMessageRoom['usernames'];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'/v1/engagement-dashboard/messages/messages-sent': {
|
||||
GET: (params: { start: string; end: string }) => {
|
||||
days: { day: Date; messages: number }[];
|
||||
period: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
yesterday: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/messages/messages-sent',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: weeklyDataResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
end: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const data = await findWeeklyMessagesSentData(transformDatesForAPI(start, end));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/messages/origin',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: messagesOriginResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
end: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const data = await findMessagesSentOrigin(transformDatesForAPI(start, end));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/messages/top-five-popular-channels',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: topFivePopularChannelsResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
end: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const data = await findTopFivePopularChannelsByMessageSentQuantity(transformDatesForAPI(start, end));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
361
apps/meteor/ee/server/api/engagementDashboard/users.ts
Normal file
361
apps/meteor/ee/server/api/engagementDashboard/users.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
ajv,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { check, Match } from 'meteor/check';
|
||||
|
||||
import { API } from '../../../../server/api';
|
||||
import { isDateISOString, transformDatesForAPI } from '../../lib/engagementDashboard/date';
|
||||
import {
|
||||
findWeeklyUsersRegisteredData,
|
||||
findActiveUsersMonthlyData,
|
||||
findBusiestsChatsInADayByHours,
|
||||
findBusiestsChatsWithinAWeek,
|
||||
findUserSessionsByHourWithinAWeek,
|
||||
} from '../../lib/engagementDashboard/users';
|
||||
|
||||
const newUsersResponseSchema = ajv.compile<{
|
||||
days: { day: Date; users: number }[];
|
||||
period: { count: number; variation: number };
|
||||
yesterday: { count: number; variation: number };
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
days: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
day: { type: 'string', format: 'date-time' },
|
||||
users: { type: 'number' },
|
||||
},
|
||||
required: ['day', 'users'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
period: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
variation: { type: 'number' },
|
||||
},
|
||||
required: ['count', 'variation'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
yesterday: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
variation: { type: 'number' },
|
||||
},
|
||||
required: ['count', 'variation'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['days', 'period', 'yesterday', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const activeUsersResponseSchema = ajv.compile<{
|
||||
month: { day: number; month: number; year: number; usersList: IUser['_id'][]; users: number }[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
day: { type: 'number' },
|
||||
month: { type: 'number' },
|
||||
year: { type: 'number' },
|
||||
usersList: { type: 'array', items: { type: 'string' } },
|
||||
users: { type: 'number' },
|
||||
},
|
||||
required: ['day', 'month', 'year', 'usersList', 'users'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['month', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const hourlyDataResponseSchema = ajv.compile<{ hours: { hour: number; users: number }[] }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
hours: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hour: { type: 'number' },
|
||||
users: { type: 'number' },
|
||||
},
|
||||
required: ['hour', 'users'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['hours', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const weeklyChatBusierResponseSchema = ajv.compile<{
|
||||
month: { day: number; month: number; year: number; users: number }[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
day: { type: 'number' },
|
||||
month: { type: 'number' },
|
||||
year: { type: 'number' },
|
||||
users: { type: 'number' },
|
||||
},
|
||||
required: ['day', 'month', 'year', 'users'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['month', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const userSessionsByHourResponseSchema = ajv.compile<{
|
||||
week: { hour: number; day: number; month: number; year: number; users: number }[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
week: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hour: { type: 'number' },
|
||||
day: { type: 'number' },
|
||||
month: { type: 'number' },
|
||||
year: { type: 'number' },
|
||||
users: { type: 'number' },
|
||||
},
|
||||
required: ['hour', 'day', 'month', 'year', 'users'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['week', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/engagement-dashboard/users/active-users': {
|
||||
GET: (params: { start: string; end: string }) => {
|
||||
month: {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
usersList: IUser['_id'][];
|
||||
users: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'/v1/engagement-dashboard/users/chat-busier/weekly-data': {
|
||||
GET: (params: { start: string }) => {
|
||||
month: {
|
||||
users: number;
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'/v1/engagement-dashboard/users/chat-busier/hourly-data': {
|
||||
GET: (params: { start: string }) => {
|
||||
hours: {
|
||||
users: number;
|
||||
hour: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'/v1/engagement-dashboard/users/users-by-time-of-the-day-in-a-week': {
|
||||
GET: (params: { start: string; end: string }) => {
|
||||
week: {
|
||||
users: number;
|
||||
hour: number;
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'/v1/engagement-dashboard/users/new-users': {
|
||||
GET: (params: { start: string; end: string }) => {
|
||||
days: { day: Date; users: number }[];
|
||||
period: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
yesterday: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/users/new-users',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: newUsersResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
end: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const data = await findWeeklyUsersRegisteredData(transformDatesForAPI(start, end));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/users/active-users',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: activeUsersResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
end: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const data = await findActiveUsersMonthlyData(transformDatesForAPI(start, end));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/users/chat-busier/hourly-data',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: hourlyDataResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start } = this.queryParams;
|
||||
|
||||
const data = await findBusiestsChatsInADayByHours(transformDatesForAPI(start));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/users/chat-busier/weekly-data',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: weeklyChatBusierResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start } = this.queryParams;
|
||||
|
||||
const data = await findBusiestsChatsWithinAWeek(transformDatesForAPI(start));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'engagement-dashboard/users/users-by-time-of-the-day-in-a-week',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-engagement-dashboard'],
|
||||
license: ['engagement-dashboard'],
|
||||
response: {
|
||||
200: userSessionsByHourResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
check(
|
||||
this.queryParams,
|
||||
Match.ObjectIncluding({
|
||||
start: Match.Where(isDateISOString),
|
||||
end: Match.Where(isDateISOString),
|
||||
}),
|
||||
);
|
||||
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const data = await findUserSessionsByHourWithinAWeek(transformDatesForAPI(start, end));
|
||||
return API.v1.success(data);
|
||||
},
|
||||
);
|
||||
53
apps/meteor/ee/server/api/federation.ts
Normal file
53
apps/meteor/ee/server/api/federation.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { FederationMatrix } from '@rocket.chat/core-services';
|
||||
import { getFederationRoutes } from '@rocket.chat/federation-matrix';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
|
||||
import type express from 'express';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
|
||||
import { API } from '../../../server/api';
|
||||
import { getTrimmedServerVersion } from '../../../server/api/lib/getTrimmedServerVersion';
|
||||
|
||||
const logger = new Logger('FederationRoutes');
|
||||
|
||||
API.v1.get(
|
||||
'/federation/matrixIds.verify',
|
||||
{
|
||||
authRequired: true,
|
||||
query: ajvQuery.compile<{
|
||||
matrixIds: string[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
matrixIds: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
}),
|
||||
response: {
|
||||
200: ajv.compile<{
|
||||
results: { [key: string]: string };
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: { type: 'object', additionalProperties: { type: 'string' } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
async function () {
|
||||
const { matrixIds } = this.queryParams;
|
||||
return API.v1.success({
|
||||
results: await FederationMatrix.verifyMatrixIds(matrixIds),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export async function registerFederationRoutes(): Promise<void> {
|
||||
try {
|
||||
const routes = getFederationRoutes(getTrimmedServerVersion());
|
||||
|
||||
(WebApp.rawConnectHandlers as unknown as ReturnType<typeof express>).use(routes.matrix.router).use(routes.wellKnown.router);
|
||||
} catch (error) {
|
||||
logger.error({ msg: '[Federation] Failed to register routes:', err: error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
11
apps/meteor/ee/server/api/index.ts
Normal file
11
apps/meteor/ee/server/api/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import './api';
|
||||
import './ldap';
|
||||
import './licenses';
|
||||
import './sessions';
|
||||
import './chat';
|
||||
import './roles';
|
||||
import '../apps/communication/uikit';
|
||||
import './engagementDashboard';
|
||||
import './audit';
|
||||
import './abac';
|
||||
import './mcp';
|
||||
50
apps/meteor/ee/server/api/ldap.ts
Normal file
50
apps/meteor/ee/server/api/ldap.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { LDAPEnterprise } from '@rocket.chat/core-services';
|
||||
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../../../server/api/api';
|
||||
import { hasPermissionAsync } from '../../../server/lib/authorization/hasPermission';
|
||||
import { settings } from '../../../server/settings';
|
||||
|
||||
const ldapSyncNowResponseSchema = ajv.compile<{ message: string }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['message', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'ldap.syncNow',
|
||||
{
|
||||
authRequired: true,
|
||||
forceTwoFactorAuthenticationForNonEnterprise: true,
|
||||
twoFactorRequired: true,
|
||||
response: {
|
||||
200: ldapSyncNowResponseSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!this.userId) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'sync-auth-services-users'))) {
|
||||
throw new Error('error-not-authorized');
|
||||
}
|
||||
|
||||
if (settings.get('LDAP_Enable') !== true) {
|
||||
throw new Error('LDAP_disabled');
|
||||
}
|
||||
|
||||
await LDAPEnterprise.sync();
|
||||
await LDAPEnterprise.syncAvatarAndAbacAttributes();
|
||||
|
||||
return API.v1.success({
|
||||
message: 'Sync_in_progress' as const,
|
||||
});
|
||||
},
|
||||
);
|
||||
168
apps/meteor/ee/server/api/lib/canned-responses.js
Normal file
168
apps/meteor/ee/server/api/lib/canned-responses.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { CannedResponse } from '@rocket.chat/models';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { hasPermissionAsync } from '../../../../server/lib/authorization/hasPermission';
|
||||
import { getDepartmentsWhichUserCanAccess } from '../v1/omnichannel/lib/departments';
|
||||
|
||||
export async function findAllCannedResponses({ userId }) {
|
||||
// If the user is an admin or livechat manager, get his own responses and all responses from all departments
|
||||
if (await hasPermissionAsync(userId, 'view-all-canned-responses')) {
|
||||
const cannedResponses = await CannedResponse.find({
|
||||
$or: [
|
||||
{
|
||||
scope: 'user',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
scope: 'department',
|
||||
},
|
||||
{
|
||||
scope: 'global',
|
||||
},
|
||||
],
|
||||
}).toArray();
|
||||
return cannedResponses;
|
||||
}
|
||||
|
||||
// Last scenario: user is an agent, so get his own responses and those from the departments he is in
|
||||
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId, true);
|
||||
|
||||
const cannedResponses = await CannedResponse.find({
|
||||
$or: [
|
||||
{
|
||||
scope: 'user',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
scope: 'department',
|
||||
departmentId: {
|
||||
$in: accessibleDepartments,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: 'global',
|
||||
},
|
||||
],
|
||||
}).toArray();
|
||||
|
||||
return cannedResponses;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} param0
|
||||
* @param {String} param0.userId
|
||||
* @param {String} [param0.shortcut]
|
||||
* @param {String} [param0.text]
|
||||
* @param {String} [param0.departmentId]
|
||||
* @param {String} [param0.scope]
|
||||
* @param {String} [param0.createdBy]
|
||||
* @param {String[]} [param0.tags]
|
||||
* @param {Object} param0.options
|
||||
* @param {Number} param0.options.offset
|
||||
* @param {Number} param0.options.count
|
||||
* @param {Object} param0.options.sort
|
||||
* @param {Object} param0.options.fields
|
||||
*/
|
||||
export async function findAllCannedResponsesFilter({ userId, shortcut, text, departmentId, scope, createdBy, tags = [], options = {} }) {
|
||||
let extraFilter = [];
|
||||
// if user cannot see all, filter to private + public + departments user is in
|
||||
if (!(await hasPermissionAsync(userId, 'view-all-canned-responses'))) {
|
||||
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId, true);
|
||||
const isDepartmentInScope = (departmentId) => !!accessibleDepartments.includes(departmentId);
|
||||
|
||||
const departmentIds = departmentId && isDepartmentInScope(departmentId) ? [departmentId] : accessibleDepartments;
|
||||
|
||||
extraFilter = [
|
||||
{
|
||||
$or: [
|
||||
{
|
||||
scope: 'user',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
scope: 'department',
|
||||
departmentId: {
|
||||
$in: departmentIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: 'global',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
extraFilter = [
|
||||
{
|
||||
departmentId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const textFilter = new RegExp(escapeRegExp(text), 'i');
|
||||
|
||||
let filter = {
|
||||
$and: [
|
||||
...(shortcut ? [{ shortcut }] : []),
|
||||
...(text ? [{ $or: [{ shortcut: textFilter }, { text: textFilter }] }] : []),
|
||||
...(scope ? [{ scope }] : []),
|
||||
...(createdBy ? [{ 'createdBy._id': createdBy }] : []),
|
||||
...(tags.length
|
||||
? [
|
||||
{
|
||||
tags: {
|
||||
$in: tags,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...extraFilter,
|
||||
],
|
||||
};
|
||||
|
||||
if (!filter.$and.length) {
|
||||
filter = {};
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = CannedResponse.findPaginated(filter, {
|
||||
sort: options.sort || { shortcut: 1 },
|
||||
skip: options.offset,
|
||||
limit: options.count,
|
||||
});
|
||||
const [cannedResponses, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
return {
|
||||
cannedResponses,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOneCannedResponse({ userId, _id }) {
|
||||
if (await hasPermissionAsync(userId, 'view-all-canned-responses')) {
|
||||
return CannedResponse.findOneById(_id);
|
||||
}
|
||||
|
||||
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId, true);
|
||||
|
||||
const filter = {
|
||||
_id,
|
||||
$or: [
|
||||
{
|
||||
scope: 'user',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
scope: 'department',
|
||||
departmentId: {
|
||||
$in: accessibleDepartments,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: 'global',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return CannedResponse.findOne(filter);
|
||||
}
|
||||
182
apps/meteor/ee/server/api/licenses.ts
Normal file
182
apps/meteor/ee/server/api/licenses.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Cloud, LicenseInfo } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Settings, Users } from '@rocket.chat/models';
|
||||
import {
|
||||
ajv,
|
||||
isLicensesAddProps,
|
||||
isLicensesInfoProps,
|
||||
isLicensesValidateProps,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../../../server/api/api';
|
||||
import { hasPermissionAsync } from '../../../server/lib/authorization/hasPermission';
|
||||
import { notifyOnSettingChangedById } from '../../../server/lib/notifyListener';
|
||||
import { settings } from '../../../server/settings';
|
||||
import { updateAuditedByUser } from '../../../server/settings/lib/auditedSettingUpdates';
|
||||
|
||||
// LicenseInfo is a large, loosely-matching shape (branded LicenseModule names, partial `limits`,
|
||||
// a nested ILicenseV3) that a strict typia $ref can't validate without fighting the type, so the
|
||||
// license/announcement payloads are validated as open objects (same relaxation as omnichannel config).
|
||||
const licensesInfoResponseSchema = ajv.compile<{ license: LicenseInfo; cloudSyncAnnouncement?: Cloud.ICloudSyncAnnouncement }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
license: { type: 'object' },
|
||||
cloudSyncAnnouncement: { type: 'object' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['license', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const successResponseSchema = ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: { success: { type: 'boolean', enum: [true] } },
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const licensesValidateErrorSchema = ajv.compile<{
|
||||
success: false;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
stack?: string;
|
||||
reasons?: unknown[];
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
error: { type: 'string' },
|
||||
errorType: { type: 'string' },
|
||||
stack: { type: 'string' },
|
||||
reasons: { type: 'array' },
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const licensesMaxActiveUsersResponseSchema = ajv.compile<{ maxActiveUsers: number | null; activeUsers: number }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
maxActiveUsers: { type: ['number', 'null'] },
|
||||
activeUsers: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['maxActiveUsers', 'activeUsers', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'licenses.info',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isLicensesInfoProps,
|
||||
response: {
|
||||
200: licensesInfoResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const unrestrictedAccess = await hasPermissionAsync(this.user, 'view-privileged-setting');
|
||||
const loadCurrentValues = unrestrictedAccess && Boolean(this.queryParams.loadValues);
|
||||
|
||||
const license = await License.getInfo({
|
||||
limits: unrestrictedAccess,
|
||||
license: unrestrictedAccess,
|
||||
currentValues: loadCurrentValues,
|
||||
});
|
||||
|
||||
let cloudSyncAnnouncement;
|
||||
try {
|
||||
// TODO: Remove this logic after setting type object is implemented.
|
||||
cloudSyncAnnouncement = JSON.parse(settings.get('Cloud_Sync_Announcement_Payload') ?? null);
|
||||
} catch (error) {
|
||||
console.error('Unable to parse Cloud_Sync_Announcement_Payload');
|
||||
}
|
||||
|
||||
const canManageCloud = await hasPermissionAsync(this.user, 'manage-cloud');
|
||||
return API.v1.success({
|
||||
license,
|
||||
...(canManageCloud && cloudSyncAnnouncement && { cloudSyncAnnouncement }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'licenses.add',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['edit-privileged-setting'],
|
||||
body: isLicensesAddProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { license } = this.bodyParams;
|
||||
if (!(await License.validateFormat(license))) {
|
||||
return API.v1.failure('Invalid license');
|
||||
}
|
||||
|
||||
const auditSettingOperation = updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username ?? '',
|
||||
ip: this.requestIp ?? '',
|
||||
useragent: this.request.headers.get('user-agent') || '',
|
||||
});
|
||||
|
||||
(await auditSettingOperation(Settings.updateValueById, 'Enterprise_License', license)).modifiedCount &&
|
||||
void notifyOnSettingChangedById('Enterprise_License');
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'licenses.validate',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['edit-privileged-setting'],
|
||||
body: isLicensesValidateProps,
|
||||
response: {
|
||||
200: successResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: licensesValidateErrorSchema,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { license } = this.bodyParams;
|
||||
|
||||
const result = await License.validateLicenseForPreview(license);
|
||||
|
||||
if (!result.valid) {
|
||||
return API.v1.failure({ error: 'license-invalid', reasons: result.reasons });
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'licenses.maxActiveUsers',
|
||||
{
|
||||
authRequired: true,
|
||||
response: {
|
||||
200: licensesMaxActiveUsersResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const maxActiveUsers = License.getMaxActiveUsers();
|
||||
const activeUsers = await Users.getActiveLocalUserCount();
|
||||
|
||||
return API.v1.success({ maxActiveUsers: maxActiveUsers > 0 ? maxActiveUsers : null, activeUsers });
|
||||
},
|
||||
);
|
||||
235
apps/meteor/ee/server/api/mcp/catalog.spec.ts
Normal file
235
apps/meteor/ee/server/api/mcp/catalog.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { getCuratedTools, getExtendedTools } from './catalog';
|
||||
|
||||
jest.mock('../../../../server/api', () => ({
|
||||
API: {
|
||||
api: {
|
||||
typedRoutes: {
|
||||
'/api/v1/chat.postMessage': {
|
||||
post: {
|
||||
tags: ['Chat'],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
description: 'Post by room id',
|
||||
properties: { roomId: { type: 'string' }, text: { type: 'string', nullable: true } },
|
||||
required: ['roomId'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
description: 'Post by channel',
|
||||
properties: { channel: { type: 'string' }, text: { type: 'string', nullable: true } },
|
||||
required: ['channel'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/rooms.get': {
|
||||
get: {
|
||||
tags: ['Rooms'],
|
||||
parameters: [
|
||||
{
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { updatedSince: { type: 'string' } },
|
||||
additionalProperties: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'/api/v1/channels.create': {
|
||||
post: {
|
||||
tags: ['Missing Documentation'],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/channels.list.joined': {
|
||||
get: {
|
||||
tags: ['Missing Documentation'],
|
||||
parameters: [{ schema: { type: 'object', properties: { count: { type: 'number' } } } }],
|
||||
},
|
||||
},
|
||||
'/api/v1/rooms.isMember': {
|
||||
get: {
|
||||
tags: ['Rooms'],
|
||||
parameters: [
|
||||
{
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string' },
|
||||
userId: { type: 'string' },
|
||||
username: { type: 'string' },
|
||||
},
|
||||
oneOf: [
|
||||
{ type: 'object', required: ['roomId', 'userId'] },
|
||||
{ type: 'object', required: ['roomId', 'username'] },
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'/api/v1/teams.listRoomsOfUser': {
|
||||
get: {
|
||||
tags: ['Teams'],
|
||||
parameters: [
|
||||
{
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
teamId: { type: 'string' },
|
||||
teamName: { type: 'string' },
|
||||
userId: { type: 'string' },
|
||||
},
|
||||
oneOf: [
|
||||
{ type: 'object', required: ['teamId'] },
|
||||
{ type: 'object', required: ['teamName'] },
|
||||
],
|
||||
required: ['userId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'/api/v1/dm.files': {
|
||||
get: {
|
||||
tags: ['DM'],
|
||||
parameters: [
|
||||
{
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha: { type: 'string' } },
|
||||
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta: { type: 'string' } },
|
||||
required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'/api/v1/users.delete': {
|
||||
post: {
|
||||
tags: ['Users'],
|
||||
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
|
||||
},
|
||||
},
|
||||
'/api/v1/users.register': {
|
||||
post: {
|
||||
tags: ['Users'],
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MCP tool catalog', () => {
|
||||
it('creates one curated tool per discriminated request variant', () => {
|
||||
const tools = getCuratedTools();
|
||||
|
||||
expect(tools.map(({ name }) => name)).toEqual([
|
||||
'post_chat_postMessage_by_roomId',
|
||||
'post_chat_postMessage_by_channel',
|
||||
'post_channels_create',
|
||||
'get_channels_list_joined',
|
||||
'get_rooms_get',
|
||||
]);
|
||||
expect(tools[0]?.inputSchema).toEqual({
|
||||
type: 'object',
|
||||
description: 'Post by room id',
|
||||
properties: { roomId: { type: 'string' }, text: { type: 'string' } },
|
||||
required: ['roomId'],
|
||||
});
|
||||
expect(tools[4]?.inputSchema).toMatchObject({ additionalProperties: { type: 'string' } });
|
||||
});
|
||||
|
||||
it('only exposes allow-listed routes in the extended catalog', () => {
|
||||
const tools = getExtendedTools();
|
||||
|
||||
expect(tools.map(({ name }) => name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'post_chat_postMessage_by_roomId',
|
||||
'post_chat_postMessage_by_channel',
|
||||
'post_channels_create',
|
||||
'get_channels_list_joined',
|
||||
'get_rooms_get',
|
||||
'get_rooms_isMember_by_roomId_userId',
|
||||
'get_rooms_isMember_by_roomId_username',
|
||||
]),
|
||||
);
|
||||
expect(tools.some(({ name }) => name.includes('users_delete'))).toBe(false);
|
||||
expect(tools.some(({ name }) => name.includes('users_register'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps curated tool names stable when the extended catalog is enabled', () => {
|
||||
const extendedNames = new Set(getExtendedTools().map(({ name }) => name));
|
||||
|
||||
expect(getCuratedTools().every(({ name }) => extendedNames.has(name))).toBe(true);
|
||||
});
|
||||
|
||||
it('generates unique valid names when variant discriminators exceed the MCP limit', () => {
|
||||
const tools = getExtendedTools();
|
||||
const names = tools.map(({ name }) => name);
|
||||
const dmFileNames = tools.filter(({ path }) => path === '/api/v1/dm.files').map(({ name }) => name);
|
||||
|
||||
expect(dmFileNames).toHaveLength(2);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names.every((name) => name.length <= 64 && /^[a-zA-Z0-9_-]+$/.test(name))).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves parent properties when variants only declare required fields', () => {
|
||||
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_rooms_isMember'));
|
||||
|
||||
for (const { inputSchema } of tools) {
|
||||
const properties = inputSchema.properties as Record<string, unknown>;
|
||||
for (const requiredProperty of inputSchema.required as string[]) {
|
||||
expect(properties).toHaveProperty(requiredProperty);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves shared required fields without changing variant discriminators', () => {
|
||||
const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_teams_listRoomsOfUser'));
|
||||
|
||||
expect(tools.map(({ name }) => name)).toEqual(['get_teams_listRoomsOfUser_by_teamId', 'get_teams_listRoomsOfUser_by_teamName']);
|
||||
for (const { inputSchema } of tools) {
|
||||
expect(inputSchema.required).toEqual(expect.arrayContaining(['userId']));
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the generated catalogs between requests', () => {
|
||||
expect(getCuratedTools()).toBe(getCuratedTools());
|
||||
expect(getExtendedTools()).toBe(getExtendedTools());
|
||||
});
|
||||
});
|
||||
420
apps/meteor/ee/server/api/mcp/catalog.ts
Normal file
420
apps/meteor/ee/server/api/mcp/catalog.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { API } from '../../../../server/api';
|
||||
|
||||
export type McpMethod = 'get' | 'post' | 'put' | 'delete';
|
||||
|
||||
export type McpTool = {
|
||||
/** MCP tool name exposed to the client (must match ^[a-zA-Z0-9_-]{1,64}$). */
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
/** Internal: the REST route this tool maps to. */
|
||||
path: string;
|
||||
method: McpMethod;
|
||||
};
|
||||
|
||||
const CURATED: { path: string; method: McpMethod; fallbackDescription?: string }[] = [
|
||||
{ path: '/api/v1/chat.postMessage', method: 'post' },
|
||||
{ path: '/api/v1/chat.getMessage', method: 'get' },
|
||||
{
|
||||
path: '/api/v1/channels.create',
|
||||
method: 'post',
|
||||
fallbackDescription: 'Create a public channel. Requires `name`; optional `members` (array of usernames).',
|
||||
},
|
||||
{
|
||||
path: '/api/v1/channels.list.joined',
|
||||
method: 'get',
|
||||
fallbackDescription: 'List the public channels the authenticated user has joined.',
|
||||
},
|
||||
{
|
||||
path: '/api/v1/rooms.get',
|
||||
method: 'get',
|
||||
fallbackDescription: 'List rooms the authenticated user has access to (optionally updated since a timestamp).',
|
||||
},
|
||||
{ path: '/api/v1/users.info', method: 'get' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Allow-list for the *extended* toolset, expressed as the base tool name per route — i.e.
|
||||
* the `toolNameFor(path, method)` value, WITHOUT any `_by_<discriminator>` variant suffix.
|
||||
* Matched at the route level, so a single entry (e.g. `get_users_info`) exposes every
|
||||
* variant of that route (`get_users_info_by_userId`, `_by_username`, …).
|
||||
*
|
||||
* The extended set is the full catalog filtered by these names — the entire API is never
|
||||
* exposed.
|
||||
*/
|
||||
const ALLOWED_TOOL_NAMES = new Set<string>([
|
||||
// chat — reading
|
||||
'get_chat_getDiscussions',
|
||||
'get_chat_getMentionedMessages',
|
||||
'get_chat_getMessage',
|
||||
'get_chat_getPinnedMessages',
|
||||
'get_chat_getStarredMessages',
|
||||
'get_chat_getThreadMessages',
|
||||
'get_chat_getThreadsList',
|
||||
'get_chat_search',
|
||||
'get_chat_syncMessages',
|
||||
'get_chat_syncThreadMessages',
|
||||
'get_chat_syncThreadsList',
|
||||
// custom user status
|
||||
'get_custom_user_status_list',
|
||||
// direct messages — reading
|
||||
'get_dm_files',
|
||||
'get_dm_history',
|
||||
'get_dm_list',
|
||||
'get_dm_list_everyone',
|
||||
'get_dm_members',
|
||||
'get_dm_messages',
|
||||
'get_dm_messages_others',
|
||||
// me
|
||||
'get_me',
|
||||
// rooms — reading
|
||||
'get_rooms_autocomplete_availableForTeams',
|
||||
'get_rooms_autocomplete_channelAndPrivate',
|
||||
'get_rooms_get',
|
||||
'get_rooms_getDiscussions',
|
||||
'get_rooms_info',
|
||||
'get_rooms_isMember',
|
||||
'get_rooms_membersOrderedByRole',
|
||||
'get_rooms_nameExists',
|
||||
// search
|
||||
'get_spotlight',
|
||||
// subscriptions — reading
|
||||
'get_subscriptions_get',
|
||||
'get_subscriptions_getOne',
|
||||
// teams — reading
|
||||
'get_teams_autocomplete',
|
||||
'get_teams_info',
|
||||
'get_teams_list',
|
||||
'get_teams_listAll',
|
||||
'get_teams_listChildren',
|
||||
'get_teams_listRooms',
|
||||
'get_teams_listRoomsOfUser',
|
||||
'get_teams_members',
|
||||
// users — reading
|
||||
'get_users_autocomplete',
|
||||
'get_users_checkUsernameAvailability',
|
||||
'get_users_getPreferences',
|
||||
'get_users_getPresence',
|
||||
'get_users_getStatus',
|
||||
'get_users_info',
|
||||
'get_users_listTeams',
|
||||
// chat — writing
|
||||
// 'post_chat_delete',
|
||||
'post_chat_followMessage',
|
||||
'post_chat_pinMessage',
|
||||
'post_chat_postMessage',
|
||||
'post_chat_react',
|
||||
'post_chat_reportMessage',
|
||||
'post_chat_sendMessage',
|
||||
'post_chat_starMessage',
|
||||
'post_chat_unfollowMessage',
|
||||
'post_chat_unPinMessage',
|
||||
'post_chat_unStarMessage',
|
||||
'post_chat_update',
|
||||
// custom user status — writing
|
||||
'post_custom_user_status_create',
|
||||
// 'post_custom_user_status_delete',
|
||||
'post_custom_user_status_update',
|
||||
// direct messages — writing
|
||||
'post_dm_close',
|
||||
'post_dm_create',
|
||||
// 'post_dm_delete',
|
||||
'post_dm_open',
|
||||
'post_dm_setTopic',
|
||||
'post_im_blockUser',
|
||||
// rooms — writing
|
||||
'post_rooms_banUser',
|
||||
'post_rooms_changeArchivationState',
|
||||
'post_rooms_createDiscussion',
|
||||
// 'post_rooms_delete',
|
||||
'post_rooms_favorite',
|
||||
'post_rooms_hide',
|
||||
'post_rooms_invite',
|
||||
'post_rooms_join',
|
||||
'post_rooms_leave',
|
||||
'post_rooms_muteUser',
|
||||
'post_rooms_open',
|
||||
'post_rooms_saveRoomSettings',
|
||||
'post_rooms_unbanUser',
|
||||
'post_rooms_unmuteUser',
|
||||
// subscriptions — writing
|
||||
'post_subscriptions_read',
|
||||
'post_subscriptions_unread',
|
||||
// teams — writing
|
||||
'post_teams_addMembers',
|
||||
'post_teams_addRooms',
|
||||
'post_teams_convertToChannel',
|
||||
'post_teams_create',
|
||||
// 'post_teams_delete',
|
||||
'post_teams_leave',
|
||||
'post_teams_removeMember',
|
||||
'post_teams_removeRoom',
|
||||
'post_teams_update',
|
||||
'post_teams_updateMember',
|
||||
'post_teams_updateRoom',
|
||||
// uploads
|
||||
// 'post_uploads_delete',
|
||||
// users — writing
|
||||
'post_users_create',
|
||||
// `users.register` rejects authenticated callers, while every MCP call is authenticated.
|
||||
'post_users_setStatus',
|
||||
'post_users_update',
|
||||
'post_users_updateOwnBasicInfo',
|
||||
]);
|
||||
|
||||
const FALLBACK_SCHEMA: Record<string, unknown> = { type: 'object', additionalProperties: true };
|
||||
|
||||
const COMBINATOR_KEYS = ['oneOf', 'anyOf', 'allOf'] as const;
|
||||
|
||||
/**
|
||||
* Transform a route's JSON Schema into one accepted by MCP clients / the Anthropic tools
|
||||
* API, which require a plain object schema and reject combinator keywords:
|
||||
* - strip OpenAPI-only `nullable` (not valid JSON Schema 2020-12) and `not`,
|
||||
* - resolve `oneOf`/`anyOf`/`allOf` by adopting the FIRST branch. For a schema built as
|
||||
* `{ oneOf: [subSchemaA, subSchemaB] }` this means the MCP tool uses one named
|
||||
* sub-schema (e.g. post-by-channel); for a bare value union like `string | string[]`
|
||||
* it keeps a concrete type. The REST layer still enforces the full rule on dispatch,
|
||||
* so this only shapes the advertised tool schema — the validator is untouched.
|
||||
*/
|
||||
const mcpSafeSchema = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(mcpSafeSchema);
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
let node: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (key === 'nullable' || key === 'not') {
|
||||
continue;
|
||||
}
|
||||
node[key] = mcpSafeSchema(val);
|
||||
}
|
||||
|
||||
const combinator = COMBINATOR_KEYS.find((key) => Array.isArray(node[key]) && (node[key] as unknown[]).length > 0);
|
||||
if (combinator) {
|
||||
const firstBranch = mcpSafeSchema((node[combinator] as unknown[])[0]) as Record<string, unknown>;
|
||||
for (const key of COMBINATOR_KEYS) {
|
||||
delete node[key];
|
||||
}
|
||||
// The first branch supplies the structure (type/properties/required); this node's
|
||||
// own keys (e.g. a top-level `description`) win on top.
|
||||
node = { ...firstBranch, ...node };
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const ensureObjectSchema = (schema: unknown): Record<string, unknown> => {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return FALLBACK_SCHEMA;
|
||||
}
|
||||
const obj = mcpSafeSchema(schema) as Record<string, unknown>;
|
||||
return obj.type === 'object' ? obj : { ...obj, type: 'object' };
|
||||
};
|
||||
|
||||
/** Pull the raw request JSON-Schema for a route (the main schema, before flattening). */
|
||||
const rawSchemaForRoute = (path: string, method: McpMethod): unknown => {
|
||||
const route = API.api.typedRoutes?.[path]?.[method];
|
||||
if (!route) {
|
||||
return undefined;
|
||||
}
|
||||
return method === 'post' || method === 'put' ? route.requestBody?.content?.['application/json']?.schema : route.parameters?.[0]?.schema;
|
||||
};
|
||||
|
||||
const VARIANT_KEYS = ['oneOf', 'anyOf'] as const;
|
||||
const MAX_TOOL_NAME_LENGTH = 64;
|
||||
|
||||
const mergeSchemaVariant = (
|
||||
root: Record<string, unknown>,
|
||||
variantKey: (typeof VARIANT_KEYS)[number],
|
||||
variant: unknown,
|
||||
): Record<string, unknown> => {
|
||||
const { [variantKey]: _variants, ...baseSchema } = root;
|
||||
if (!variant || typeof variant !== 'object' || Array.isArray(variant)) {
|
||||
return ensureObjectSchema(baseSchema);
|
||||
}
|
||||
|
||||
const branch = variant as Record<string, unknown>;
|
||||
const baseProperties =
|
||||
baseSchema.properties && typeof baseSchema.properties === 'object' && !Array.isArray(baseSchema.properties)
|
||||
? (baseSchema.properties as Record<string, unknown>)
|
||||
: undefined;
|
||||
const branchProperties =
|
||||
branch.properties && typeof branch.properties === 'object' && !Array.isArray(branch.properties)
|
||||
? (branch.properties as Record<string, unknown>)
|
||||
: undefined;
|
||||
const baseRequired = Array.isArray(baseSchema.required) ? baseSchema.required : [];
|
||||
const branchRequired = Array.isArray(branch.required) ? branch.required : [];
|
||||
const required = [...new Set([...baseRequired, ...branchRequired])];
|
||||
|
||||
return ensureObjectSchema({
|
||||
...baseSchema,
|
||||
...branch,
|
||||
...((baseProperties || branchProperties) && { properties: { ...baseProperties, ...branchProperties } }),
|
||||
...(required.length > 0 && { required }),
|
||||
});
|
||||
};
|
||||
|
||||
/** A branch's discriminator = its `required` keys (e.g. `channel`, `roomId`, `userId`). */
|
||||
const discriminatorOf = (schema: Record<string, unknown>): string | undefined => {
|
||||
const { required } = schema;
|
||||
if (Array.isArray(required) && required.length > 0 && required.every((r) => typeof r === 'string')) {
|
||||
return required.join('_');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type SchemaVariant = { discriminator?: string; description?: string; schema: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* Split a route's request schema into MCP-ready variants. When the schema is a
|
||||
* `oneOf`/`anyOf` of object sub-schemas with distinct discriminators (e.g.
|
||||
* post-by-channel vs post-by-roomId), each becomes its own variant; otherwise a single
|
||||
* flattened object schema is returned.
|
||||
*/
|
||||
const variantsForRoute = (path: string, method: McpMethod): SchemaVariant[] => {
|
||||
const raw = rawSchemaForRoute(path, method);
|
||||
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
const root = raw as Record<string, unknown>;
|
||||
const key = VARIANT_KEYS.find((k) => Array.isArray(root[k]) && (root[k] as unknown[]).length > 1);
|
||||
if (key) {
|
||||
const mainDescription = typeof root.description === 'string' ? root.description : undefined;
|
||||
const rawBranches = root[key] as unknown[];
|
||||
const branches = rawBranches.map((branch) => mergeSchemaVariant(root, key, branch));
|
||||
const discriminators = rawBranches.map((branch) => discriminatorOf(ensureObjectSchema(branch)));
|
||||
const allDistinct = discriminators.every(Boolean) && new Set(discriminators).size === discriminators.length;
|
||||
if (allDistinct) {
|
||||
return branches.map((schema, i) => ({
|
||||
discriminator: discriminators[i],
|
||||
description: typeof schema.description === 'string' ? schema.description : mainDescription,
|
||||
schema,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const schema = ensureObjectSchema(raw);
|
||||
return [{ description: typeof schema.description === 'string' ? schema.description : undefined, schema }];
|
||||
};
|
||||
|
||||
const fitToolName = (baseName: string, discriminator?: string): string => {
|
||||
const suffix = discriminator ? `_by_${discriminator.replace(/[^a-zA-Z0-9_-]+/g, '_')}` : '';
|
||||
const fullName = `${baseName}${suffix}`;
|
||||
if (fullName.length <= MAX_TOOL_NAME_LENGTH) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
const minimumBaseLength = Math.min(baseName.length, 16);
|
||||
if (suffix.length <= MAX_TOOL_NAME_LENGTH - minimumBaseLength) {
|
||||
return `${baseName.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length)}${suffix}`;
|
||||
}
|
||||
|
||||
const hash = createHash('sha256').update(fullName).digest('hex').slice(0, 8);
|
||||
return `${fullName.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
||||
};
|
||||
|
||||
const ensureUniqueToolNames = (tools: McpTool[]): McpTool[] => {
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
return tools.map((tool, index) => {
|
||||
if (!usedNames.has(tool.name)) {
|
||||
usedNames.add(tool.name);
|
||||
return tool;
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
let candidate: string;
|
||||
do {
|
||||
const hash = createHash('sha256')
|
||||
.update(`${tool.method}:${tool.path}:${tool.name}:${JSON.stringify(tool.inputSchema)}:${index}:${attempt}`)
|
||||
.digest('hex')
|
||||
.slice(0, 8);
|
||||
candidate = `${tool.name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
||||
attempt += 1;
|
||||
} while (usedNames.has(candidate));
|
||||
|
||||
usedNames.add(candidate);
|
||||
return { ...tool, name: candidate };
|
||||
});
|
||||
};
|
||||
|
||||
const toolsForRoute = (baseName: string, path: string, method: McpMethod, fallbackDescription?: string): McpTool[] =>
|
||||
variantsForRoute(path, method).map((variant) => ({
|
||||
name: fitToolName(baseName, variant.discriminator),
|
||||
description: variant.description ?? fallbackDescription ?? `${method.toUpperCase()} ${path}`,
|
||||
path,
|
||||
method,
|
||||
inputSchema: variant.schema,
|
||||
}));
|
||||
|
||||
const toolNameFor = (path: string, method: McpMethod): string => {
|
||||
const slug = path.replace(/^\/api\/v\d+\//, '').replace(/[^a-zA-Z0-9]+/g, '_');
|
||||
return `${method}_${slug}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk the documented routes (excluding `Missing Documentation`, mirroring the OpenAPI
|
||||
* filter) and emit one or more tools per route, keeping only routes whose base tool name
|
||||
* passes `isRouteAllowed`. The filter is applied to the base name, so all `_by_` variants
|
||||
* of an allowed route are included together.
|
||||
*/
|
||||
const collectTools = (isRouteAllowed: (baseName: string) => boolean): McpTool[] => {
|
||||
const tools: McpTool[] = [];
|
||||
|
||||
for (const [path, methods] of Object.entries(API.api.typedRoutes ?? {})) {
|
||||
for (const [method, route] of Object.entries(methods)) {
|
||||
if (route?.tags?.includes('Missing Documentation')) {
|
||||
continue;
|
||||
}
|
||||
if (!['get', 'post', 'put', 'delete'].includes(method)) {
|
||||
continue;
|
||||
}
|
||||
const baseName = toolNameFor(path, method as McpMethod);
|
||||
if (!isRouteAllowed(baseName)) {
|
||||
continue;
|
||||
}
|
||||
const fallback = route?.tags?.length
|
||||
? `${method.toUpperCase()} ${path} (${route.tags.join(', ')})`
|
||||
: `${method.toUpperCase()} ${path}`;
|
||||
tools.push(...toolsForRoute(baseName, path, method as McpMethod, fallback));
|
||||
}
|
||||
}
|
||||
|
||||
return ensureUniqueToolNames(tools);
|
||||
};
|
||||
|
||||
let curatedTools: McpTool[] | undefined;
|
||||
|
||||
export const getCuratedTools = (): McpTool[] => {
|
||||
curatedTools ??= ensureUniqueToolNames(
|
||||
CURATED.filter(({ path, method }) => Boolean(API.api.typedRoutes?.[path]?.[method])).flatMap(({ path, method, fallbackDescription }) =>
|
||||
toolsForRoute(toolNameFor(path, method), path, method, fallbackDescription),
|
||||
),
|
||||
);
|
||||
|
||||
return curatedTools;
|
||||
};
|
||||
|
||||
/**
|
||||
* The extended toolset — the full catalog filtered by {@link ALLOWED_TOOL_NAMES}. The
|
||||
* entire API is never exposed; routes outside the allow-list (and `Missing Documentation`
|
||||
* routes) are excluded.
|
||||
*/
|
||||
let extendedTools: McpTool[] | undefined;
|
||||
|
||||
export const getExtendedTools = (): McpTool[] => {
|
||||
if (!extendedTools) {
|
||||
const toolsByName = new Map(getCuratedTools().map((tool) => [tool.name, tool]));
|
||||
for (const tool of collectTools((baseName) => ALLOWED_TOOL_NAMES.has(baseName))) {
|
||||
toolsByName.set(tool.name, tool);
|
||||
}
|
||||
extendedTools = [...toolsByName.values()];
|
||||
}
|
||||
|
||||
return extendedTools;
|
||||
};
|
||||
156
apps/meteor/ee/server/api/mcp/dispatch.spec.ts
Normal file
156
apps/meteor/ee/server/api/mcp/dispatch.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { createMcpResponseBudget, dispatchTool } from './dispatch';
|
||||
import type { McpAuth } from './server';
|
||||
|
||||
const auth: McpAuth = {
|
||||
userId: 'user-id',
|
||||
authToken: 'auth-token',
|
||||
};
|
||||
|
||||
describe('MCP tool dispatch', () => {
|
||||
const originalPort = process.env.PORT;
|
||||
const runtimeGlobal = globalThis as typeof globalThis & { __meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string } };
|
||||
const originalRuntimeConfig = runtimeGlobal.__meteor_runtime_config__;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
if (originalPort === undefined) {
|
||||
delete process.env.PORT;
|
||||
} else {
|
||||
process.env.PORT = originalPort;
|
||||
}
|
||||
if (originalRuntimeConfig === undefined) {
|
||||
delete runtimeGlobal.__meteor_runtime_config__;
|
||||
} else {
|
||||
runtimeGlobal.__meteor_runtime_config__ = originalRuntimeConfig;
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards GET arguments and caller identity to the local REST API', async () => {
|
||||
process.env.PORT = '3100';
|
||||
runtimeGlobal.__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '/chat' };
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(new Response(JSON.stringify({ message: { _id: 'message-id' } }), { status: 200 }));
|
||||
|
||||
const result = await dispatchTool(
|
||||
{
|
||||
name: 'chat_getMessage',
|
||||
description: 'Get a message',
|
||||
inputSchema: { type: 'object' },
|
||||
path: '/api/v1/chat.getMessage',
|
||||
method: 'get',
|
||||
},
|
||||
{ msgId: 'message-id', fields: { msg: 1 }, optional: undefined },
|
||||
auth,
|
||||
'192.0.2.1',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:3100/chat/api/v1/chat.getMessage?msgId=message-id&fields=%7B%22msg%22%3A1%7D',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': 'user-id',
|
||||
'X-Auth-Token': 'auth-token',
|
||||
'X-Real-IP': '192.0.2.1',
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, status: 200, body: { message: { _id: 'message-id' } } });
|
||||
});
|
||||
|
||||
it('preserves non-JSON error responses', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(new Response('Service unavailable', { status: 503 }));
|
||||
|
||||
await expect(
|
||||
dispatchTool(
|
||||
{
|
||||
name: 'chat_postMessage',
|
||||
description: 'Post a message',
|
||||
inputSchema: { type: 'object' },
|
||||
path: '/api/v1/chat.postMessage',
|
||||
method: 'post',
|
||||
},
|
||||
{ roomId: 'room-id', text: 'Hello' },
|
||||
auth,
|
||||
),
|
||||
).resolves.toEqual({ ok: false, status: 503, body: 'Service unavailable' });
|
||||
});
|
||||
|
||||
it('handles empty REST responses', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
await expect(
|
||||
dispatchTool(
|
||||
{
|
||||
name: 'subscriptions_read',
|
||||
description: 'Mark a subscription as read',
|
||||
inputSchema: { type: 'object' },
|
||||
path: '/api/v1/subscriptions.read',
|
||||
method: 'post',
|
||||
},
|
||||
{ rid: 'room-id' },
|
||||
auth,
|
||||
),
|
||||
).resolves.toEqual({ ok: true, status: 204, body: '' });
|
||||
});
|
||||
|
||||
it('rejects responses whose content length exceeds the MCP result size limit', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(5 * 1024 * 1024 + 1) },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
dispatchTool(
|
||||
{
|
||||
name: 'rooms_get',
|
||||
description: 'Get rooms',
|
||||
inputSchema: { type: 'object' },
|
||||
path: '/api/v1/rooms.get',
|
||||
method: 'get',
|
||||
},
|
||||
{},
|
||||
auth,
|
||||
),
|
||||
).rejects.toThrow('MCP tool response exceeds the 5 MiB limit');
|
||||
});
|
||||
|
||||
it('stops streaming responses that exceed the MCP result size limit', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(new Uint8Array(5 * 1024 * 1024 + 1), { status: 200 }));
|
||||
|
||||
await expect(
|
||||
dispatchTool(
|
||||
{
|
||||
name: 'rooms_get',
|
||||
description: 'Get rooms',
|
||||
inputSchema: { type: 'object' },
|
||||
path: '/api/v1/rooms.get',
|
||||
method: 'get',
|
||||
},
|
||||
{},
|
||||
auth,
|
||||
),
|
||||
).rejects.toThrow('MCP tool response exceeds the 5 MiB limit');
|
||||
});
|
||||
|
||||
it('shares the response size budget across batched tool calls', async () => {
|
||||
jest.spyOn(global, 'fetch').mockImplementation(async () => new Response('abc', { status: 200 }));
|
||||
const responseBudget = createMcpResponseBudget(5);
|
||||
const tool = {
|
||||
name: 'rooms_get',
|
||||
description: 'Get rooms',
|
||||
inputSchema: { type: 'object' },
|
||||
path: '/api/v1/rooms.get',
|
||||
method: 'get' as const,
|
||||
};
|
||||
|
||||
await expect(dispatchTool(tool, {}, auth, undefined, responseBudget)).resolves.toMatchObject({ body: 'abc' });
|
||||
await expect(dispatchTool(tool, {}, auth, undefined, responseBudget)).rejects.toThrow('MCP batch response exceeds the 5 bytes limit');
|
||||
});
|
||||
});
|
||||
147
apps/meteor/ee/server/api/mcp/dispatch.ts
Normal file
147
apps/meteor/ee/server/api/mcp/dispatch.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { McpTool } from './catalog';
|
||||
import type { McpAuth } from './server';
|
||||
|
||||
export type DispatchResult = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
export type McpResponseBudget = {
|
||||
consume: (bytes: number) => void;
|
||||
};
|
||||
|
||||
const TOOL_CALL_TIMEOUT_MS = 20_000;
|
||||
const MAX_TOOL_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
const BYTES_PER_MEBIBYTE = 1024 * 1024;
|
||||
|
||||
const formatByteLimit = (bytes: number): string =>
|
||||
bytes % BYTES_PER_MEBIBYTE === 0 ? `${bytes / BYTES_PER_MEBIBYTE} MiB` : `${bytes} bytes`;
|
||||
|
||||
export const createMcpResponseBudget = (maxBytes = MAX_TOOL_RESPONSE_BYTES): McpResponseBudget => {
|
||||
let remainingBytes = maxBytes;
|
||||
const formattedLimit = formatByteLimit(maxBytes);
|
||||
|
||||
return {
|
||||
consume(bytes) {
|
||||
if (bytes > remainingBytes) {
|
||||
throw new Error(`MCP batch response exceeds the ${formattedLimit} limit`);
|
||||
}
|
||||
|
||||
remainingBytes -= bytes;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const readResponseText = async (response: Response, responseBudget?: McpResponseBudget): Promise<string> => {
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_TOOL_RESPONSE_BYTES) {
|
||||
throw new Error('MCP tool response exceeds the 5 MiB limit');
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const chunks: string[] = [];
|
||||
let receivedBytes = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
receivedBytes += value.byteLength;
|
||||
if (receivedBytes > MAX_TOOL_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error('MCP tool response exceeds the 5 MiB limit');
|
||||
}
|
||||
|
||||
try {
|
||||
responseBudget?.consume(value.byteLength);
|
||||
} catch (error) {
|
||||
await reader.cancel();
|
||||
throw error;
|
||||
}
|
||||
chunks.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
|
||||
chunks.push(decoder.decode());
|
||||
return chunks.join('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute the REST endpoint a tool maps to, as the authenticated user.
|
||||
*
|
||||
* Dispatch is done via a loopback HTTP call to the local REST API, forwarding the
|
||||
* caller's PAT headers. This guarantees identical behaviour to a real REST client —
|
||||
* the same auth, permission checks, parameter validation and response shape — with
|
||||
* zero duplicated business logic. (An in-process Hono dispatch is a possible future
|
||||
* optimisation to avoid the loopback hop.)
|
||||
*
|
||||
* `clientIp` (resolved server-side from the MCP connection) is forwarded as `X-Real-IP`
|
||||
* so the REST per-route rate limiter keys on the real client rather than the loopback
|
||||
* address — otherwise every MCP caller would share a single `127.0.0.1` bucket.
|
||||
*/
|
||||
export const dispatchTool = async (
|
||||
tool: McpTool,
|
||||
args: Record<string, unknown>,
|
||||
auth: McpAuth,
|
||||
clientIp?: string,
|
||||
responseBudget?: McpResponseBudget,
|
||||
): Promise<DispatchResult> => {
|
||||
const port = process.env.PORT || '3000';
|
||||
const runtimeConfig = (
|
||||
globalThis as typeof globalThis & {
|
||||
__meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string };
|
||||
}
|
||||
).__meteor_runtime_config__;
|
||||
const base = `http://127.0.0.1:${port}${runtimeConfig?.ROOT_URL_PATH_PREFIX ?? ''}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': auth.userId,
|
||||
'X-Auth-Token': auth.authToken,
|
||||
...(clientIp && { 'X-Real-IP': clientIp }),
|
||||
};
|
||||
|
||||
let url = base + tool.path;
|
||||
const init: RequestInit = {
|
||||
method: tool.method.toUpperCase(),
|
||||
headers,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(TOOL_CALL_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
if (tool.method === 'get' || tool.method === 'delete') {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(args ?? {})) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
qs.append(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
}
|
||||
const query = qs.toString();
|
||||
if (query) {
|
||||
url += `?${query}`;
|
||||
}
|
||||
} else {
|
||||
init.body = JSON.stringify(args ?? {});
|
||||
}
|
||||
|
||||
const res = await fetch(url, init);
|
||||
const responseText = await readResponseText(res, responseBudget);
|
||||
let body: unknown = responseText;
|
||||
if (responseText) {
|
||||
try {
|
||||
body = JSON.parse(responseText);
|
||||
} catch {
|
||||
// Keep non-JSON REST responses as text so callers receive the actual result.
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: res.ok, status: res.status, body };
|
||||
};
|
||||
377
apps/meteor/ee/server/api/mcp/index.spec.ts
Normal file
377
apps/meteor/ee/server/api/mcp/index.spec.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { handleMcpGet, handleMcpPost } from './index';
|
||||
import { handleRpcMessage } from './server';
|
||||
import { API } from '../../../../server/api';
|
||||
import { authenticationMiddlewareForHono } from '../../../../server/api/v1/middlewares/authenticationHono';
|
||||
import { permissionsMiddleware } from '../../../../server/api/v1/middlewares/permissions';
|
||||
import { settings } from '../../../../server/settings/cached';
|
||||
import { license } from '../v1/middlewares/license';
|
||||
|
||||
jest.mock('meteor/accounts-base', () => ({ Accounts: { _hashLoginToken: jest.fn((token: string) => `hashed-${token}`) } }), {
|
||||
virtual: true,
|
||||
});
|
||||
|
||||
jest.mock('@rocket.chat/models', () => ({
|
||||
Users: { findPersonalAccessTokenByHashedTokenAndUserId: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('./server', () => ({
|
||||
handleRpcMessage: jest.fn(),
|
||||
isJsonRpcRequest: jest.fn(
|
||||
(value: unknown) => typeof value === 'object' && value !== null && 'jsonrpc' in value && value.jsonrpc === '2.0' && 'method' in value,
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../server/api', () => ({
|
||||
API: {
|
||||
v1: {
|
||||
registerRateLimiterForRoute: jest.fn(),
|
||||
enforceRateLimitForRoute: jest.fn(),
|
||||
router: {
|
||||
getHonoRouter: jest.fn(() => ({ use: jest.fn(), post: jest.fn(), get: jest.fn() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../../../server/api/v1/middlewares/authenticationHono', () => ({
|
||||
authenticationMiddlewareForHono: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../server/api/v1/middlewares/permissions', () => ({
|
||||
permissionsMiddleware: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
||||
jest.mock('../v1/middlewares/license', () => ({
|
||||
license: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../server/settings/cached', () => ({
|
||||
settings: { get: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockRouter = jest.mocked(API.v1.router.getHonoRouter).mock.results[0]?.value;
|
||||
|
||||
const context = {
|
||||
bodyParams: { jsonrpc: '2.0', id: 1, method: 'ping' },
|
||||
userId: 'user-id',
|
||||
token: 'hashed-auth-token',
|
||||
requestIp: '192.0.2.1',
|
||||
request: new Request('http://localhost/api/v1/mcp', { headers: { 'x-auth-token': 'auth-token' } }),
|
||||
};
|
||||
|
||||
describe('MCP HTTP route', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(settings.get).mockReturnValue(true);
|
||||
jest.mocked(handleRpcMessage).mockReset();
|
||||
jest.mocked(API.v1.enforceRateLimitForRoute).mockReset().mockResolvedValue(undefined);
|
||||
jest
|
||||
.mocked(Users.findPersonalAccessTokenByHashedTokenAndUserId)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ _id: 'user-id' } as never);
|
||||
});
|
||||
|
||||
it('applies the API rate limiter before handling MCP requests', async () => {
|
||||
const middleware = mockRouter.use.mock.calls[0]?.[2];
|
||||
expect(middleware).toBeDefined();
|
||||
if (!middleware) {
|
||||
throw new Error('MCP rate-limit middleware was not registered');
|
||||
}
|
||||
|
||||
const request = new Request('http://localhost/api/v1/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'x-user-id': 'user-id' },
|
||||
});
|
||||
const response = new Response();
|
||||
const next = jest.fn().mockResolvedValue(undefined);
|
||||
await middleware(
|
||||
{
|
||||
req: { method: 'POST', raw: request, header: (name: string) => request.headers.get(name) ?? undefined },
|
||||
res: response,
|
||||
get: () => '192.0.2.1',
|
||||
},
|
||||
next,
|
||||
);
|
||||
|
||||
expect(API.v1.enforceRateLimitForRoute).toHaveBeenCalledWith({
|
||||
route: 'mcp',
|
||||
method: 'post',
|
||||
request,
|
||||
response,
|
||||
requestIp: '192.0.2.1',
|
||||
userId: 'user-id',
|
||||
});
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns a JSON-RPC error with rate-limit headers when the limit is exceeded', async () => {
|
||||
const middleware = mockRouter.use.mock.calls[0]?.[2];
|
||||
expect(middleware).toBeDefined();
|
||||
if (!middleware) {
|
||||
throw new Error('MCP rate-limit middleware was not registered');
|
||||
}
|
||||
|
||||
jest.mocked(API.v1.enforceRateLimitForRoute).mockImplementationOnce(async ({ response }: { response: Response }) => {
|
||||
response.headers.set('X-RateLimit-Remaining', '0');
|
||||
throw Object.assign(new Error('Please slow down'), { error: 'error-too-many-requests', reason: 'Please slow down' });
|
||||
});
|
||||
const request = new Request('http://localhost/api/v1/mcp', { method: 'POST', headers: { 'x-user-id': 'user-id' } });
|
||||
const result = await middleware(
|
||||
{
|
||||
req: { method: 'POST', raw: request, header: (name: string) => request.headers.get(name) ?? undefined },
|
||||
res: new Response(),
|
||||
get: () => '192.0.2.1',
|
||||
},
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(429);
|
||||
expect(result?.headers.get('X-RateLimit-Remaining')).toBe('0');
|
||||
await expect(result?.json()).resolves.toMatchObject({ error: { message: 'Please slow down' } });
|
||||
});
|
||||
|
||||
it('registers the endpoint with authentication, permission, and license gates', () => {
|
||||
expect(API.v1.router.getHonoRouter).toHaveBeenCalledTimes(1);
|
||||
expect(authenticationMiddlewareForHono).toHaveBeenCalledWith(API.v1, expect.objectContaining({ authRequired: true }));
|
||||
expect(API.v1.registerRateLimiterForRoute).toHaveBeenCalledWith({
|
||||
route: 'mcp',
|
||||
rateLimiterOptions: { numRequestsAllowed: 60, intervalTimeInMS: 60_000 },
|
||||
methods: ['post'],
|
||||
});
|
||||
expect(permissionsMiddleware).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
permissionsRequired: { '*': { permissions: ['access-mcp'], operation: 'hasAll' } },
|
||||
}),
|
||||
);
|
||||
expect(license).toHaveBeenCalledWith(expect.objectContaining({ license: [AI_LICENSE_MODULE] }), expect.anything());
|
||||
expect(mockRouter.use).toHaveBeenCalledWith(
|
||||
'/mcp',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockRouter.post).toHaveBeenCalledWith('/mcp', expect.any(Function));
|
||||
expect(mockRouter.get).toHaveBeenCalledWith('/mcp', expect.any(Function));
|
||||
});
|
||||
|
||||
it('adapts Hono POST requests to exact JSON-RPC responses', async () => {
|
||||
const response = { jsonrpc: '2.0' as const, id: 7, result: {} };
|
||||
jest.mocked(handleRpcMessage).mockResolvedValue(response);
|
||||
const request = new Request('http://localhost/api/v1/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-auth-token': 'auth-token', 'x-user-id': 'user-id' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'ping' }),
|
||||
});
|
||||
const handler = mockRouter.post.mock.calls[0]?.[1];
|
||||
expect(handler).toBeDefined();
|
||||
if (!handler) {
|
||||
throw new Error('MCP POST handler was not registered');
|
||||
}
|
||||
|
||||
const result = await handler({
|
||||
req: { raw: request },
|
||||
get: (key: string) => (key === 'user' ? { _id: 'user-id' } : '192.0.2.1'),
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result.status).toBe(200);
|
||||
await expect(result.json()).resolves.toEqual(response);
|
||||
expect(Accounts._hashLoginToken).toHaveBeenCalledWith('auth-token');
|
||||
expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).toHaveBeenCalledWith({
|
||||
userId: 'user-id',
|
||||
hashedToken: 'hashed-auth-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a JSON-RPC parse error for malformed JSON after validating the personal access token', async () => {
|
||||
const handler = mockRouter.post.mock.calls[0]?.[1];
|
||||
expect(handler).toBeDefined();
|
||||
if (!handler) {
|
||||
throw new Error('MCP POST handler was not registered');
|
||||
}
|
||||
|
||||
const result = await handler({
|
||||
req: {
|
||||
raw: new Request('http://localhost/api/v1/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-auth-token': 'auth-token', 'x-user-id': 'user-id' },
|
||||
body: '{',
|
||||
}),
|
||||
},
|
||||
get: (key: string) => (key === 'user' ? { _id: 'user-id' } : '192.0.2.1'),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
await expect(result.json()).resolves.toMatchObject({ id: null, error: { code: -32700, message: 'Parse error' } });
|
||||
expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).toHaveBeenCalled();
|
||||
expect(handleRpcMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns not found while MCP is disabled', async () => {
|
||||
jest.mocked(settings.get).mockReturnValue(false);
|
||||
|
||||
await expect(handleMcpPost(context)).resolves.toMatchObject({ statusCode: 404 });
|
||||
expect(handleMcpGet(context)).toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects empty and oversized JSON-RPC batches', async () => {
|
||||
await expect(handleMcpPost({ ...context, bodyParams: [] })).resolves.toMatchObject({
|
||||
statusCode: 400,
|
||||
body: { error: { code: -32600 } },
|
||||
});
|
||||
await expect(handleMcpPost({ ...context, bodyParams: Array.from({ length: 21 }, () => context.bodyParams) })).resolves.toMatchObject({
|
||||
statusCode: 400,
|
||||
body: { error: { code: -32600 } },
|
||||
});
|
||||
expect(handleRpcMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects JSON-RPC batches for protocol revisions that require one message per POST', async () => {
|
||||
await expect(
|
||||
handleMcpPost({
|
||||
...context,
|
||||
bodyParams: [context.bodyParams],
|
||||
request: new Request('http://localhost/api/v1/mcp', {
|
||||
headers: { 'mcp-protocol-version': '2025-11-25', 'x-auth-token': 'auth-token' },
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({ statusCode: 400, body: { error: { code: -32600 } } });
|
||||
expect(handleRpcMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a JSON-RPC response because this server does not issue client requests', async () => {
|
||||
const response = { jsonrpc: '2.0', id: 1, result: {} };
|
||||
|
||||
await expect(handleMcpPost({ ...context, bodyParams: response })).resolves.toMatchObject({
|
||||
statusCode: 400,
|
||||
body: { error: { code: -32600 } },
|
||||
});
|
||||
expect(handleRpcMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processes valid and malformed entries independently in legacy JSON-RPC batches', async () => {
|
||||
const validResponse = { jsonrpc: '2.0' as const, id: 1, result: {} };
|
||||
const invalidResponse = { jsonrpc: '2.0' as const, id: null, error: { code: -32600, message: 'Invalid Request' } };
|
||||
const malformedEntry = { jsonrpc: '2.0', id: 2, result: {} };
|
||||
jest.mocked(handleRpcMessage).mockResolvedValueOnce(validResponse).mockResolvedValueOnce(invalidResponse);
|
||||
|
||||
await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams, malformedEntry] })).resolves.toEqual({
|
||||
statusCode: 200,
|
||||
body: [validResponse, invalidResponse],
|
||||
});
|
||||
expect(handleRpcMessage).toHaveBeenNthCalledWith(1, context.bodyParams, expect.anything(), context.requestIp, expect.anything());
|
||||
expect(handleRpcMessage).toHaveBeenNthCalledWith(2, malformedEntry, expect.anything(), context.requestIp, expect.anything());
|
||||
});
|
||||
|
||||
it('acknowledges notifications without a response body', async () => {
|
||||
jest.mocked(handleRpcMessage).mockResolvedValue(null);
|
||||
|
||||
await expect(handleMcpPost(context)).resolves.toEqual({ statusCode: 202, body: undefined });
|
||||
await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams] })).resolves.toEqual({
|
||||
statusCode: 202,
|
||||
body: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits notification entries from batch responses', async () => {
|
||||
const response = { jsonrpc: '2.0' as const, id: 1, result: {} };
|
||||
jest.mocked(handleRpcMessage).mockResolvedValueOnce(response).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams, context.bodyParams] })).resolves.toEqual({
|
||||
statusCode: 200,
|
||||
body: [response],
|
||||
});
|
||||
const [firstCall, secondCall] = jest.mocked(handleRpcMessage).mock.calls;
|
||||
expect(firstCall?.[3]).toBeDefined();
|
||||
expect(firstCall?.[3]).toBe(secondCall?.[3]);
|
||||
});
|
||||
|
||||
it('limits concurrent calls while preserving batch response order', async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
jest.mocked(handleRpcMessage).mockImplementation(async (message) => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await Promise.resolve();
|
||||
inFlight -= 1;
|
||||
if (typeof message !== 'object' || message === null || !('id' in message) || typeof message.id !== 'number') {
|
||||
throw new Error('Expected a numbered JSON-RPC request');
|
||||
}
|
||||
return { jsonrpc: '2.0', id: message.id, result: {} };
|
||||
});
|
||||
const bodyParams = Array.from({ length: 20 }, (_, id) => ({ jsonrpc: '2.0', id, method: 'ping' }));
|
||||
|
||||
const response = await handleMcpPost({ ...context, bodyParams });
|
||||
|
||||
expect(maxInFlight).toBe(4);
|
||||
expect(response.body).toEqual(bodyParams.map(({ id }) => ({ jsonrpc: '2.0', id, result: {} })));
|
||||
});
|
||||
|
||||
it('returns method not allowed for GET requests', () => {
|
||||
expect(handleMcpGet(context)).toMatchObject({ statusCode: 405, headers: { Allow: 'POST' } });
|
||||
expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects authenticated sessions that are not personal access tokens', async () => {
|
||||
jest.mocked(Users.findPersonalAccessTokenByHashedTokenAndUserId).mockResolvedValue(null);
|
||||
|
||||
await expect(handleMcpPost(context)).resolves.toMatchObject({
|
||||
statusCode: 401,
|
||||
body: { error: { message: 'Personal Access Token required' } },
|
||||
});
|
||||
expect(handleRpcMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects final encoded responses that exceed the MCP response limit', async () => {
|
||||
jest.mocked(handleRpcMessage).mockResolvedValue({ jsonrpc: '2.0', id: 1, result: 'x'.repeat(5 * 1024 * 1024) });
|
||||
|
||||
await expect(handleMcpPost(context)).resolves.toMatchObject({
|
||||
statusCode: 413,
|
||||
body: { error: { message: 'MCP response exceeds the 5 MiB limit' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects browser requests from untrusted origins', async () => {
|
||||
jest.mocked(settings.get).mockImplementation((setting) => {
|
||||
if (setting === 'MCP_Enabled') {
|
||||
return true;
|
||||
}
|
||||
if (setting === 'Site_Url') {
|
||||
return 'https://chat.example.com';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleMcpPost({
|
||||
...context,
|
||||
request: new Request('https://chat.example.com/api/v1/mcp', {
|
||||
headers: { 'origin': 'https://attacker.example', 'x-auth-token': 'auth-token' },
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({ statusCode: 403 });
|
||||
expect(handleRpcMessage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
handleMcpGet({
|
||||
request: new Request('https://chat.example.com/api/v1/mcp', { headers: { origin: 'https://attacker.example' } }),
|
||||
}),
|
||||
).toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rejects unsupported protocol-version headers', async () => {
|
||||
await expect(
|
||||
handleMcpPost({
|
||||
...context,
|
||||
request: new Request('http://localhost/api/v1/mcp', {
|
||||
headers: { 'mcp-protocol-version': '2099-01-01', 'x-auth-token': 'auth-token' },
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({ statusCode: 400 });
|
||||
expect(handleRpcMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
256
apps/meteor/ee/server/api/mcp/index.ts
Normal file
256
apps/meteor/ee/server/api/mcp/index.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { StatusCode } from 'hono/utils/http-status';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { createMcpResponseBudget } from './dispatch';
|
||||
import { handleRpcMessage, isJsonRpcRequest, type JsonRpcResponse, type McpAuth } from './server';
|
||||
import { isMcpOriginAllowed, isMcpProtocolVersionSupported, supportsMcpBatching } from './transport';
|
||||
import { API } from '../../../../server/api';
|
||||
import type { TypedOptions } from '../../../../server/api/definition';
|
||||
import { authenticationMiddlewareForHono } from '../../../../server/api/v1/middlewares/authenticationHono';
|
||||
import { permissionsMiddleware } from '../../../../server/api/v1/middlewares/permissions';
|
||||
import { settings } from '../../../../server/settings/cached';
|
||||
import { license } from '../v1/middlewares/license';
|
||||
|
||||
const logger = new Logger('MCP');
|
||||
|
||||
type McpHttpResponse = {
|
||||
statusCode: StatusCode;
|
||||
body: JsonRpcResponse | JsonRpcResponse[] | undefined;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
const disabledResponse: McpHttpResponse = {
|
||||
statusCode: 404,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32601, message: 'MCP endpoint is disabled' } },
|
||||
};
|
||||
|
||||
type McpActionContext = {
|
||||
bodyParams: unknown;
|
||||
bodyParseError?: boolean;
|
||||
userId: string;
|
||||
token: string;
|
||||
requestIp: string;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
const MAX_BATCH_SIZE = 20;
|
||||
const MAX_BATCH_CONCURRENCY = 4;
|
||||
const MAX_MCP_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
const MCP_ROUTE = 'mcp';
|
||||
const MCP_RATE_LIMIT_OPTIONS = { numRequestsAllowed: 60, intervalTimeInMS: 60_000 };
|
||||
|
||||
const personalAccessTokenRequiredResponse: McpHttpResponse = {
|
||||
statusCode: 401,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Personal Access Token required' } },
|
||||
};
|
||||
|
||||
const invalidRequestResponse: McpHttpResponse = {
|
||||
statusCode: 400,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } },
|
||||
};
|
||||
|
||||
const parseErrorResponse: McpHttpResponse = {
|
||||
statusCode: 400,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } },
|
||||
};
|
||||
|
||||
const hasPersonalAccessToken = async ({ userId, token }: Pick<McpActionContext, 'userId' | 'token'>): Promise<boolean> =>
|
||||
Boolean(await Users.findPersonalAccessTokenByHashedTokenAndUserId({ userId, hashedToken: token }));
|
||||
|
||||
const jsonResponse = (body: JsonRpcResponse | JsonRpcResponse[]): McpHttpResponse => {
|
||||
if (Buffer.byteLength(JSON.stringify(body), 'utf8') > MAX_MCP_RESPONSE_BYTES) {
|
||||
return {
|
||||
statusCode: 413,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'MCP response exceeds the 5 MiB limit' } },
|
||||
};
|
||||
}
|
||||
|
||||
return { statusCode: 200, body };
|
||||
};
|
||||
|
||||
const validateTransportRequest = (request: Request): McpHttpResponse | undefined => {
|
||||
if (!isMcpOriginAllowed(request.headers.get('origin'))) {
|
||||
return {
|
||||
statusCode: 403,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Origin is not allowed' } },
|
||||
};
|
||||
}
|
||||
if (!isMcpProtocolVersionSupported(request.headers.get('mcp-protocol-version'))) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Unsupported MCP protocol version' } },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleBatch = async (messages: unknown[], auth: McpAuth, clientIp: string): Promise<(JsonRpcResponse | null)[]> => {
|
||||
const responseBudget = createMcpResponseBudget(MAX_MCP_RESPONSE_BYTES);
|
||||
const responses = new Array<JsonRpcResponse | null>(messages.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
const processNext = async (): Promise<void> => {
|
||||
while (nextIndex < messages.length) {
|
||||
const index = nextIndex++;
|
||||
responses[index] = await handleRpcMessage(messages[index], auth, clientIp, responseBudget);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(messages.length, MAX_BATCH_CONCURRENCY) }, () => processNext()));
|
||||
return responses;
|
||||
};
|
||||
|
||||
export const handleMcpPost = async (context: McpActionContext): Promise<McpHttpResponse> => {
|
||||
if (!settings.get<boolean>('MCP_Enabled')) {
|
||||
return disabledResponse;
|
||||
}
|
||||
|
||||
const transportError = validateTransportRequest(context.request);
|
||||
if (transportError) {
|
||||
return transportError;
|
||||
}
|
||||
if (!(await hasPersonalAccessToken(context))) {
|
||||
return personalAccessTokenRequiredResponse;
|
||||
}
|
||||
if (context.bodyParseError) {
|
||||
return parseErrorResponse;
|
||||
}
|
||||
|
||||
const message = context.bodyParams;
|
||||
const auth: McpAuth = {
|
||||
userId: context.userId,
|
||||
authToken: String(context.request.headers.get('x-auth-token') ?? ''),
|
||||
};
|
||||
const clientIp = context.requestIp;
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
if (!supportsMcpBatching(context.request.headers.get('mcp-protocol-version'))) {
|
||||
return invalidRequestResponse;
|
||||
}
|
||||
|
||||
if (message.length === 0 || message.length > MAX_BATCH_SIZE) {
|
||||
return invalidRequestResponse;
|
||||
}
|
||||
|
||||
const responses = (await handleBatch(message, auth, clientIp)).filter((response): response is JsonRpcResponse => response !== null);
|
||||
return responses.length ? jsonResponse(responses) : { statusCode: 202, body: undefined };
|
||||
}
|
||||
|
||||
if (!isJsonRpcRequest(message)) {
|
||||
return invalidRequestResponse;
|
||||
}
|
||||
|
||||
const response = await handleRpcMessage(message, auth, clientIp);
|
||||
if (!response) {
|
||||
return { statusCode: 202, body: undefined };
|
||||
}
|
||||
|
||||
return jsonResponse(response);
|
||||
};
|
||||
|
||||
export const handleMcpGet = (context: Pick<McpActionContext, 'request'>): McpHttpResponse => {
|
||||
if (!settings.get<boolean>('MCP_Enabled')) {
|
||||
return disabledResponse;
|
||||
}
|
||||
|
||||
const transportError = validateTransportRequest(context.request);
|
||||
if (transportError) {
|
||||
return transportError;
|
||||
}
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers: { Allow: 'POST' },
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Only POST is supported' } },
|
||||
};
|
||||
};
|
||||
|
||||
const routeOptions = {
|
||||
response: {},
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
'*': { permissions: ['access-mcp'], operation: 'hasAll' },
|
||||
},
|
||||
license: [AI_LICENSE_MODULE],
|
||||
} satisfies TypedOptions;
|
||||
|
||||
const sendResponse = (response: McpHttpResponse): Response => {
|
||||
const headers = { 'Content-Type': 'application/json', ...response.headers };
|
||||
return new Response(response.body === undefined ? null : JSON.stringify(response.body), { status: response.statusCode, headers });
|
||||
};
|
||||
|
||||
const isRateLimitError = (error: unknown): error is { error: 'error-too-many-requests'; reason?: string } =>
|
||||
typeof error === 'object' && error !== null && 'error' in error && error.error === 'error-too-many-requests';
|
||||
|
||||
const rateLimitMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
try {
|
||||
await API.v1.enforceRateLimitForRoute({
|
||||
route: MCP_ROUTE,
|
||||
method: c.req.method.toLowerCase(),
|
||||
request: c.req.raw,
|
||||
response: c.res,
|
||||
requestIp: c.get('remoteAddress'),
|
||||
userId: c.req.header('x-user-id'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRateLimitError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendResponse({
|
||||
statusCode: 429,
|
||||
body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: error.reason ?? 'Too many requests' } },
|
||||
headers: Object.fromEntries([...c.res.headers].filter(([name]) => name.toLowerCase().startsWith('x-ratelimit-'))),
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitHeaders = [...c.res.headers].filter(([name]) => name.toLowerCase().startsWith('x-ratelimit-'));
|
||||
await next();
|
||||
for (const [name, value] of rateLimitHeaders) {
|
||||
c.res.headers.set(name, value);
|
||||
}
|
||||
};
|
||||
|
||||
const router = API.v1.router.getHonoRouter();
|
||||
API.v1.registerRateLimiterForRoute({ route: MCP_ROUTE, rateLimiterOptions: MCP_RATE_LIMIT_OPTIONS, methods: ['post'] });
|
||||
router.use(
|
||||
'/mcp',
|
||||
authenticationMiddlewareForHono(API.v1, { authRequired: true, logger }),
|
||||
rateLimitMiddleware,
|
||||
permissionsMiddleware(routeOptions),
|
||||
license(routeOptions, License),
|
||||
);
|
||||
router.post('/mcp', async (c) => {
|
||||
const request = c.req.raw;
|
||||
const rawToken = request.headers.get('x-auth-token') ?? '';
|
||||
const userId = request.headers.get('x-user-id');
|
||||
|
||||
if (!userId) {
|
||||
return sendResponse(personalAccessTokenRequiredResponse);
|
||||
}
|
||||
|
||||
let bodyParams: unknown;
|
||||
let bodyParseError = false;
|
||||
|
||||
try {
|
||||
bodyParams = await request.clone().json();
|
||||
} catch {
|
||||
bodyParseError = true;
|
||||
}
|
||||
|
||||
return sendResponse(
|
||||
await handleMcpPost({
|
||||
bodyParams,
|
||||
bodyParseError,
|
||||
userId,
|
||||
token: Accounts._hashLoginToken(rawToken) ?? '',
|
||||
requestIp: c.get('remoteAddress'),
|
||||
request,
|
||||
}),
|
||||
);
|
||||
});
|
||||
router.get('/mcp', (c) => sendResponse(handleMcpGet({ request: c.req.raw })));
|
||||
174
apps/meteor/ee/server/api/mcp/server.spec.ts
Normal file
174
apps/meteor/ee/server/api/mcp/server.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { getCuratedTools, getExtendedTools } from './catalog';
|
||||
import { dispatchTool } from './dispatch';
|
||||
import { handleRpcMessage, isJsonRpcRequest, type McpAuth } from './server';
|
||||
import { settings } from '../../../../server/settings/cached';
|
||||
|
||||
jest.mock('./catalog', () => ({
|
||||
getCuratedTools: jest.fn(),
|
||||
getExtendedTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./dispatch', () => ({
|
||||
dispatchTool: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../server/api/lib/getTrimmedServerVersion', () => ({
|
||||
getTrimmedServerVersion: () => '9.0',
|
||||
}));
|
||||
|
||||
jest.mock('../../../../server/settings/cached', () => ({
|
||||
settings: { get: jest.fn() },
|
||||
}));
|
||||
|
||||
const auth: McpAuth = {
|
||||
userId: 'user-id',
|
||||
authToken: 'auth-token',
|
||||
};
|
||||
|
||||
const tool = {
|
||||
name: 'get_chat_getMessage',
|
||||
description: 'Get a message',
|
||||
inputSchema: { type: 'object' },
|
||||
path: '/api/v1/chat.getMessage',
|
||||
method: 'get' as const,
|
||||
};
|
||||
|
||||
const extendedTool = {
|
||||
...tool,
|
||||
name: 'get_chat_search',
|
||||
description: 'Search messages',
|
||||
path: '/api/v1/chat.search',
|
||||
};
|
||||
|
||||
const initializeParams = {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' },
|
||||
};
|
||||
|
||||
describe('MCP JSON-RPC server', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(settings.get).mockReturnValue(false);
|
||||
jest.mocked(getCuratedTools).mockReset().mockReturnValue([tool]);
|
||||
jest.mocked(getExtendedTools).mockReset().mockReturnValue([]);
|
||||
jest.mocked(dispatchTool).mockReset();
|
||||
});
|
||||
|
||||
it('rejects malformed JSON-RPC messages without throwing', async () => {
|
||||
expect(isJsonRpcRequest({ jsonrpc: '2.0', method: 'ping' })).toBe(true);
|
||||
expect(isJsonRpcRequest({ jsonrpc: '1.0', method: 'ping' })).toBe(false);
|
||||
expect(isJsonRpcRequest({ jsonrpc: '2.0', id: null, method: 'ping' })).toBe(false);
|
||||
|
||||
await expect(handleRpcMessage(null, auth)).resolves.toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32600, message: 'Invalid Request' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns server capabilities during initialization', async () => {
|
||||
await expect(handleRpcMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initializeParams }, auth)).resolves.toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: 'rocketchat', version: '9.0' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the latest supported version when negotiation fails', async () => {
|
||||
await expect(
|
||||
handleRpcMessage(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'initialize',
|
||||
params: { ...initializeParams, protocolVersion: 'unsupported' },
|
||||
},
|
||||
auth,
|
||||
),
|
||||
).resolves.toMatchObject({ result: { protocolVersion: '2025-11-25' } });
|
||||
});
|
||||
|
||||
it('rejects initialize requests without the required client information', async () => {
|
||||
await expect(
|
||||
handleRpcMessage({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2025-11-25' } }, auth),
|
||||
).resolves.toMatchObject({ error: { code: -32602, message: 'Invalid initialize parameters' } });
|
||||
});
|
||||
|
||||
it('lists the curated toolset by default', async () => {
|
||||
const response = await handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, auth);
|
||||
|
||||
expect(getCuratedTools).toHaveBeenCalledTimes(1);
|
||||
expect(getExtendedTools).not.toHaveBeenCalled();
|
||||
expect(response).toMatchObject({ result: { tools: [{ name: tool.name }] } });
|
||||
});
|
||||
|
||||
it('lists the extended toolset when enabled', async () => {
|
||||
jest.mocked(settings.get).mockReturnValue(true);
|
||||
jest.mocked(getExtendedTools).mockReturnValue([extendedTool]);
|
||||
|
||||
const response = await handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, auth);
|
||||
|
||||
expect(getExtendedTools).toHaveBeenCalledTimes(1);
|
||||
expect(getCuratedTools).not.toHaveBeenCalled();
|
||||
expect(response).toMatchObject({ result: { tools: [{ name: extendedTool.name }] } });
|
||||
});
|
||||
|
||||
it('validates tool call parameters before dispatch', async () => {
|
||||
await expect(
|
||||
handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: tool.name, arguments: [] } }, auth),
|
||||
).resolves.toMatchObject({ error: { code: -32602 } });
|
||||
expect(dispatchTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches a known tool as the authenticated user', async () => {
|
||||
jest.mocked(dispatchTool).mockResolvedValue({ ok: true, status: 200, body: { message: { _id: 'message-id' } } });
|
||||
|
||||
const response = await handleRpcMessage(
|
||||
{ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: tool.name, arguments: { msgId: 'message-id' } } },
|
||||
auth,
|
||||
'192.0.2.1',
|
||||
);
|
||||
|
||||
expect(dispatchTool).toHaveBeenCalledWith(tool, { msgId: 'message-id' }, auth, '192.0.2.1', undefined);
|
||||
expect(response).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
result: {
|
||||
content: [{ type: 'text', text: JSON.stringify({ message: { _id: 'message-id' } }) }],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('marks unsuccessful REST responses as tool errors', async () => {
|
||||
jest.mocked(dispatchTool).mockResolvedValue({ ok: false, status: 403, body: { error: 'Forbidden' } });
|
||||
|
||||
await expect(
|
||||
handleRpcMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: tool.name, arguments: {} } }, auth),
|
||||
).resolves.toMatchObject({ result: { isError: true, content: [{ text: JSON.stringify({ error: 'Forbidden' }) }] } });
|
||||
});
|
||||
|
||||
it('returns thrown dispatch failures as tool errors', async () => {
|
||||
jest.mocked(dispatchTool).mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await expect(
|
||||
handleRpcMessage({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: tool.name, arguments: {} } }, auth),
|
||||
).resolves.toMatchObject({ result: { isError: true, content: [{ text: 'Tool execution failed: Connection failed' }] } });
|
||||
});
|
||||
|
||||
it('rejects unknown tools without dispatching', async () => {
|
||||
await expect(
|
||||
handleRpcMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'unknown', arguments: {} } }, auth),
|
||||
).resolves.toMatchObject({ error: { code: -32602, message: 'Unknown tool: unknown' } });
|
||||
expect(dispatchTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reply to notifications', async () => {
|
||||
await expect(handleRpcMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }, auth)).resolves.toBeNull();
|
||||
await expect(handleRpcMessage({ jsonrpc: '2.0', method: 'unknown/notification' }, auth)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
150
apps/meteor/ee/server/api/mcp/server.ts
Normal file
150
apps/meteor/ee/server/api/mcp/server.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { getCuratedTools, getExtendedTools, type McpTool } from './catalog';
|
||||
import { dispatchTool, type McpResponseBudget } from './dispatch';
|
||||
import { SUPPORTED_PROTOCOL_VERSIONS } from './transport';
|
||||
import { getTrimmedServerVersion } from '../../../../server/api/lib/getTrimmedServerVersion';
|
||||
import { settings } from '../../../../server/settings/cached';
|
||||
|
||||
export type McpAuth = {
|
||||
userId: string;
|
||||
/** The raw (unhashed) Personal Access Token, forwarded to the REST layer on dispatch. */
|
||||
authToken: string;
|
||||
};
|
||||
|
||||
const DEFAULT_PROTOCOL_VERSION = '2025-11-25';
|
||||
|
||||
const negotiateProtocolVersion = (requestedVersion: unknown): string =>
|
||||
typeof requestedVersion === 'string' && SUPPORTED_PROTOCOL_VERSIONS.has(requestedVersion) ? requestedVersion : DEFAULT_PROTOCOL_VERSION;
|
||||
|
||||
export type JsonRpcRequest = {
|
||||
jsonrpc: '2.0';
|
||||
id?: string | number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type JsonRpcResponse = {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number | null;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
};
|
||||
|
||||
const result = (id: JsonRpcRequest['id'], value: unknown): JsonRpcResponse => ({ jsonrpc: '2.0', id: id ?? null, result: value });
|
||||
|
||||
const error = (id: JsonRpcResponse['id'] | undefined, code: number, message: string, data?: unknown): JsonRpcResponse => ({
|
||||
jsonrpc: '2.0',
|
||||
id: id ?? null,
|
||||
error: { code, message, ...(data !== undefined && { data }) },
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const isInitializeParams = (params: unknown): params is Record<string, unknown> => {
|
||||
if (!isRecord(params) || typeof params.protocolVersion !== 'string' || !isRecord(params.capabilities) || !isRecord(params.clientInfo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof params.clientInfo.name === 'string' && typeof params.clientInfo.version === 'string';
|
||||
};
|
||||
|
||||
export const isJsonRpcRequest = (value: unknown): value is JsonRpcRequest => {
|
||||
if (!isRecord(value) || value.jsonrpc !== '2.0' || typeof value.method !== 'string' || value.method.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.id !== undefined && typeof value.id !== 'string' && typeof value.id !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.params === undefined || isRecord(value.params);
|
||||
};
|
||||
|
||||
const listTools = (): McpTool[] => {
|
||||
if (settings.get<boolean>('MCP_Expose_Extended_API')) {
|
||||
return getExtendedTools();
|
||||
}
|
||||
return getCuratedTools();
|
||||
};
|
||||
|
||||
const toToolDefinition = ({ name, description, inputSchema }: McpTool) => ({ name, description, inputSchema });
|
||||
|
||||
/**
|
||||
* Handle a single JSON-RPC message. Returns the response object, or `null` for
|
||||
* notifications (which must not produce a response per the JSON-RPC spec).
|
||||
*/
|
||||
export const handleRpcMessage = async (
|
||||
message: unknown,
|
||||
auth: McpAuth,
|
||||
clientIp?: string,
|
||||
responseBudget?: McpResponseBudget,
|
||||
): Promise<JsonRpcResponse | null> => {
|
||||
if (!isJsonRpcRequest(message)) {
|
||||
return error(null, -32600, 'Invalid Request');
|
||||
}
|
||||
|
||||
const { id, method, params } = message;
|
||||
const respond = (response: JsonRpcResponse): JsonRpcResponse | null => (id === undefined ? null : response);
|
||||
|
||||
switch (method) {
|
||||
case 'initialize': {
|
||||
if (!isInitializeParams(params)) {
|
||||
return respond(error(id, -32602, 'Invalid initialize parameters'));
|
||||
}
|
||||
|
||||
return respond(
|
||||
result(id, {
|
||||
protocolVersion: negotiateProtocolVersion(params.protocolVersion),
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: {
|
||||
name: 'rocketchat',
|
||||
version: getTrimmedServerVersion(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
case 'notifications/initialized':
|
||||
case 'notifications/cancelled':
|
||||
return null;
|
||||
|
||||
case 'ping':
|
||||
return respond(result(id, {}));
|
||||
|
||||
case 'tools/list':
|
||||
return respond(result(id, { tools: listTools().map(toToolDefinition) }));
|
||||
|
||||
case 'tools/call': {
|
||||
const name = params?.name;
|
||||
const args = params?.arguments;
|
||||
if (typeof name !== 'string' || (args !== undefined && !isRecord(args))) {
|
||||
return respond(error(id, -32602, 'Invalid tools/call parameters'));
|
||||
}
|
||||
|
||||
const tool = listTools().find((t) => t.name === name);
|
||||
|
||||
if (!tool) {
|
||||
return respond(error(id, -32602, `Unknown tool: ${name}`));
|
||||
}
|
||||
|
||||
try {
|
||||
const dispatch = await dispatchTool(tool, args ?? {}, auth, clientIp, responseBudget);
|
||||
return respond(
|
||||
result(id, {
|
||||
content: [{ type: 'text', text: JSON.stringify(dispatch.body) }],
|
||||
isError: !dispatch.ok,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
return respond(
|
||||
result(id, {
|
||||
content: [{ type: 'text', text: `Tool execution failed: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return respond(error(id, -32601, `Method not found: ${method}`));
|
||||
}
|
||||
};
|
||||
63
apps/meteor/ee/server/api/mcp/transport.spec.ts
Normal file
63
apps/meteor/ee/server/api/mcp/transport.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { isMcpOriginAllowed, isMcpProtocolVersionSupported, supportsMcpBatching } from './transport';
|
||||
import { settings } from '../../../../server/settings/cached';
|
||||
|
||||
jest.mock('../../../../server/settings/cached', () => ({
|
||||
settings: { get: jest.fn() },
|
||||
}));
|
||||
|
||||
describe('MCP HTTP transport validation', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(settings.get).mockImplementation((setting) => {
|
||||
switch (setting) {
|
||||
case 'Site_Url':
|
||||
return 'https://chat.example.com/';
|
||||
case 'API_Enable_CORS':
|
||||
return true;
|
||||
case 'API_CORS_Origin':
|
||||
return 'https://client.example, https://other.example/path';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('allows non-browser, same-origin, and explicitly configured browser requests', () => {
|
||||
expect(isMcpOriginAllowed(null)).toBe(true);
|
||||
expect(isMcpOriginAllowed('https://chat.example.com')).toBe(true);
|
||||
expect(isMcpOriginAllowed('https://client.example')).toBe(true);
|
||||
expect(isMcpOriginAllowed('https://other.example')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed, untrusted, and wildcard-only browser origins', () => {
|
||||
expect(isMcpOriginAllowed('not-an-origin')).toBe(false);
|
||||
expect(isMcpOriginAllowed('https://attacker.example')).toBe(false);
|
||||
|
||||
jest.mocked(settings.get).mockImplementation((setting) => {
|
||||
if (setting === 'Site_Url') {
|
||||
return 'https://chat.example.com';
|
||||
}
|
||||
if (setting === 'API_Enable_CORS') {
|
||||
return true;
|
||||
}
|
||||
if (setting === 'API_CORS_Origin') {
|
||||
return '*';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(isMcpOriginAllowed('https://attacker.example')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an absent or supported protocol version only', () => {
|
||||
expect(isMcpProtocolVersionSupported(null)).toBe(true);
|
||||
expect(isMcpProtocolVersionSupported('2025-11-25')).toBe(true);
|
||||
expect(isMcpProtocolVersionSupported('2099-01-01')).toBe(false);
|
||||
});
|
||||
|
||||
it('only accepts JSON-RPC batches for the 2025-03-26 transport revision', () => {
|
||||
expect(supportsMcpBatching(null)).toBe(true);
|
||||
expect(supportsMcpBatching('2025-03-26')).toBe(true);
|
||||
expect(supportsMcpBatching('2025-06-18')).toBe(false);
|
||||
expect(supportsMcpBatching('2025-11-25')).toBe(false);
|
||||
});
|
||||
});
|
||||
52
apps/meteor/ee/server/api/mcp/transport.ts
Normal file
52
apps/meteor/ee/server/api/mcp/transport.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { settings } from '../../../../server/settings/cached';
|
||||
|
||||
export const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-11-25', '2025-06-18', '2025-03-26']);
|
||||
export const DEFAULT_PROTOCOL_VERSION = '2025-03-26';
|
||||
|
||||
const normalizeOrigin = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* MCP clients running outside a browser do not send Origin. Browser requests must come
|
||||
* from the workspace itself or an explicitly configured CORS origin. A wildcard CORS
|
||||
* setting is intentionally not accepted because it cannot protect against DNS rebinding.
|
||||
*/
|
||||
export const isMcpOriginAllowed = (origin: string | null): boolean => {
|
||||
if (origin === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedOrigin = normalizeOrigin(origin);
|
||||
if (!normalizedOrigin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedOrigin === normalizeOrigin(settings.get('Site_Url'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!settings.get<boolean>('API_Enable_CORS')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configuredOrigins = settings.get<string>('API_CORS_Origin') ?? '';
|
||||
if (configuredOrigins === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return configuredOrigins.split(',').some((configuredOrigin) => normalizeOrigin(configuredOrigin.trim()) === normalizedOrigin);
|
||||
};
|
||||
|
||||
export const isMcpProtocolVersionSupported = (version: string | null): boolean =>
|
||||
version === null || SUPPORTED_PROTOCOL_VERSIONS.has(version);
|
||||
|
||||
export const supportsMcpBatching = (version: string | null): boolean => (version ?? DEFAULT_PROTOCOL_VERSION) === '2025-03-26';
|
||||
190
apps/meteor/ee/server/api/roles.ts
Normal file
190
apps/meteor/ee/server/api/roles.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { IRole } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Roles } from '@rocket.chat/models';
|
||||
import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../../../server/api/api';
|
||||
import { hasPermissionAsync } from '../../../server/lib/authorization/hasPermission';
|
||||
import { settings } from '../../../server/settings';
|
||||
import { insertRoleAsync } from '../lib/roles/insertRole';
|
||||
import { updateRole } from '../lib/roles/updateRole';
|
||||
|
||||
type RoleCreateProps = Pick<IRole, 'name'> & Partial<Pick<IRole, 'description' | 'scope' | 'mandatory2fa'>>;
|
||||
|
||||
const roleCreatePropsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['Users', 'Subscriptions'],
|
||||
nullable: true,
|
||||
},
|
||||
mandatory2fa: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isRoleCreateProps = ajv.compile<RoleCreateProps>(roleCreatePropsSchema);
|
||||
|
||||
type RoleUpdateProps = {
|
||||
roleId: IRole['_id'];
|
||||
name: IRole['name'];
|
||||
} & Partial<RoleCreateProps>;
|
||||
|
||||
const roleUpdatePropsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roleId: {
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['Users', 'Subscriptions'],
|
||||
nullable: true,
|
||||
},
|
||||
mandatory2fa: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ['roleId', 'name'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const isRoleUpdateProps = ajv.compile<RoleUpdateProps>(roleUpdatePropsSchema);
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/roles.create': {
|
||||
POST: (params: RoleCreateProps) => {
|
||||
role: IRole;
|
||||
};
|
||||
};
|
||||
'/v1/roles.update': {
|
||||
POST: (role: RoleUpdateProps) => {
|
||||
role: IRole;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const roleResponseSchema = ajv.compile<{ role: IRole }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
role: { $ref: '#/components/schemas/IRole' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['role', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.post(
|
||||
'roles.create',
|
||||
{
|
||||
authRequired: true,
|
||||
license: ['custom-roles'],
|
||||
body: isRoleCreateProps,
|
||||
response: {
|
||||
200: roleResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('custom-roles')) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature');
|
||||
}
|
||||
|
||||
const { userId } = this;
|
||||
|
||||
if (!userId || !(await hasPermissionAsync(userId, 'access-permissions'))) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed');
|
||||
}
|
||||
|
||||
const { name, scope, description, mandatory2fa } = this.bodyParams;
|
||||
|
||||
if (await Roles.findOneByIdOrName(name)) {
|
||||
throw new Meteor.Error('error-duplicate-role-names-not-allowed', 'Role name already exists');
|
||||
}
|
||||
|
||||
const roleData = {
|
||||
description: description || '',
|
||||
...(mandatory2fa !== undefined && { mandatory2fa }),
|
||||
name,
|
||||
scope: scope || 'Users',
|
||||
protected: false,
|
||||
};
|
||||
|
||||
const options = {
|
||||
broadcastUpdate: settings.get<boolean>('UI_DisplayRoles'),
|
||||
};
|
||||
|
||||
const role = await insertRoleAsync(roleData, options);
|
||||
|
||||
return API.v1.success({ role });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'roles.update',
|
||||
{
|
||||
authRequired: true,
|
||||
// No route-level `license` gate: the license middleware would reject before the handler,
|
||||
// but updating a protected role is allowed without custom-roles (handled in-action below).
|
||||
body: isRoleUpdateProps,
|
||||
response: {
|
||||
200: roleResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!(await hasPermissionAsync(this.user, 'access-permissions'))) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed');
|
||||
}
|
||||
|
||||
const { roleId, name, scope, description, mandatory2fa } = this.bodyParams;
|
||||
|
||||
const role = await Roles.findOne(roleId);
|
||||
|
||||
if (!License.hasModule('custom-roles') && !role?.protected) {
|
||||
throw new Meteor.Error('error-action-not-allowed', 'This is an enterprise feature');
|
||||
}
|
||||
|
||||
const roleData = {
|
||||
description: description || '',
|
||||
...(mandatory2fa !== undefined && { mandatory2fa }),
|
||||
name,
|
||||
scope: scope || 'Users',
|
||||
protected: false,
|
||||
};
|
||||
|
||||
const options = {
|
||||
broadcastUpdate: settings.get<boolean>('UI_DisplayRoles'),
|
||||
};
|
||||
|
||||
const updatedRole = await updateRole(roleId, roleData, options);
|
||||
|
||||
return API.v1.success({ role: updatedRole });
|
||||
},
|
||||
);
|
||||
380
apps/meteor/ee/server/api/sessions.ts
Normal file
380
apps/meteor/ee/server/api/sessions.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IUser, ISession, DeviceManagementSession, DeviceManagementPopulatedSession } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Users, Sessions } from '@rocket.chat/models';
|
||||
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
|
||||
import {
|
||||
ajv,
|
||||
ajvQuery,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateNotFoundErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { API } from '../../../server/api/api';
|
||||
import { getPaginationItems } from '../../../server/api/lib/getPaginationItems';
|
||||
|
||||
type SessionsProps = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
const isSessionsProps = ajv.compile<SessionsProps>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['sessionId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
type SessionsPaginateProps = PaginatedRequest<{
|
||||
filter?: string;
|
||||
}>;
|
||||
|
||||
const isSessionsPaginateProps = ajvQuery.compile<SessionsPaginateProps>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
offset: {
|
||||
type: 'number',
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
},
|
||||
sort: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const validateSortKeys = (sortKeys: string[]): boolean => {
|
||||
const validSortKeys = ['loginAt', 'device.name', 'device.os.name', 'device.os.version', '_user.name', '_user.username'];
|
||||
|
||||
return sortKeys.every((s) => validSortKeys.includes(s));
|
||||
};
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/sessions/list': {
|
||||
GET: (params: SessionsPaginateProps) => PaginatedResult<{ sessions: Array<DeviceManagementSession> }>;
|
||||
};
|
||||
'/v1/sessions/info': {
|
||||
GET: (params: SessionsProps) => DeviceManagementSession;
|
||||
};
|
||||
'/v1/sessions/logout.me': {
|
||||
POST: (params: SessionsProps) => Pick<ISession, 'sessionId'>;
|
||||
};
|
||||
'/v1/sessions/list.all': {
|
||||
GET: (params: SessionsPaginateProps) => PaginatedResult<{ sessions: Array<DeviceManagementPopulatedSession> }>;
|
||||
};
|
||||
'/v1/sessions/info.admin': {
|
||||
GET: (params: SessionsProps) => DeviceManagementPopulatedSession;
|
||||
};
|
||||
'/v1/sessions/logout': {
|
||||
POST: (params: SessionsProps) => Pick<ISession, 'sessionId'>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsListResponseSchema = ajv.compile<PaginatedResult<{ sessions: DeviceManagementSession[] }>>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessions: { type: 'array', items: { $ref: '#/components/schemas/DeviceManagementSession' } },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['sessions', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const sessionsListAllResponseSchema = ajv.compile<PaginatedResult<{ sessions: DeviceManagementPopulatedSession[] }>>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessions: { type: 'array', items: { $ref: '#/components/schemas/DeviceManagementPopulatedSession' } },
|
||||
count: { type: 'number' },
|
||||
offset: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['sessions', 'count', 'offset', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const sessionInfoResponseSchema = ajv.compile<DeviceManagementSession>({
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/DeviceManagementSession' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessionInfoAdminResponseSchema = ajv.compile<DeviceManagementPopulatedSession>({
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/DeviceManagementPopulatedSession' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['success'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessionLogoutResponseSchema = ajv.compile<Pick<ISession, 'sessionId'>>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
required: ['sessionId', 'success'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
API.v1.get(
|
||||
'sessions/list',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isSessionsPaginateProps,
|
||||
license: ['device-management'],
|
||||
response: {
|
||||
200: sessionsListResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('device-management')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort = { loginAt: -1 } } = await this.parseJsonQuery();
|
||||
const search = escapeRegExp(this.queryParams?.filter || '');
|
||||
|
||||
if (!validateSortKeys(Object.keys(sort))) {
|
||||
return API.v1.failure('error-invalid-sort-keys');
|
||||
}
|
||||
|
||||
const sessions = await Sessions.aggregateSessionsByUserId({
|
||||
uid: this.userId,
|
||||
search,
|
||||
sort,
|
||||
offset,
|
||||
count,
|
||||
currentLoginToken: this.token,
|
||||
});
|
||||
return API.v1.success(sessions);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'sessions/info',
|
||||
{
|
||||
authRequired: true,
|
||||
query: isSessionsProps,
|
||||
license: ['device-management'],
|
||||
response: {
|
||||
200: sessionInfoResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('device-management')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { sessionId } = this.queryParams;
|
||||
const sessions = await Sessions.findOneBySessionIdAndUserId(sessionId, this.userId);
|
||||
if (!sessions) {
|
||||
return API.v1.notFound('Session not found');
|
||||
}
|
||||
// Project to DeviceManagementSession explicitly — the raw ISession carries loginToken, which must not leak.
|
||||
return API.v1.success({
|
||||
_id: sessions._id,
|
||||
sessionId: sessions.sessionId,
|
||||
device: sessions.device,
|
||||
host: sessions.host,
|
||||
ip: sessions.ip,
|
||||
loginAt: sessions.loginAt,
|
||||
logoutAt: sessions.logoutAt,
|
||||
userId: sessions.userId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'sessions/logout.me',
|
||||
{
|
||||
authRequired: true,
|
||||
body: isSessionsProps,
|
||||
license: ['device-management'],
|
||||
response: {
|
||||
200: sessionLogoutResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('device-management')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { sessionId } = this.bodyParams;
|
||||
const sessionObj = await Sessions.findOneBySessionIdAndUserId(sessionId, this.userId);
|
||||
|
||||
if (!sessionObj?.loginToken) {
|
||||
return API.v1.notFound('Session not found');
|
||||
}
|
||||
|
||||
await api.broadcast('user.forceLogout', sessionObj.userId, sessionId);
|
||||
|
||||
await Promise.all([
|
||||
Users.unsetOneLoginToken(this.userId, sessionObj.loginToken),
|
||||
Sessions.logoutByloginTokenAndUserId({ loginToken: sessionObj.loginToken, userId: this.userId }),
|
||||
]);
|
||||
|
||||
return API.v1.success({ sessionId });
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'sessions/list.all',
|
||||
{
|
||||
authRequired: true,
|
||||
twoFactorRequired: true,
|
||||
query: isSessionsPaginateProps,
|
||||
permissionsRequired: ['view-device-management'],
|
||||
license: ['device-management'],
|
||||
response: {
|
||||
200: sessionsListAllResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('device-management')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort = { loginAt: -1 } } = await this.parseJsonQuery();
|
||||
const filter = escapeRegExp(this.queryParams?.filter || '');
|
||||
|
||||
if (!validateSortKeys(Object.keys(sort))) {
|
||||
return API.v1.failure('error-invalid-sort-keys');
|
||||
}
|
||||
|
||||
const search: string[] = [];
|
||||
|
||||
if (filter) {
|
||||
search.push(filter);
|
||||
|
||||
search.push(
|
||||
...(await Users.findActiveByUsernameOrNameRegexWithExceptionsAndConditions<Pick<IUser, '_id'>>(
|
||||
{ $regex: filter, $options: 'i' },
|
||||
[],
|
||||
{},
|
||||
{ projection: { _id: 1 }, limit: 5 },
|
||||
)
|
||||
.map((el) => el._id)
|
||||
.toArray()),
|
||||
);
|
||||
}
|
||||
|
||||
const sessions = await Sessions.aggregateSessionsAndPopulate({ search: search.join('|'), sort, offset, count });
|
||||
return API.v1.success(sessions);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.get(
|
||||
'sessions/info.admin',
|
||||
{
|
||||
authRequired: true,
|
||||
twoFactorRequired: true,
|
||||
query: isSessionsProps,
|
||||
permissionsRequired: ['view-device-management'],
|
||||
license: ['device-management'],
|
||||
response: {
|
||||
200: sessionInfoAdminResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('device-management')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const sessionId = this.queryParams?.sessionId;
|
||||
const { sessions } = await Sessions.aggregateSessionsAndPopulate({ search: sessionId, count: 1 });
|
||||
if (!sessions?.length) {
|
||||
return API.v1.notFound('Session not found');
|
||||
}
|
||||
return API.v1.success(sessions[0]);
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.post(
|
||||
'sessions/logout',
|
||||
{
|
||||
authRequired: true,
|
||||
twoFactorRequired: true,
|
||||
body: isSessionsProps,
|
||||
permissionsRequired: ['logout-device-management'],
|
||||
license: ['device-management'],
|
||||
response: {
|
||||
200: sessionLogoutResponseSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
404: validateNotFoundErrorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
if (!License.hasModule('device-management')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { sessionId } = this.bodyParams;
|
||||
const sessionObj = await Sessions.findOneBySessionId(sessionId);
|
||||
|
||||
if (!sessionObj?.loginToken) {
|
||||
return API.v1.notFound('Session not found');
|
||||
}
|
||||
|
||||
await api.broadcast('user.forceLogout', sessionObj.userId, sessionId);
|
||||
|
||||
await Promise.all([
|
||||
Users.unsetOneLoginToken(sessionObj.userId, sessionObj.loginToken),
|
||||
Sessions.logoutByloginTokenAndUserId({ loginToken: sessionObj.loginToken, userId: sessionObj.userId, logoutBy: this.userId }),
|
||||
]);
|
||||
|
||||
return API.v1.success({ sessionId });
|
||||
},
|
||||
);
|
||||
192
apps/meteor/ee/server/api/v1/canned-responses.ts
Normal file
192
apps/meteor/ee/server/api/v1/canned-responses.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse, IUser } from '@rocket.chat/core-typings';
|
||||
import { isPOSTCannedResponsesProps, isCannedResponsesProps, isDELETECannedResponsesProps } from '@rocket.chat/rest-typings';
|
||||
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../server/api/lib/getPaginationItems';
|
||||
import { removeCannedResponse } from '../../meteor-methods/removeCannedResponse';
|
||||
import { saveCannedResponse } from '../../meteor-methods/saveCannedResponse';
|
||||
import { findAllCannedResponses, findAllCannedResponsesFilter, findOneCannedResponse } from '../lib/canned-responses';
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/canned-responses': {
|
||||
GET: (
|
||||
params: PaginatedRequest<{
|
||||
shortcut?: string;
|
||||
text?: string;
|
||||
scope?: string;
|
||||
createdBy?: IUser['username'];
|
||||
tags?: any;
|
||||
departmentId?: ILivechatDepartment['_id'];
|
||||
}>,
|
||||
) => PaginatedResult<{
|
||||
cannedResponses: IOmnichannelCannedResponse[];
|
||||
}>;
|
||||
POST: (params: {
|
||||
_id?: IOmnichannelCannedResponse['_id'];
|
||||
shortcut: string;
|
||||
text: string;
|
||||
scope: string;
|
||||
tags?: any;
|
||||
departmentId?: ILivechatDepartment['_id'];
|
||||
}) => void;
|
||||
DELETE: (params: { _id: IOmnichannelCannedResponse['_id'] }) => void;
|
||||
};
|
||||
'/v1/canned-responses/:_id': {
|
||||
GET: () => {
|
||||
cannedResponse: IOmnichannelCannedResponse;
|
||||
};
|
||||
DELETE: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
API.v1.addRoute(
|
||||
'canned-responses.get',
|
||||
{ authRequired: true, permissionsRequired: ['view-canned-responses'], license: ['canned-responses'] },
|
||||
{
|
||||
async get() {
|
||||
return API.v1.success({
|
||||
responses: await findAllCannedResponses({ userId: this.userId }),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @openapi
|
||||
* /api/v1/canned-responses:
|
||||
* delete:
|
||||
* deprecated: true
|
||||
* security:
|
||||
* $ref: '#/security/authenticated'
|
||||
* parameters:
|
||||
* - in: body
|
||||
* name: body
|
||||
* description: |
|
||||
* **_id** (required): Canned Response ID to be removed.
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - _id
|
||||
* properties:
|
||||
* _id:
|
||||
* type: string
|
||||
* tags:
|
||||
* - Canned_Responses
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Successful Response
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: success
|
||||
* data:
|
||||
* type: object
|
||||
* description: The response data
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* 401:
|
||||
* $ref: '#/responses/Unauthorized'
|
||||
* 403:
|
||||
* $ref: '#/responses/Forbidden'
|
||||
* 404:
|
||||
* $ref: '#/responses/NotFound'
|
||||
* 500:
|
||||
* $ref: '#/responses/InternalServerError'
|
||||
*/
|
||||
API.v1.addRoute(
|
||||
'canned-responses',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: { GET: ['view-canned-responses'], POST: ['save-canned-responses'], DELETE: ['remove-canned-responses'] },
|
||||
validateParams: { POST: isPOSTCannedResponsesProps, DELETE: isDELETECannedResponsesProps, GET: isCannedResponsesProps },
|
||||
license: ['canned-responses'],
|
||||
deprecations: { DELETE: { version: '8.0.0', alternatives: ['/v1/canned-responses/:_id'] } },
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields } = await this.parseJsonQuery();
|
||||
const { shortcut, text, scope, tags, departmentId, createdBy } = this.queryParams;
|
||||
const { cannedResponses, total } = await findAllCannedResponsesFilter({
|
||||
shortcut,
|
||||
text,
|
||||
scope,
|
||||
tags,
|
||||
departmentId,
|
||||
userId: this.userId,
|
||||
createdBy,
|
||||
options: {
|
||||
sort,
|
||||
offset,
|
||||
count,
|
||||
fields,
|
||||
},
|
||||
});
|
||||
return API.v1.success({
|
||||
cannedResponses,
|
||||
count: cannedResponses.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
async post() {
|
||||
const { _id, shortcut, text, scope, departmentId, tags } = this.bodyParams;
|
||||
await saveCannedResponse(
|
||||
this.userId,
|
||||
{
|
||||
shortcut,
|
||||
text,
|
||||
scope,
|
||||
...(tags && { tags }),
|
||||
...(departmentId && { departmentId }),
|
||||
},
|
||||
_id,
|
||||
);
|
||||
return API.v1.success();
|
||||
},
|
||||
// deprecated
|
||||
async delete() {
|
||||
const { _id } = this.bodyParams;
|
||||
await removeCannedResponse(this.userId, _id);
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'canned-responses/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: { GET: ['view-canned-responses'], DELETE: ['remove-canned-responses'] },
|
||||
license: ['canned-responses'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { _id } = this.urlParams;
|
||||
const cannedResponse = await findOneCannedResponse({
|
||||
userId: this.userId,
|
||||
_id,
|
||||
});
|
||||
|
||||
if (!cannedResponse) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
return API.v1.success({ cannedResponse });
|
||||
},
|
||||
async delete() {
|
||||
const { _id } = this.urlParams;
|
||||
await removeCannedResponse(this.userId, _id);
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
32
apps/meteor/ee/server/api/v1/middlewares/license.ts
Normal file
32
apps/meteor/ee/server/api/v1/middlewares/license.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { LicenseManager } from '@rocket.chat/license';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
|
||||
import type { FailureResult, TypedOptions } from '../../../../../server/api/definition';
|
||||
|
||||
export const license =
|
||||
(options: TypedOptions, licenseManager: LicenseManager): MiddlewareHandler =>
|
||||
async (c, next) => {
|
||||
if (!options.license) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const license = options.license.every((license) => licenseManager.hasModule(license));
|
||||
|
||||
const failure: FailureResult<{
|
||||
error: string;
|
||||
errorType: string;
|
||||
}> = {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
success: false,
|
||||
error: 'This is an enterprise feature [error-action-not-allowed]',
|
||||
errorType: 'error-action-not-allowed',
|
||||
},
|
||||
};
|
||||
|
||||
if (!license) {
|
||||
return c.json(failure.body, failure.statusCode);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
127
apps/meteor/ee/server/api/v1/omnichannel/agents.ts
Normal file
127
apps/meteor/ee/server/api/v1/omnichannel/agents.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
isLivechatAnalyticsAgentsTotalServiceTimeProps,
|
||||
isLivechatAnalyticsAgentsAverageServiceTimeProps,
|
||||
isLivechatAnalyticsAgentsAvailableForServiceHistoryProps,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import {
|
||||
findAllAverageServiceTimeAsync,
|
||||
findAllServiceTimeAsync,
|
||||
findAvailableServiceTimeHistoryAsync,
|
||||
} from '../../../../../server/lib/omnichannel/analytics/agents';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/agents/average-service-time',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsAgentsAverageServiceTimeProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { start, end } = 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 { agents, total } = await findAllAverageServiceTimeAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
agents,
|
||||
count: agents.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/agents/total-service-time',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsAgentsTotalServiceTimeProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { start, end } = 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 { agents, total } = await findAllServiceTimeAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
agents,
|
||||
count: agents.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/agents/available-for-service-history',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsAgentsAvailableForServiceHistoryProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { start, end } = this.queryParams;
|
||||
const { fullReport } = 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 { agents, total } = await findAvailableServiceTimeHistoryAsync({
|
||||
start,
|
||||
end,
|
||||
fullReport: fullReport === 'true',
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
agents,
|
||||
count: agents.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
44
apps/meteor/ee/server/api/v1/omnichannel/business-hours.ts
Normal file
44
apps/meteor/ee/server/api/v1/omnichannel/business-hours.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import { findBusinessHours } from '../../../lib/omnichannel/business-hour/lib/business-hour';
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/livechat/business-hours': {
|
||||
GET: (params: PaginatedRequest) => {
|
||||
businessHours: ILivechatBusinessHour[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/business-hours',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-business-hours'], license: ['livechat-enterprise'] },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { name } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findBusinessHours(
|
||||
this.userId,
|
||||
{
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
name,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
93
apps/meteor/ee/server/api/v1/omnichannel/contacts.ts
Normal file
93
apps/meteor/ee/server/api/v1/omnichannel/contacts.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
|
||||
import { ContactVisitorAssociationSchema, ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { changeContactBlockStatus, closeBlockedRoom, ensureSingleContactLicense } from './lib/contacts';
|
||||
import { API } from '../../../../../server/api';
|
||||
import { logger } from '../../../lib/omnichannel/logger';
|
||||
|
||||
type blockContactProps = {
|
||||
visitor: ILivechatContactVisitorAssociation;
|
||||
};
|
||||
|
||||
const blockContactSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
visitor: ContactVisitorAssociationSchema,
|
||||
},
|
||||
required: ['visitor'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const isBlockContactProps = ajv.compile<blockContactProps>(blockContactSchema);
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/omnichannel/contacts.block': {
|
||||
POST: (params: blockContactProps) => void;
|
||||
};
|
||||
'/v1/omnichannel/contacts.unblock': {
|
||||
POST: (params: blockContactProps) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.block',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['block-livechat-contact'],
|
||||
validateParams: isBlockContactProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
ensureSingleContactLicense();
|
||||
const { visitor } = this.bodyParams;
|
||||
const { user } = this;
|
||||
|
||||
await changeContactBlockStatus({
|
||||
visitor,
|
||||
block: true,
|
||||
});
|
||||
logger.info({
|
||||
msg: 'Visitor blocked',
|
||||
visitorId: visitor.visitorId,
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
await closeBlockedRoom(visitor, user);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.unblock',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['unblock-livechat-contact'],
|
||||
validateParams: isBlockContactProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
ensureSingleContactLicense();
|
||||
const { visitor } = this.bodyParams;
|
||||
const { user } = this;
|
||||
|
||||
await changeContactBlockStatus({
|
||||
visitor,
|
||||
block: false,
|
||||
});
|
||||
logger.info({
|
||||
msg: 'Visitor unblocked',
|
||||
visitorId: visitor.visitorId,
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
344
apps/meteor/ee/server/api/v1/omnichannel/departments.ts
Normal file
344
apps/meteor/ee/server/api/v1/omnichannel/departments.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
isLivechatAnalyticsDepartmentsAmountOfChatsProps,
|
||||
isLivechatAnalyticsDepartmentsAverageServiceTimeProps,
|
||||
isLivechatAnalyticsDepartmentsAverageChatDurationTimeProps,
|
||||
isLivechatAnalyticsDepartmentsTotalServiceTimeProps,
|
||||
isLivechatAnalyticsDepartmentsAverageWaitingTimeProps,
|
||||
isLivechatAnalyticsDepartmentsTotalTransferredChatsProps,
|
||||
isLivechatAnalyticsDepartmentsTotalAbandonedChatsProps,
|
||||
isLivechatAnalyticsDepartmentsPercentageAbandonedChatsProps,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import {
|
||||
findAllRoomsAsync,
|
||||
findAllAverageServiceTimeAsync,
|
||||
findAllServiceTimeAsync,
|
||||
findAllAverageWaitingTimeAsync,
|
||||
findAllNumberOfTransferredRoomsAsync,
|
||||
findAllNumberOfAbandonedRoomsAsync,
|
||||
findPercentageOfAbandonedRoomsAsync,
|
||||
findAllAverageOfChatDurationTimeAsync,
|
||||
} from '../../../../../server/lib/omnichannel/analytics/departments';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/amount-of-chats',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsAmountOfChatsProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { start, end } = this.queryParams;
|
||||
const { answered, 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 { departments, total } = await findAllRoomsAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
answered: answered === 'true',
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/average-service-time',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsAverageServiceTimeProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
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 { departments, total } = await findAllAverageServiceTimeAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/average-chat-duration-time',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsAverageChatDurationTimeProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
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 { departments, total } = await findAllAverageOfChatDurationTimeAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/total-service-time',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsTotalServiceTimeProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
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 { departments, total } = await findAllServiceTimeAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/average-waiting-time',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsAverageWaitingTimeProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
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 { departments, total } = await findAllAverageWaitingTimeAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/total-transferred-chats',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsTotalTransferredChatsProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
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 { departments, total } = await findAllNumberOfTransferredRoomsAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/total-abandoned-chats',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsTotalAbandonedChatsProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
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 { departments, total } = await findAllNumberOfAbandonedRoomsAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/departments/percentage-abandoned-chats',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsDepartmentsPercentageAbandonedChatsProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
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 { departments, total } = await findPercentageOfAbandonedRoomsAsync({
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
departmentId,
|
||||
options: { offset, count },
|
||||
});
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
15
apps/meteor/ee/server/api/v1/omnichannel/index.ts
Normal file
15
apps/meteor/ee/server/api/v1/omnichannel/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import './agents';
|
||||
import './departments';
|
||||
import './inquiries';
|
||||
import './monitors';
|
||||
import './sla';
|
||||
import './priorities';
|
||||
import './tags';
|
||||
import './units';
|
||||
import './business-hours';
|
||||
import './rooms';
|
||||
import './transcript';
|
||||
import './reports';
|
||||
import './triggers';
|
||||
import './contacts';
|
||||
import './outbound';
|
||||
27
apps/meteor/ee/server/api/v1/omnichannel/inquiries.ts
Normal file
27
apps/meteor/ee/server/api/v1/omnichannel/inquiries.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { setSLAToInquiry } from './lib/inquiries';
|
||||
import { API } from '../../../../../server/api';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiry.setSLA',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
PUT: { permissions: ['view-l-room', 'manage-livechat-sla'], operation: 'hasAny' },
|
||||
},
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async put() {
|
||||
const { roomId, sla } = this.bodyParams;
|
||||
if (!roomId) {
|
||||
return API.v1.failure("The 'roomId' param is required");
|
||||
}
|
||||
await setSLAToInquiry({
|
||||
userId: this.userId,
|
||||
roomId,
|
||||
sla,
|
||||
});
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
36
apps/meteor/ee/server/api/v1/omnichannel/lib/contacts.ts
Normal file
36
apps/meteor/ee/server/api/v1/omnichannel/lib/contacts.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { IUser, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { LivechatContacts, LivechatRooms, LivechatVisitors } from '@rocket.chat/models';
|
||||
|
||||
import { i18n } from '../../../../../../server/lib/i18n';
|
||||
import { closeRoom } from '../../../../../../server/lib/omnichannel/closeRoom';
|
||||
|
||||
export async function changeContactBlockStatus({ block, visitor }: { visitor: ILivechatContactVisitorAssociation; block: boolean }) {
|
||||
const result = await LivechatContacts.setChannelBlockStatus(visitor, block);
|
||||
|
||||
if (!result.modifiedCount) {
|
||||
throw new Error('error-contact-not-found');
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureSingleContactLicense() {
|
||||
if (!License.hasModule('contact-id-verification')) {
|
||||
throw new Error('error-action-not-allowed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeBlockedRoom(association: ILivechatContactVisitorAssociation, user: IUser) {
|
||||
const visitor = await LivechatVisitors.findOneById(association.visitorId);
|
||||
|
||||
if (!visitor) {
|
||||
throw new Error('error-visitor-not-found');
|
||||
}
|
||||
|
||||
const room = await LivechatRooms.findOneOpenByContactChannelVisitor(association);
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
return closeRoom({ room, visitor, comment: i18n.t('close-blocked-room-comment'), user });
|
||||
}
|
||||
62
apps/meteor/ee/server/api/v1/omnichannel/lib/dashboards.ts
Normal file
62
apps/meteor/ee/server/api/v1/omnichannel/lib/dashboards.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ReportResult, ReportWithUnmatchingElements, IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
import mem from 'mem';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
type AggParams = { start: Date; end: Date; sort: Record<string, 1 | -1>; extraQuery: Filter<IOmnichannelRoom> };
|
||||
|
||||
const defaultValue = { data: [], total: 0 };
|
||||
export const findAllConversationsBySource = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
|
||||
return (await LivechatRooms.getConversationsBySource(start, end, extraQuery).toArray())[0] || defaultValue;
|
||||
};
|
||||
|
||||
export const findAllConversationsByStatus = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
|
||||
return (await LivechatRooms.getConversationsByStatus(start, end, extraQuery).toArray())[0] || defaultValue;
|
||||
};
|
||||
|
||||
export const findAllConversationsByDepartment = async ({
|
||||
start,
|
||||
end,
|
||||
sort,
|
||||
extraQuery,
|
||||
}: AggParams): Promise<ReportWithUnmatchingElements> => {
|
||||
const [result, total] = await Promise.all([
|
||||
LivechatRooms.getConversationsByDepartment(start, end, sort, extraQuery).toArray(),
|
||||
LivechatRooms.getTotalConversationsWithoutDepartmentBetweenDates(start, end, extraQuery),
|
||||
]);
|
||||
|
||||
return {
|
||||
...(result?.[0] || defaultValue),
|
||||
unspecified: total || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const findAllConversationsByTags = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
|
||||
const [result, total] = await Promise.all([
|
||||
LivechatRooms.getConversationsByTags(start, end, sort, extraQuery).toArray(),
|
||||
LivechatRooms.getConversationsWithoutTagsBetweenDate(start, end, extraQuery),
|
||||
]);
|
||||
|
||||
return {
|
||||
...(result?.[0] || defaultValue),
|
||||
unspecified: total || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const findAllConversationsByAgents = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
|
||||
const [result, total] = await Promise.all([
|
||||
LivechatRooms.getConversationsByAgents(start, end, sort, extraQuery).toArray(),
|
||||
LivechatRooms.getTotalConversationsWithoutAgentsBetweenDate(start, end, extraQuery),
|
||||
]);
|
||||
|
||||
return {
|
||||
...(result?.[0] || defaultValue),
|
||||
unspecified: total || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const findAllConversationsBySourceCached = mem(findAllConversationsBySource, { maxAge: 60000, cacheKey: JSON.stringify });
|
||||
export const findAllConversationsByStatusCached = mem(findAllConversationsByStatus, { maxAge: 60000, cacheKey: JSON.stringify });
|
||||
export const findAllConversationsByDepartmentCached = mem(findAllConversationsByDepartment, { maxAge: 60000, cacheKey: JSON.stringify });
|
||||
export const findAllConversationsByTagsCached = mem(findAllConversationsByTags, { maxAge: 60000, cacheKey: JSON.stringify });
|
||||
export const findAllConversationsByAgentsCached = mem(findAllConversationsByAgents, { maxAge: 60000, cacheKey: JSON.stringify });
|
||||
11
apps/meteor/ee/server/api/v1/omnichannel/lib/definition.ts
Normal file
11
apps/meteor/ee/server/api/v1/omnichannel/lib/definition.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface IPagination {
|
||||
offset: number;
|
||||
count: number;
|
||||
sort: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface IPaginatedResponse {
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}
|
||||
45
apps/meteor/ee/server/api/v1/omnichannel/lib/departments.ts
Normal file
45
apps/meteor/ee/server/api/v1/omnichannel/lib/departments.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { LivechatDepartment, LivechatDepartmentAgents, LivechatUnit } from '@rocket.chat/models';
|
||||
|
||||
import { helperLogger } from '../../../../lib/omnichannel/logger';
|
||||
|
||||
export const getDepartmentsWhichUserCanAccess = async (userId: string, includeDisabled = false): Promise<string[]> => {
|
||||
const departments = await LivechatDepartmentAgents.find(
|
||||
{
|
||||
agentId: userId,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
departmentId: 1,
|
||||
},
|
||||
},
|
||||
).toArray();
|
||||
|
||||
const monitoredDepartments = await LivechatUnit.findMonitoredDepartmentsByMonitorId(userId, includeDisabled);
|
||||
const combinedDepartments = [
|
||||
...departments.map((department) => department.departmentId),
|
||||
...monitoredDepartments.map((department) => department._id),
|
||||
];
|
||||
|
||||
return [...new Set(combinedDepartments)];
|
||||
};
|
||||
|
||||
export const hasAccessToDepartment = async (userId: string, departmentId: string): Promise<boolean> => {
|
||||
const department = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(userId, departmentId, { projection: { _id: 1 } });
|
||||
if (department) {
|
||||
helperLogger.debug({
|
||||
msg: 'User has access to department as agent',
|
||||
userId,
|
||||
departmentId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const monitorAccess = await LivechatDepartment.checkIfMonitorIsMonitoringDepartmentById(userId, departmentId);
|
||||
helperLogger.debug({
|
||||
msg: 'User monitor access status for department',
|
||||
userId,
|
||||
departmentId,
|
||||
hasAccess: monitorAccess,
|
||||
});
|
||||
return monitorAccess;
|
||||
};
|
||||
30
apps/meteor/ee/server/api/v1/omnichannel/lib/inquiries.ts
Normal file
30
apps/meteor/ee/server/api/v1/omnichannel/lib/inquiries.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { LivechatInquiry, Users, OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
|
||||
|
||||
import { updateRoomSLA } from './sla';
|
||||
|
||||
export async function setSLAToInquiry({ userId, roomId, sla }: { userId: string; roomId: string; sla?: string }): Promise<void> {
|
||||
const inquiry = await LivechatInquiry.findOneByRoomId(roomId, { projection: { status: 1 } });
|
||||
if (!inquiry || inquiry.status !== 'queued') {
|
||||
throw new Error('error-invalid-inquiry');
|
||||
}
|
||||
|
||||
const slaData = sla && (await OmnichannelServiceLevelAgreements.findOneByIdOrName(sla));
|
||||
if (!slaData) {
|
||||
throw new Error('error-invalid-sla');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(userId, { projection: { _id: 1, username: 1, name: 1 } });
|
||||
if (!user?.username) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
await updateRoomSLA(
|
||||
roomId,
|
||||
{
|
||||
_id: user._id,
|
||||
name: user.name || '',
|
||||
username: user.username,
|
||||
},
|
||||
slaData,
|
||||
);
|
||||
}
|
||||
71
apps/meteor/ee/server/api/v1/omnichannel/lib/monitors.ts
Normal file
71
apps/meteor/ee/server/api/v1/omnichannel/lib/monitors.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ILivechatMonitor, IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
export async function findMonitors({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
pagination: {
|
||||
offset: number;
|
||||
count: number;
|
||||
sort: {
|
||||
[key: string]: 1 | -1;
|
||||
};
|
||||
};
|
||||
}): Promise<PaginatedResult<{ monitors: ILivechatMonitor[] }>> {
|
||||
const query = {};
|
||||
if (text) {
|
||||
const filterReg = new RegExp(escapeRegExp(text), 'i');
|
||||
Object.assign(query, {
|
||||
$or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }],
|
||||
});
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = Users.findPaginatedUsersInRolesWithQuery<ILivechatMonitor>('livechat-monitor', query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: {
|
||||
username: 1,
|
||||
name: 1,
|
||||
status: 1,
|
||||
statusLivechat: 1,
|
||||
emails: 1,
|
||||
livechat: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const [monitors, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
monitors,
|
||||
count: monitors.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findMonitorByUsername({ username }: { username: string }): Promise<IUser> {
|
||||
const user = await Users.findOne(
|
||||
{ username, roles: 'livechat-monitor' },
|
||||
{
|
||||
projection: {
|
||||
username: 1,
|
||||
name: 1,
|
||||
status: 1,
|
||||
statusLivechat: 1,
|
||||
emails: 1,
|
||||
livechat: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('invalid-user');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
73
apps/meteor/ee/server/api/v1/omnichannel/lib/outbound.ts
Normal file
73
apps/meteor/ee/server/api/v1/omnichannel/lib/outbound.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Apps } from '@rocket.chat/apps';
|
||||
import type {
|
||||
IOutboundProvider,
|
||||
ValidOutboundProvider,
|
||||
IOutboundMessageProviderService,
|
||||
IOutboundProviderMetadata,
|
||||
IOutboundMessage,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { ValidOutboundProviderList } from '@rocket.chat/core-typings';
|
||||
|
||||
import { OutboundMessageProvider } from '../../../../../../server/lib/OutboundMessageProvider';
|
||||
import { getOutboundService } from '../../../../../../server/lib/omnichannel/outboundcommunication';
|
||||
|
||||
export class OutboundMessageProviderService implements IOutboundMessageProviderService {
|
||||
private readonly provider: OutboundMessageProvider;
|
||||
|
||||
constructor() {
|
||||
this.provider = new OutboundMessageProvider();
|
||||
}
|
||||
|
||||
get outboundMessageProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
private isProviderValid(type: any): type is ValidOutboundProvider {
|
||||
return ValidOutboundProviderList.includes(type);
|
||||
}
|
||||
|
||||
public listOutboundProviders(type?: string): IOutboundProvider[] {
|
||||
if (type !== undefined && !this.isProviderValid(type)) {
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
|
||||
return this.provider.getOutboundMessageProviders(type);
|
||||
}
|
||||
|
||||
public getProviderMetadata(providerId: string): Promise<IOutboundProviderMetadata> {
|
||||
const provider = this.provider.findOneByProviderId(providerId);
|
||||
if (!provider) {
|
||||
throw new Error('error-invalid-provider');
|
||||
}
|
||||
|
||||
return this.getProviderManager().getProviderMetadata(provider.appId, provider.type);
|
||||
}
|
||||
|
||||
private getProviderManager() {
|
||||
if (!Apps.self?.isLoaded()) {
|
||||
throw new Error('apps-engine-not-loaded');
|
||||
}
|
||||
|
||||
const manager = Apps.self?.getManager()?.getOutboundCommunicationProviderManager();
|
||||
if (!manager) {
|
||||
throw new Error('apps-engine-not-configured-correctly');
|
||||
}
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
public sendMessage(providerId: string, message: IOutboundMessage) {
|
||||
const provider = this.provider.findOneByProviderId(providerId);
|
||||
if (!provider) {
|
||||
throw new Error('error-invalid-provider');
|
||||
}
|
||||
|
||||
return this.getProviderManager().sendOutboundMessage(provider.appId, provider.type, message);
|
||||
}
|
||||
}
|
||||
|
||||
export const outboundMessageProvider = new OutboundMessageProviderService();
|
||||
|
||||
getOutboundService.patch(() => {
|
||||
return outboundMessageProvider;
|
||||
});
|
||||
120
apps/meteor/ee/server/api/v1/omnichannel/lib/priorities.ts
Normal file
120
apps/meteor/ee/server/api/v1/omnichannel/lib/priorities.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { ILivechatPriority, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { LivechatInquiry, LivechatPriority, LivechatRooms } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
|
||||
import { notifyOnLivechatInquiryChangedByRoom, notifyOnRoomChanged } from '../../../../../../server/lib/notifyListener';
|
||||
import { logger } from '../../../../lib/omnichannel/logger';
|
||||
|
||||
type FindPriorityParams = {
|
||||
text?: string;
|
||||
pagination: {
|
||||
offset: number;
|
||||
count: number;
|
||||
sort: FindOptions<ILivechatPriority>['sort'];
|
||||
};
|
||||
};
|
||||
|
||||
export async function findPriority({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindPriorityParams): Promise<PaginatedResult<{ priorities: ILivechatPriority[] }>> {
|
||||
const query = {
|
||||
...(text && { $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }),
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = LivechatPriority.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [priorities, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
priorities,
|
||||
count: priorities.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updatePriority(_id: string, data: Pick<ILivechatPriority, 'name'> & { reset?: boolean }): Promise<ILivechatPriority> {
|
||||
if (data.name) {
|
||||
// If we want to enforce translated duplicates we need to change this
|
||||
const priority = await LivechatPriority.findOneNameUsingRegex(data.name, { projection: { name: 1 } });
|
||||
if (priority && priority._id !== _id) {
|
||||
throw new Error('error-duplicate-priority-name');
|
||||
}
|
||||
}
|
||||
|
||||
const createdResult = await LivechatPriority.updatePriority(_id, data.reset || false, data.name);
|
||||
|
||||
if (!createdResult) {
|
||||
logger.error({ msg: 'Error updating priority: unsuccessful result from MongoDB', priorityId: _id, result: createdResult });
|
||||
throw Error('error-unable-to-update-priority');
|
||||
}
|
||||
|
||||
return createdResult;
|
||||
}
|
||||
|
||||
export const updateRoomPriority = async (
|
||||
rid: string,
|
||||
user: Required<Pick<IUser, '_id' | 'username' | 'name'>>,
|
||||
priorityId: string,
|
||||
): Promise<void> => {
|
||||
const room = await LivechatRooms.findOneById(rid);
|
||||
if (!room) {
|
||||
throw new Error('error-room-does-not-exist');
|
||||
}
|
||||
|
||||
const priority = await LivechatPriority.findOneById(priorityId);
|
||||
if (!priority) {
|
||||
throw new Error('error-invalid-priority');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
LivechatRooms.setPriorityByRoomId(rid, priority),
|
||||
LivechatInquiry.setPriorityForRoom(rid, priority),
|
||||
addPriorityChangeHistoryToRoom(room._id, user, priority),
|
||||
]);
|
||||
|
||||
void notifyOnRoomChanged({ ...room, priorityId: priority._id, priorityWeight: priority.sortItem }, 'updated');
|
||||
void notifyOnLivechatInquiryChangedByRoom(rid, 'updated');
|
||||
};
|
||||
|
||||
export const removePriorityFromRoom = async (rid: string, user: Required<Pick<IUser, '_id' | 'username' | 'name'>>): Promise<void> => {
|
||||
const room = await LivechatRooms.findOneById<Omit<IOmnichannelRoom, 'priorityId' | 'priorityWeight'>>(rid, {
|
||||
projection: { priorityId: 0, priorityWeight: 0 },
|
||||
});
|
||||
if (!room) {
|
||||
throw new Error('error-room-does-not-exist');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
LivechatRooms.unsetPriorityByRoomId(rid),
|
||||
LivechatInquiry.unsetPriorityForRoom(rid),
|
||||
addPriorityChangeHistoryToRoom(rid, user),
|
||||
]);
|
||||
|
||||
void notifyOnRoomChanged(room, 'updated');
|
||||
void notifyOnLivechatInquiryChangedByRoom(rid, 'updated');
|
||||
};
|
||||
|
||||
const addPriorityChangeHistoryToRoom = async (
|
||||
roomId: string,
|
||||
user: Required<Pick<IUser, '_id' | 'username' | 'name'>>,
|
||||
priority?: Pick<ILivechatPriority, 'name' | 'i18n'>,
|
||||
) => {
|
||||
await Message.saveSystemMessage('omnichannel_priority_change_history', roomId, '', user, {
|
||||
priorityData: {
|
||||
definedBy: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
},
|
||||
...(priority && { priority }),
|
||||
},
|
||||
});
|
||||
};
|
||||
58
apps/meteor/ee/server/api/v1/omnichannel/lib/sla.ts
Normal file
58
apps/meteor/ee/server/api/v1/omnichannel/lib/sla.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { IOmnichannelServiceLevelAgreements, IUser } from '@rocket.chat/core-typings';
|
||||
import { OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
|
||||
import {
|
||||
addSlaChangeHistoryToRoom,
|
||||
removeInquiryQueueSla,
|
||||
removeSlaFromRoom,
|
||||
updateInquiryQueueSla,
|
||||
updateRoomSlaWeights,
|
||||
} from '../../../../lib/omnichannel/SlaHelper';
|
||||
|
||||
type FindSLAParams = {
|
||||
text?: string;
|
||||
pagination: {
|
||||
offset: number;
|
||||
count: number;
|
||||
sort: FindOptions<IOmnichannelServiceLevelAgreements>['sort'];
|
||||
};
|
||||
};
|
||||
|
||||
export async function findSLA({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindSLAParams): Promise<PaginatedResult<{ sla: IOmnichannelServiceLevelAgreements[] }>> {
|
||||
const query = {
|
||||
...(text && { $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }),
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = OmnichannelServiceLevelAgreements.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [sla, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
sla,
|
||||
count: sla.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export const updateRoomSLA = async (
|
||||
roomId: string,
|
||||
user: Required<Pick<IUser, '_id' | 'username' | 'name'>>,
|
||||
sla: Pick<IOmnichannelServiceLevelAgreements, '_id' | 'name' | 'dueTimeInMinutes'>,
|
||||
) => {
|
||||
await Promise.all([updateInquiryQueueSla(roomId, sla), updateRoomSlaWeights(roomId, sla), addSlaChangeHistoryToRoom(roomId, user, sla)]);
|
||||
};
|
||||
|
||||
export const removeRoomSLA = async (roomId: string, user: Required<Pick<IUser, '_id' | 'username' | 'name'>>) => {
|
||||
await Promise.all([removeInquiryQueueSla(roomId), removeSlaFromRoom(roomId), addSlaChangeHistoryToRoom(roomId, user)]);
|
||||
};
|
||||
127
apps/meteor/ee/server/api/v1/omnichannel/lib/tags.ts
Normal file
127
apps/meteor/ee/server/api/v1/omnichannel/lib/tags.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { ILivechatTag, FindTagsResult } from '@rocket.chat/core-typings';
|
||||
import { LivechatTag } from '@rocket.chat/models';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { Filter, FindOptions } from 'mongodb';
|
||||
|
||||
import { getDepartmentsWhichUserCanAccess } from './departments';
|
||||
import { hasPermissionAsync } from '../../../../../../server/lib/authorization/hasPermission';
|
||||
import { helperLogger } from '../../../../lib/omnichannel/logger';
|
||||
|
||||
type FindTagsParams = {
|
||||
userId: string;
|
||||
text?: string;
|
||||
pagination: {
|
||||
offset: number;
|
||||
count: number;
|
||||
sort: FindOptions<ILivechatTag>['sort'];
|
||||
};
|
||||
department?: string;
|
||||
viewAll?: boolean;
|
||||
};
|
||||
|
||||
type FindTagsByIdParams = {
|
||||
userId: string;
|
||||
tagId: string;
|
||||
};
|
||||
|
||||
type FindTagsByIdResult = ILivechatTag | null;
|
||||
|
||||
// If viewAll is true
|
||||
// -> & user has access to all tags
|
||||
// -> then all tags will be returned
|
||||
// -> Pages:
|
||||
// - Admin > Omnichannel > Tags
|
||||
// - Current chat's panel, filter by tags
|
||||
// - Canned response creation
|
||||
// -> & user does not have access to all tags
|
||||
// -> then only public tags will be returned (unauthorized access - no page uses this)
|
||||
// If viewAll is false
|
||||
// -> & user has access to all tags
|
||||
// -> & department is not specified
|
||||
// -> only public tags will be returned (Pages: Close chat modal tag selection for chats not associated with a department being closed by manager)
|
||||
// -> & department is specified
|
||||
// -> only tags associated with the department will be returned (Page: Close chat modal tag selection for chats associated with a department being closed by manager)
|
||||
// -> & user does not have access to all tags (same as viewAll = false & user has access to all tags)
|
||||
// -> & department is not specified
|
||||
// -> only public tags will be returned (Page: Close chat modal tag selection for chats not associated with a department being closed by agent)
|
||||
// -> & department is specified
|
||||
// -> only tags associated with the department will be returned (Page: Close chat modal tag selection for chats associated with a department being closed by agent)
|
||||
export async function findTags({
|
||||
userId,
|
||||
text,
|
||||
department,
|
||||
viewAll,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindTagsParams): Promise<FindTagsResult> {
|
||||
let filteredDepartmentIds: string[] = [];
|
||||
|
||||
if (!(await hasPermissionAsync(userId, 'manage-livechat-tags'))) {
|
||||
if (viewAll) {
|
||||
viewAll = false;
|
||||
}
|
||||
|
||||
// Get a list of all departments this user has access to and only
|
||||
// return tags that are associated with those departments
|
||||
filteredDepartmentIds = await getDepartmentsWhichUserCanAccess(userId);
|
||||
|
||||
helperLogger.debug({
|
||||
msg: 'User does not have permission to manage livechat tags. Filtering tags by departments user has access to.',
|
||||
userId,
|
||||
accessibleDepartmentsLength: filteredDepartmentIds.length,
|
||||
top5AccessibleDepartments: filteredDepartmentIds.slice(0, 5),
|
||||
});
|
||||
|
||||
if (department && !filteredDepartmentIds.includes(department)) {
|
||||
department = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (department) {
|
||||
// In certain cases, the user would only want to see tags for a specific department
|
||||
// EG: When closing the chat which is associated with a specific department
|
||||
// user get's to choose which tag to use to close the chat
|
||||
// (only tags associated with the department should be shown)
|
||||
filteredDepartmentIds = [department];
|
||||
}
|
||||
|
||||
const query: {
|
||||
$and?: Filter<ILivechatTag>[];
|
||||
} = {
|
||||
$and: [
|
||||
...(text ? [{ $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }] : []),
|
||||
...(!viewAll
|
||||
? [
|
||||
{
|
||||
$or: [
|
||||
{ departments: { $size: 0 } },
|
||||
...(filteredDepartmentIds.length ? [{ departments: { $in: filteredDepartmentIds } }] : []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
if (!query?.$and?.length) {
|
||||
delete query.$and;
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = LivechatTag.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [tags, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
tags,
|
||||
count: tags.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findTagById({ tagId }: FindTagsByIdParams): Promise<FindTagsByIdResult> {
|
||||
return LivechatTag.findOneById(tagId);
|
||||
}
|
||||
57
apps/meteor/ee/server/api/v1/omnichannel/lib/triggers.ts
Normal file
57
apps/meteor/ee/server/api/v1/omnichannel/lib/triggers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
export async function callTriggerExternalService({
|
||||
url,
|
||||
timeout,
|
||||
fallbackMessage,
|
||||
body,
|
||||
headers,
|
||||
}: {
|
||||
url: string;
|
||||
timeout: number;
|
||||
fallbackMessage: string;
|
||||
body: Record<string, any>;
|
||||
headers: Record<string, string>;
|
||||
}) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
timeout: timeout || 1000,
|
||||
body,
|
||||
headers,
|
||||
method: 'POST',
|
||||
// SECURITY: Integrations can only be configured by users with enough privileges. It's ok to disable this check here.
|
||||
ignoreSsrfValidation: true,
|
||||
});
|
||||
|
||||
if (!response.ok || response.status !== 200) {
|
||||
const text = await response.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const { contents } = data;
|
||||
|
||||
if (
|
||||
!Array.isArray(contents) ||
|
||||
!contents.length ||
|
||||
!contents.every(({ msg, order }) => typeof msg === 'string' && typeof order === 'number')
|
||||
) {
|
||||
throw new Error('External service response does not match expected format');
|
||||
}
|
||||
|
||||
return {
|
||||
response: {
|
||||
statusCode: response.status,
|
||||
contents: data?.contents || [],
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
const isTimeout = error.message === 'The user aborted a request.';
|
||||
return {
|
||||
error: isTimeout ? 'error-timeout' : 'error-invalid-external-service-response',
|
||||
response: error.message,
|
||||
fallbackMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
92
apps/meteor/ee/server/api/v1/omnichannel/lib/units.ts
Normal file
92
apps/meteor/ee/server/api/v1/omnichannel/lib/units.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { IOmnichannelBusinessUnit, ILivechatUnitMonitor } from '@rocket.chat/core-typings';
|
||||
import { LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models';
|
||||
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
|
||||
export async function findUnitsOfUser({
|
||||
text,
|
||||
userId,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
userId: string;
|
||||
pagination: {
|
||||
offset: number;
|
||||
count: number;
|
||||
sort: FindOptions<IOmnichannelBusinessUnit>['sort'];
|
||||
};
|
||||
}): Promise<{
|
||||
units: IOmnichannelBusinessUnit[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}> {
|
||||
const filter = text && new RegExp(escapeRegExp(text), 'i');
|
||||
|
||||
// TODO: an aggregation woudl be faster? or nah?
|
||||
const myUnits = await LivechatUnitMonitors.findByMonitorId(userId)
|
||||
.map((u) => u.unitId)
|
||||
.toArray();
|
||||
const query = { _id: { $in: myUnits }, ...(text && { $or: [{ name: filter }] }) };
|
||||
|
||||
const { cursor, totalCount } = LivechatUnit.findPaginatedUnits(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [units, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
units,
|
||||
count: units.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findUnits({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
pagination: {
|
||||
offset: number;
|
||||
count: number;
|
||||
sort: FindOptions<IOmnichannelBusinessUnit>['sort'];
|
||||
};
|
||||
}): Promise<{
|
||||
units: IOmnichannelBusinessUnit[];
|
||||
count: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
}> {
|
||||
const filter = text && new RegExp(escapeRegExp(text), 'i');
|
||||
|
||||
const query = { ...(text && { $or: [{ name: filter }] }) };
|
||||
|
||||
const { cursor, totalCount } = LivechatUnit.findPaginatedUnits(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [units, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
units,
|
||||
count: units.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findUnitMonitors({ unitId }: { unitId: string }): Promise<ILivechatUnitMonitor[]> {
|
||||
return LivechatUnitMonitors.find({ unitId }).toArray();
|
||||
}
|
||||
|
||||
export async function findUnitById({ unitId, userId }: { unitId: string; userId: string }): Promise<IOmnichannelBusinessUnit | null> {
|
||||
const unitsFromUser = await getUnitsFromUser(userId);
|
||||
return LivechatUnit.findOneById(unitId, {}, { unitsFromUser });
|
||||
}
|
||||
128
apps/meteor/ee/server/api/v1/omnichannel/monitors.ts
Normal file
128
apps/meteor/ee/server/api/v1/omnichannel/monitors.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { ILivechatMonitor } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
isPOSTLivechatMonitorCreateRequest,
|
||||
isPOSTLivechatMonitorsDeleteRequest,
|
||||
POSTLivechatMonitorsCreateSuccessResponse,
|
||||
POSTLivechatMonitorsDeleteSuccessResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { findMonitors, findMonitorByUsername } from './lib/monitors';
|
||||
import { API } from '../../../../../server/api';
|
||||
import type { ExtractRoutesFromAPI } from '../../../../../server/api/ApiClass';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/monitors',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-monitors'],
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findMonitors({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/monitors/:username',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-monitors'],
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { username } = this.urlParams;
|
||||
|
||||
return API.v1.success(
|
||||
(await findMonitorByUsername({
|
||||
username,
|
||||
})) as unknown as ILivechatMonitor,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatMonitorsEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/monitors.create',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatMonitorsCreateSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-monitors'],
|
||||
license: ['livechat-enterprise'],
|
||||
body: isPOSTLivechatMonitorCreateRequest,
|
||||
},
|
||||
async function action() {
|
||||
const { username } = this.bodyParams;
|
||||
|
||||
const result = await LivechatEnterprise.addMonitor(username);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/monitors.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatMonitorsDeleteSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-monitors'],
|
||||
license: ['livechat-enterprise'],
|
||||
body: isPOSTLivechatMonitorsDeleteRequest,
|
||||
},
|
||||
async function action() {
|
||||
const { username } = this.bodyParams;
|
||||
|
||||
try {
|
||||
const result = await LivechatEnterprise.removeMonitor(username);
|
||||
if (!result) {
|
||||
return API.v1.failure('error-removing-monitor');
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Meteor.Error) {
|
||||
return API.v1.failure(error.reason);
|
||||
}
|
||||
|
||||
return API.v1.failure('error-removing-monitor');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatMonitorsEndpoints = ExtractRoutesFromAPI<typeof livechatMonitorsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatMonitorsEndpoints {}
|
||||
}
|
||||
91
apps/meteor/ee/server/api/v1/omnichannel/outbound.ts
Normal file
91
apps/meteor/ee/server/api/v1/omnichannel/outbound.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { canSendOutboundMessage } from '@rocket.chat/omni-core-ee';
|
||||
import {
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { outboundMessageProvider } from './lib/outbound';
|
||||
import { API } from '../../../../../server/api';
|
||||
import type { ExtractRoutesFromAPI } from '../../../../../server/api/ApiClass';
|
||||
import {
|
||||
GETOutboundProvidersResponseSchema,
|
||||
GETOutboundProviderParamsSchema,
|
||||
GETOutboundProviderBadRequestErrorSchema,
|
||||
GETOutboundProviderMetadataSchema,
|
||||
POSTOutboundMessageParams,
|
||||
POSTOutboundMessageSuccessSchema,
|
||||
} from '../../../lib/omnichannel/outboundcomms/rest';
|
||||
|
||||
const outboundCommsEndpoints = API.v1
|
||||
.get(
|
||||
'omnichannel/outbound/providers',
|
||||
{
|
||||
response: {
|
||||
200: GETOutboundProvidersResponseSchema,
|
||||
400: GETOutboundProviderBadRequestErrorSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
query: GETOutboundProviderParamsSchema,
|
||||
permissionsRequired: ['outbound.send-messages'],
|
||||
authRequired: true,
|
||||
license: ['outbound-messaging'],
|
||||
},
|
||||
async function action() {
|
||||
const { type } = this.queryParams;
|
||||
|
||||
const providers = outboundMessageProvider.listOutboundProviders(type);
|
||||
return API.v1.success({
|
||||
providers,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'omnichannel/outbound/providers/:id/metadata',
|
||||
{
|
||||
response: {
|
||||
200: GETOutboundProviderMetadataSchema,
|
||||
400: GETOutboundProviderBadRequestErrorSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
permissionsRequired: ['outbound.send-messages'],
|
||||
authRequired: true,
|
||||
license: ['outbound-messaging'],
|
||||
},
|
||||
async function action() {
|
||||
const { id } = this.urlParams;
|
||||
|
||||
const providerMetadata = await outboundMessageProvider.getProviderMetadata(id);
|
||||
return API.v1.success({
|
||||
metadata: providerMetadata,
|
||||
});
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'omnichannel/outbound/providers/:id/message',
|
||||
{
|
||||
response: {
|
||||
200: POSTOutboundMessageSuccessSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['outbound.send-messages'],
|
||||
body: POSTOutboundMessageParams,
|
||||
license: ['outbound-messaging'],
|
||||
},
|
||||
async function action() {
|
||||
const { id } = this.urlParams;
|
||||
const { departmentId, agentId } = this.bodyParams;
|
||||
|
||||
await canSendOutboundMessage(this.userId, agentId, departmentId);
|
||||
|
||||
await outboundMessageProvider.sendMessage(id, this.bodyParams);
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
export type OutboundCommsEndpoints = ExtractRoutesFromAPI<typeof outboundCommsEndpoints>;
|
||||
99
apps/meteor/ee/server/api/v1/omnichannel/priorities.ts
Normal file
99
apps/meteor/ee/server/api/v1/omnichannel/priorities.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { LivechatPriority } from '@rocket.chat/models';
|
||||
import { isGETLivechatPrioritiesParams, isPUTLivechatPriority } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { findPriority, updatePriority } from './lib/priorities';
|
||||
import { API } from '../../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import { notifyOnLivechatPriorityChanged } from '../../../../../server/lib/notifyListener';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/priorities',
|
||||
{
|
||||
authRequired: true,
|
||||
validateParams: isGETLivechatPrioritiesParams,
|
||||
permissionsRequired: { GET: { permissions: ['manage-livechat-priorities', 'view-l-room'], operation: 'hasAny' } },
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findPriority({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/priorities/:priorityId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['manage-livechat-priorities', 'view-l-room'], operation: 'hasAny' },
|
||||
PUT: { permissions: ['manage-livechat-priorities'], operation: 'hasAny' },
|
||||
},
|
||||
validateParams: { PUT: isPUTLivechatPriority },
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { priorityId } = this.urlParams;
|
||||
const priority = await LivechatPriority.findOneById(priorityId);
|
||||
|
||||
if (!priority) {
|
||||
return API.v1.notFound(`Priority with id ${priorityId} not found`);
|
||||
}
|
||||
|
||||
return API.v1.success(priority);
|
||||
},
|
||||
async put() {
|
||||
const { priorityId } = this.urlParams;
|
||||
|
||||
await updatePriority(priorityId, this.bodyParams);
|
||||
|
||||
void notifyOnLivechatPriorityChanged({ _id: priorityId, ...this.bodyParams });
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/priorities.reset',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
POST: { permissions: ['manage-livechat-priorities'], operation: 'hasAny' },
|
||||
GET: { permissions: ['manage-livechat-priorities'], operation: 'hasAny' },
|
||||
},
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
if (!(await LivechatPriority.canResetPriorities())) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
const eligiblePriorities = (await LivechatPriority.findByDirty().toArray()).map(({ _id }) => _id);
|
||||
|
||||
await LivechatPriority.resetPriorities(eligiblePriorities);
|
||||
|
||||
eligiblePriorities.forEach((_id) => notifyOnLivechatPriorityChanged({ _id, name: undefined }));
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
async get() {
|
||||
return API.v1.success({ reset: await LivechatPriority.canResetPriorities() });
|
||||
},
|
||||
},
|
||||
);
|
||||
155
apps/meteor/ee/server/api/v1/omnichannel/reports.ts
Normal file
155
apps/meteor/ee/server/api/v1/omnichannel/reports.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { isGETDashboardConversationsByType } from '@rocket.chat/rest-typings';
|
||||
import type { Moment } from 'moment';
|
||||
import moment from 'moment';
|
||||
|
||||
import {
|
||||
findAllConversationsBySourceCached,
|
||||
findAllConversationsByStatusCached,
|
||||
findAllConversationsByDepartmentCached,
|
||||
findAllConversationsByTagsCached,
|
||||
findAllConversationsByAgentsCached,
|
||||
} from './lib/dashboards';
|
||||
import { API } from '../../../../../server/api';
|
||||
import { restrictQuery } from '../../../lib/omnichannel/restrictQuery';
|
||||
|
||||
const checkDates = (start: Moment, end: Moment) => {
|
||||
if (!start.isValid()) {
|
||||
throw new Error('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
if (!end.isValid()) {
|
||||
throw new Error('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
// Check dates are no more than 1 year apart using moment
|
||||
// 1.01 === "we allow to pass year by some hours/days"
|
||||
if (moment(end).startOf('day').diff(moment(start).startOf('day'), 'year', true) > 1.01) {
|
||||
throw new Error('The "start" and "end" query parameters must be less than 1 year apart.');
|
||||
}
|
||||
|
||||
if (start.isAfter(end)) {
|
||||
throw new Error('The "start" query parameter must be before the "end" query parameter.');
|
||||
}
|
||||
};
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/conversations-by-source',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-reports'],
|
||||
validateParams: isGETDashboardConversationsByType,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const startDate = moment(start);
|
||||
const endDate = moment(end);
|
||||
|
||||
checkDates(startDate, endDate);
|
||||
|
||||
const extraQuery = await restrictQuery({ userId: this.userId });
|
||||
const result = await findAllConversationsBySourceCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/conversations-by-status',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-reports'],
|
||||
validateParams: isGETDashboardConversationsByType,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
|
||||
const startDate = moment(start);
|
||||
const endDate = moment(end);
|
||||
|
||||
checkDates(startDate, endDate);
|
||||
const extraQuery = await restrictQuery({ userId: this.userId });
|
||||
const result = await findAllConversationsByStatusCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/conversations-by-department',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-reports'],
|
||||
validateParams: isGETDashboardConversationsByType,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const startDate = moment(start);
|
||||
const endDate = moment(end);
|
||||
|
||||
checkDates(startDate, endDate);
|
||||
const extraQuery = await restrictQuery({ userId: this.userId });
|
||||
const result = await findAllConversationsByDepartmentCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/conversations-by-tags',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-reports'],
|
||||
validateParams: isGETDashboardConversationsByType,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const startDate = moment(start);
|
||||
const endDate = moment(end);
|
||||
|
||||
checkDates(startDate, endDate);
|
||||
const extraQuery = await restrictQuery({ userId: this.userId });
|
||||
const result = await findAllConversationsByTagsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/conversations-by-agent',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-reports'],
|
||||
validateParams: isGETDashboardConversationsByType,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const startDate = moment(start);
|
||||
const endDate = moment(end);
|
||||
|
||||
checkDates(startDate, endDate);
|
||||
const extraQuery = await restrictQuery({ userId: this.userId });
|
||||
const result = await findAllConversationsByAgentsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
139
apps/meteor/ee/server/api/v1/omnichannel/rooms.ts
Normal file
139
apps/meteor/ee/server/api/v1/omnichannel/rooms.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { OmnichannelEEService } from '@rocket.chat/core-services';
|
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms, Subscriptions } from '@rocket.chat/models';
|
||||
import { isLivechatRoomOnHoldProps, isLivechatRoomResumeOnHoldProps, isPOSTLivechatRoomPriorityParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { removePriorityFromRoom, updateRoomPriority } from './lib/priorities';
|
||||
import { API } from '../../../../../server/api';
|
||||
import { hasPermissionAsync } from '../../../../../server/lib/authorization/hasPermission';
|
||||
import { i18n } from '../../../../../server/lib/i18n';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.onHold',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['on-hold-livechat-room'],
|
||||
validateParams: isLivechatRoomOnHoldProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { roomId } = this.bodyParams;
|
||||
|
||||
type Room = Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'u' | 'lastMessage' | 'servedBy'>;
|
||||
|
||||
const room = await LivechatRooms.findOneById<Room>(roomId, {
|
||||
projection: { _id: 1, t: 1, open: 1, onHold: 1, u: 1, lastMessage: 1, servedBy: 1 },
|
||||
});
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } });
|
||||
if (!subscription && !(await hasPermissionAsync(this.user, 'on-hold-others-livechat-room'))) {
|
||||
throw new Error('Not_authorized');
|
||||
}
|
||||
|
||||
const onHoldBy = { _id: this.userId, username: this.user.username, name: this.user.name };
|
||||
const comment = i18n.t('Omnichannel_On_Hold_manually', {
|
||||
user: onHoldBy.name || `@${onHoldBy.username}`,
|
||||
});
|
||||
|
||||
await OmnichannelEEService.placeRoomOnHold(room, comment, this.user);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.resumeOnHold',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
validateParams: isLivechatRoomResumeOnHoldProps,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { roomId } = this.bodyParams;
|
||||
if (!roomId || roomId.trim() === '') {
|
||||
throw new Error('invalid-param');
|
||||
}
|
||||
|
||||
type Room = Pick<IOmnichannelRoom, '_id' | 't' | 'open' | 'onHold' | 'servedBy' | 'u' | 'lastMessage'>;
|
||||
|
||||
const room = await LivechatRooms.findOneById<Room>(roomId, {
|
||||
projection: { t: 1, open: 1, onHold: 1, servedBy: 1 },
|
||||
});
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId, { projection: { _id: 1 } });
|
||||
if (!subscription && !(await hasPermissionAsync(this.user, 'on-hold-others-livechat-room'))) {
|
||||
throw new Error('Not_authorized');
|
||||
}
|
||||
|
||||
const { name, username, _id: userId } = this.user;
|
||||
const onHoldBy = { _id: userId, username, name };
|
||||
const comment = i18n.t('Omnichannel_on_hold_chat_resumed_manually', {
|
||||
user: onHoldBy.name || `@${onHoldBy.username}`,
|
||||
});
|
||||
|
||||
await OmnichannelEEService.resumeRoomOnHold(room, comment, this.user, true);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room/:rid/priority',
|
||||
{
|
||||
authRequired: true,
|
||||
validateParams: { POST: isPOSTLivechatRoomPriorityParams },
|
||||
permissionsRequired: {
|
||||
POST: { permissions: ['view-l-room'], operation: 'hasAny' },
|
||||
DELETE: { permissions: ['view-l-room'], operation: 'hasAny' },
|
||||
},
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { rid } = this.urlParams;
|
||||
const { priorityId } = this.bodyParams;
|
||||
|
||||
if (!this.user.username) {
|
||||
return API.v1.failure('Invalid user');
|
||||
}
|
||||
|
||||
await updateRoomPriority(
|
||||
rid,
|
||||
{
|
||||
_id: this.user._id,
|
||||
name: this.user.name || '',
|
||||
username: this.user.username,
|
||||
},
|
||||
priorityId,
|
||||
);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
async delete() {
|
||||
const { rid } = this.urlParams;
|
||||
|
||||
if (!this.user.username) {
|
||||
return API.v1.failure('Invalid user');
|
||||
}
|
||||
|
||||
await removePriorityFromRoom(rid, {
|
||||
_id: this.user._id,
|
||||
name: this.user.name || '',
|
||||
username: this.user.username,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
107
apps/meteor/ee/server/api/v1/omnichannel/sla.ts
Normal file
107
apps/meteor/ee/server/api/v1/omnichannel/sla.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
|
||||
import { isLivechatPrioritiesProps, isCreateOrUpdateLivechatSlaProps } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { findSLA } from './lib/sla';
|
||||
import { API } from '../../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/sla',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['manage-livechat-sla', 'view-l-room'], operation: 'hasAny' },
|
||||
POST: { permissions: ['manage-livechat-sla'], operation: 'hasAny' },
|
||||
},
|
||||
validateParams: {
|
||||
GET: isLivechatPrioritiesProps,
|
||||
POST: isCreateOrUpdateLivechatSlaProps,
|
||||
},
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findSLA({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
async post() {
|
||||
const { name, description, dueTimeInMinutes } = this.bodyParams;
|
||||
|
||||
const newSla = await LivechatEnterprise.saveSLA(
|
||||
null,
|
||||
{
|
||||
name,
|
||||
description,
|
||||
dueTimeInMinutes,
|
||||
},
|
||||
this.userId,
|
||||
);
|
||||
|
||||
return API.v1.success({ sla: newSla });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/sla/:slaId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['manage-livechat-sla', 'view-l-room'], operation: 'hasAny' },
|
||||
DELETE: { permissions: ['manage-livechat-sla'], operation: 'hasAny' },
|
||||
PUT: { permissions: ['manage-livechat-sla'], operation: 'hasAny' },
|
||||
},
|
||||
validateParams: {
|
||||
PUT: isCreateOrUpdateLivechatSlaProps,
|
||||
},
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { slaId } = this.urlParams;
|
||||
|
||||
const sla = await OmnichannelServiceLevelAgreements.findOneById(slaId);
|
||||
|
||||
if (!sla) {
|
||||
return API.v1.notFound(`SLA with id ${slaId} not found`);
|
||||
}
|
||||
return API.v1.success(sla);
|
||||
},
|
||||
async delete() {
|
||||
const { slaId } = this.urlParams;
|
||||
|
||||
await LivechatEnterprise.removeSLA(this.userId, slaId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
async put() {
|
||||
const { name, description, dueTimeInMinutes } = this.bodyParams;
|
||||
const { slaId } = this.urlParams;
|
||||
|
||||
const updatedSla = await LivechatEnterprise.saveSLA(
|
||||
slaId,
|
||||
{
|
||||
name,
|
||||
description,
|
||||
dueTimeInMinutes,
|
||||
},
|
||||
this.userId,
|
||||
);
|
||||
|
||||
return API.v1.success({ sla: updatedSla });
|
||||
},
|
||||
},
|
||||
);
|
||||
123
apps/meteor/ee/server/api/v1/omnichannel/tags.ts
Normal file
123
apps/meteor/ee/server/api/v1/omnichannel/tags.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
isPOSTLivechatTagsSaveParams,
|
||||
POSTLivechatTagsSaveSuccessResponse,
|
||||
isPOSTLivechatTagsDeleteParams,
|
||||
POSTLivechatTagsDeleteSuccessResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { findTags, findTagById } from './lib/tags';
|
||||
import { API } from '../../../../../server/api';
|
||||
import type { ExtractRoutesFromAPI } from '../../../../../server/api/ApiClass';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/tags',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: { GET: { permissions: ['view-l-room', 'manage-livechat-tags'], operation: 'hasAny' } },
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text, viewAll, department } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findTags({
|
||||
userId: this.userId,
|
||||
text,
|
||||
department,
|
||||
viewAll: viewAll === 'true',
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort: typeof sort === 'string' ? JSON.parse(sort || '{}') : sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/tags/:tagId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: { GET: { permissions: ['view-l-room', 'manage-livechat-tags'], operation: 'hasAny' } },
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { tagId } = this.urlParams;
|
||||
|
||||
const tag = await findTagById({
|
||||
userId: this.userId,
|
||||
tagId,
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
return API.v1.notFound('Tag not found');
|
||||
}
|
||||
|
||||
return API.v1.success(tag);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatTagsEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/tags.save',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatTagsSaveSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissions: ['manage-livechat-tags'],
|
||||
license: ['livechat-enterprise'],
|
||||
body: isPOSTLivechatTagsSaveParams,
|
||||
},
|
||||
async function action() {
|
||||
const { _id, tagData, tagDepartments } = this.bodyParams;
|
||||
|
||||
const result = await LivechatEnterprise.saveTag(_id, tagData, tagDepartments);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/tags.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatTagsDeleteSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissions: ['manage-livechat-tags'],
|
||||
license: ['livechat-enterprise'],
|
||||
body: isPOSTLivechatTagsDeleteParams,
|
||||
},
|
||||
async function action() {
|
||||
const { id } = this.bodyParams;
|
||||
|
||||
await LivechatEnterprise.removeTag(id);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatTagsEndpoints = ExtractRoutesFromAPI<typeof livechatTagsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatTagsEndpoints {}
|
||||
}
|
||||
37
apps/meteor/ee/server/api/v1/omnichannel/transcript.ts
Normal file
37
apps/meteor/ee/server/api/v1/omnichannel/transcript.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
|
||||
import { API } from '../../../../../server/api';
|
||||
import { canAccessRoomAsync } from '../../../../../server/lib/authorization/canAccessRoom';
|
||||
import { requestPdfTranscript } from '../../../lib/omnichannel/requestPdfTranscript';
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/:rid/request-transcript',
|
||||
{ authRequired: true, permissionsRequired: ['request-pdf-transcript'], license: ['livechat-enterprise'] },
|
||||
{
|
||||
async post() {
|
||||
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'open' | 'v' | 't' | 'pdfTranscriptFileId'>>(
|
||||
this.urlParams.rid,
|
||||
{
|
||||
projection: { _id: 1, open: 1, v: 1, t: 1, pdfTranscriptFileId: 1 },
|
||||
},
|
||||
);
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, { _id: this.userId }))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
// Flow is as follows:
|
||||
// 1. On Test Mode, call Transcript.workOnPdf directly
|
||||
// 2. On Normal Mode, call QueueWorker.queueWork to queue the work
|
||||
// 3. OmnichannelTranscript.workOnPdf will be called by the worker to generate the transcript
|
||||
// 4. We be happy :)
|
||||
await requestPdfTranscript(room, this.userId);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
134
apps/meteor/ee/server/api/v1/omnichannel/triggers.ts
Normal file
134
apps/meteor/ee/server/api/v1/omnichannel/triggers.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { isExternalServiceTrigger } from '@rocket.chat/core-typings';
|
||||
import { LivechatTrigger } from '@rocket.chat/models';
|
||||
import { isLivechatTriggerWebhookCallParams } from '@rocket.chat/rest-typings';
|
||||
import { isLivechatTriggerWebhookTestParams } from '@rocket.chat/rest-typings/src/v1/omnichannel';
|
||||
|
||||
import { callTriggerExternalService } from './lib/triggers';
|
||||
import { API } from '../../../../../server/api';
|
||||
import { settings } from '../../../../../server/settings';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/triggers/external-service/test',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatTriggerWebhookTestParams,
|
||||
rateLimiterOptions: { numRequestsAllowed: 15, intervalTimeInMS: 60000 },
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { webhookUrl, timeout, fallbackMessage, extraData: clientParams } = this.bodyParams;
|
||||
|
||||
const token = settings.get<string>('Livechat_secret_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Livechat secret token is not configured');
|
||||
}
|
||||
|
||||
const body = {
|
||||
metadata: clientParams,
|
||||
visitorToken: '1234567890',
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-RocketChat-Livechat-Token': token,
|
||||
};
|
||||
|
||||
const response = await callTriggerExternalService({
|
||||
url: webhookUrl,
|
||||
timeout,
|
||||
fallbackMessage,
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return API.v1.failure({
|
||||
triggerId: 'test-trigger',
|
||||
...response,
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
triggerId: 'test-trigger',
|
||||
...response,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/triggers/:_id/external-service/call',
|
||||
{
|
||||
authRequired: false,
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 10,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
validateParams: isLivechatTriggerWebhookCallParams,
|
||||
license: ['livechat-enterprise'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { _id: triggerId } = this.urlParams;
|
||||
const { token: visitorToken, extraData } = this.bodyParams;
|
||||
|
||||
const trigger = await LivechatTrigger.findOneById(triggerId);
|
||||
|
||||
if (!trigger) {
|
||||
throw new Error('Invalid trigger');
|
||||
}
|
||||
|
||||
if (!trigger?.actions.length || !isExternalServiceTrigger(trigger)) {
|
||||
throw new Error('Trigger is not configured to use an external service');
|
||||
}
|
||||
|
||||
const { params: { serviceTimeout = 5000, serviceUrl, serviceFallbackMessage = 'trigger-default-fallback-message' } = {} } =
|
||||
trigger.actions[0];
|
||||
|
||||
if (!serviceUrl) {
|
||||
throw new Error('Invalid service URL');
|
||||
}
|
||||
|
||||
const token = settings.get<string>('Livechat_secret_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Livechat secret token is not configured');
|
||||
}
|
||||
|
||||
const body = {
|
||||
metadata: extraData,
|
||||
visitorToken,
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-RocketChat-Livechat-Token': token,
|
||||
};
|
||||
|
||||
const response = await callTriggerExternalService({
|
||||
url: serviceUrl,
|
||||
timeout: serviceTimeout,
|
||||
fallbackMessage: serviceFallbackMessage,
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return API.v1.failure({
|
||||
triggerId,
|
||||
...response,
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success({
|
||||
triggerId,
|
||||
...response,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
153
apps/meteor/ee/server/api/v1/omnichannel/units.ts
Normal file
153
apps/meteor/ee/server/api/v1/omnichannel/units.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { ILivechatUnitMonitor, IOmnichannelBusinessUnit, OmnichannelBusinessUnitPayload } from '@rocket.chat/core-typings';
|
||||
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { findUnits, findUnitById, findUnitMonitors, findUnitsOfUser } from './lib/units';
|
||||
import { API } from '../../../../../server/api';
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import { hasPermissionAsync } from '../../../../../server/lib/authorization/hasPermission';
|
||||
import { findAllDepartmentsAvailable, findAllDepartmentsByUnit } from '../../../lib/omnichannel/Department';
|
||||
import { LivechatEnterprise } from '../../../lib/omnichannel/LivechatEnterprise';
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Endpoints {
|
||||
'/v1/livechat/units/:unitId/monitors': {
|
||||
GET: (params: { unitId: string }) => { monitors: ILivechatUnitMonitor[] };
|
||||
};
|
||||
'/v1/livechat/units': {
|
||||
GET: (params: PaginatedRequest<{ text?: string }>) => PaginatedResult & { units: IOmnichannelBusinessUnit[] };
|
||||
POST: (params: OmnichannelBusinessUnitPayload) => Omit<IOmnichannelBusinessUnit, '_updatedAt'>;
|
||||
};
|
||||
'/v1/livechat/units/:id': {
|
||||
GET: () => IOmnichannelBusinessUnit;
|
||||
POST: (params: OmnichannelBusinessUnitPayload) => Omit<IOmnichannelBusinessUnit, '_updatedAt'>;
|
||||
DELETE: () => number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/units/:unitId/monitors',
|
||||
{ authRequired: true, permissionsRequired: ['manage-livechat-monitors'], license: ['livechat-enterprise'] },
|
||||
{
|
||||
async get() {
|
||||
const { unitId } = this.urlParams;
|
||||
|
||||
if (!unitId) {
|
||||
return API.v1.failure('The "unitId" parameter is required');
|
||||
}
|
||||
return API.v1.success({
|
||||
monitors: await findUnitMonitors({
|
||||
unitId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/units',
|
||||
{ authRequired: true, permissionsRequired: { POST: ['manage-livechat-units'], GET: [] }, license: ['livechat-enterprise'] },
|
||||
{
|
||||
async get() {
|
||||
const params = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(params);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
if (!(await hasPermissionAsync(this.user, 'manage-livechat-units'))) {
|
||||
return API.v1.success(await findUnitsOfUser({ text, userId: this.userId, pagination: { offset, count, sort } }));
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findUnits({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
async post() {
|
||||
const { unitData, unitMonitors, unitDepartments } = this.bodyParams;
|
||||
return API.v1.success(await LivechatEnterprise.saveUnit(null, unitData, unitMonitors, unitDepartments, this.userId));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/units/:id',
|
||||
{ authRequired: true, permissionsRequired: ['manage-livechat-units'], license: ['livechat-enterprise'] },
|
||||
{
|
||||
async get() {
|
||||
const { id } = this.urlParams;
|
||||
const unit = await findUnitById({
|
||||
unitId: id,
|
||||
userId: this.userId,
|
||||
});
|
||||
|
||||
return API.v1.success(unit);
|
||||
},
|
||||
async post() {
|
||||
const { unitData, unitMonitors, unitDepartments } = this.bodyParams;
|
||||
const { id } = this.urlParams;
|
||||
|
||||
return API.v1.success(await LivechatEnterprise.saveUnit(id, unitData, unitMonitors, unitDepartments, this.userId));
|
||||
},
|
||||
async delete() {
|
||||
const { id } = this.urlParams;
|
||||
|
||||
return API.v1.success((await LivechatEnterprise.removeUnit(id, this.userId)).deletedCount);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/units/:unitId/departments',
|
||||
{ authRequired: true, permissionsRequired: ['manage-livechat-units'], license: ['livechat-enterprise'] },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { unitId } = this.urlParams;
|
||||
|
||||
const { departments, total } = await findAllDepartmentsByUnit(unitId, offset, count);
|
||||
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/units/:unitId/departments/available',
|
||||
{ authRequired: true, permissionsRequired: ['manage-livechat-units'], license: ['livechat-enterprise'] },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { unitId } = this.urlParams;
|
||||
const { text, onlyMyDepartments } = this.queryParams;
|
||||
|
||||
const { departments, total } = await findAllDepartmentsAvailable(
|
||||
this.userId,
|
||||
unitId,
|
||||
offset,
|
||||
count,
|
||||
text,
|
||||
onlyMyDepartments === 'true',
|
||||
);
|
||||
|
||||
return API.v1.success({
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
68
apps/meteor/ee/server/apps/appRequestsCron.ts
Normal file
68
apps/meteor/ee/server/apps/appRequestsCron.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';
|
||||
|
||||
import { appRequestNotififyForUsers } from './marketplace/appRequestNotifyUsers';
|
||||
import { Apps } from './orchestrator';
|
||||
import { getWorkspaceAccessToken } from '../../../server/lib/cloud';
|
||||
import { settings } from '../../../server/settings';
|
||||
|
||||
const appsNotifyAppRequests = async function _appsNotifyAppRequests() {
|
||||
try {
|
||||
if (License.hasOfflineLicense()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installedApps = await Apps.installedApps({ enabled: true });
|
||||
if (!installedApps || installedApps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceUrl = settings.get<string>('Site_Url');
|
||||
const token = await getWorkspaceAccessToken();
|
||||
|
||||
if (!token) {
|
||||
Apps.debugLog(`could not load workspace token to send app requests notifications`);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
ignoreSsrfValidation: true,
|
||||
} as ExtendedFetchOptions;
|
||||
|
||||
const pendingSentUrl = `v1/app-request/sent/pending`;
|
||||
const result = await Apps.getMarketplaceClient().fetch(pendingSentUrl, options);
|
||||
const { data } = await result.json();
|
||||
const filtered = installedApps.filter((app) => data.indexOf(app.getID()) !== -1);
|
||||
|
||||
for await (const app of filtered) {
|
||||
const appId = app.getID();
|
||||
const appName = app.getName();
|
||||
|
||||
const usersNotified = await appRequestNotififyForUsers(Apps.getMarketplaceClient().getMarketplaceUrl(), workspaceUrl, appId, appName)
|
||||
.then(async (response) => {
|
||||
// Mark all app requests as sent
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
await Apps.getMarketplaceClient().fetch(`v1/app-request/markAsSent/${appId}`, { ...options, method: 'POST' });
|
||||
return response;
|
||||
})
|
||||
.catch((err) => {
|
||||
Apps.debugLog(`could not send app request notifications for app ${appId}. Error: ${err}`);
|
||||
return err;
|
||||
});
|
||||
|
||||
const errors = (usersNotified as (string | Error)[]).filter((batch) => batch instanceof Error);
|
||||
if (errors.length > 0) {
|
||||
Apps.debugLog(`Some batches of users could not be notified for app ${appId}. Errors: ${errors}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Apps.debugLog(err);
|
||||
}
|
||||
};
|
||||
|
||||
await cronJobs.add('Apps-Request-End-Users:notify', '0 */12 * * *', async () => appsNotifyAppRequests());
|
||||
@@ -0,0 +1,15 @@
|
||||
import { API } from '../../../../../server/api';
|
||||
import type { AppsRestApi } from '../rest';
|
||||
|
||||
export const registerActionButtonsHandler = ({ api, _manager }: AppsRestApi) =>
|
||||
void api.addRoute(
|
||||
'actionButtons',
|
||||
{ authRequired: false },
|
||||
{
|
||||
async get() {
|
||||
const buttons = await _manager.getUIActionButtonManager().getAllActionButtons();
|
||||
|
||||
return API.v1.success(buttons);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { isAppLogsProps, ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import type { AppsRestApi } from '../rest';
|
||||
import { makeAppLogsQuery } from './lib/makeAppLogsQuery';
|
||||
|
||||
// TODO: this is a very common error response schema. We should find a way to standardize it.
|
||||
const errorResponse = ajv.compile<{
|
||||
success: false;
|
||||
error: string;
|
||||
}>({
|
||||
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'],
|
||||
});
|
||||
|
||||
export const registerAppGeneralLogsHandler = ({ api, _orch }: AppsRestApi) =>
|
||||
void api.get(
|
||||
'logs',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-apps'],
|
||||
query: isAppLogsProps,
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
offset: { type: 'number' },
|
||||
// TODO: reference a schema for the array items
|
||||
logs: { type: 'array' },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
required: ['offset', 'logs', 'count', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: errorResponse,
|
||||
401: errorResponse,
|
||||
403: errorResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const options = {
|
||||
sort: sort || { _updatedAt: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
};
|
||||
|
||||
let query: Record<string, any>;
|
||||
|
||||
try {
|
||||
query = makeAppLogsQuery(this.queryParams);
|
||||
} catch (error) {
|
||||
return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
|
||||
const result = await _orch.getLogStorage().findPaginated(query, options);
|
||||
|
||||
return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { AppsRestApi } from '../rest';
|
||||
|
||||
// This might be a good candidate for a default validator function exported by @rocket.chat/rest-typings
|
||||
const errorResponse = ajv.compile<{
|
||||
success: false;
|
||||
error: string;
|
||||
}>({
|
||||
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'],
|
||||
});
|
||||
|
||||
export const registerAppLogsDistinctInstanceHandler = ({ api, _orch }: AppsRestApi) =>
|
||||
void api.get(
|
||||
':id/logs/distinctValues',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-apps'],
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
instanceIds: { type: 'array', items: { type: 'string' } },
|
||||
methods: { type: 'array', items: { type: 'string' } },
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
required: ['instanceIds', 'methods', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
401: errorResponse,
|
||||
403: errorResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const app = await _orch.getManager()?.getOneById(this.urlParams.id);
|
||||
|
||||
if (!app) {
|
||||
return api.notFound(`No app found with id: ${this.urlParams.id}`);
|
||||
}
|
||||
|
||||
const result = await _orch.getLogStorage().distinctValues(this.urlParams.id);
|
||||
|
||||
return api.success(result);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { isAppLogsExportProps } from '@rocket.chat/rest-typings';
|
||||
import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv';
|
||||
import { parse } from 'cookie';
|
||||
import { json2csv } from 'json-2-csv';
|
||||
|
||||
import type { AppsRestApi } from '../rest';
|
||||
import { makeAppLogsQuery } from './lib/makeAppLogsQuery';
|
||||
import { APIClass } from '../../../../../server/api/ApiClass';
|
||||
import type { APIActionContext } from '../../../../../server/api/router';
|
||||
|
||||
const isErrorResponse = ajv.compile<{
|
||||
success: false;
|
||||
error: string;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [false],
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
class ExportHandlerAPI extends APIClass {
|
||||
public override async authenticatedRoute(routeContext: APIActionContext): Promise<IUser | null> {
|
||||
const { rc_uid, rc_token } = parse(routeContext.request.headers.get('cookie') || '');
|
||||
|
||||
if (rc_uid) {
|
||||
routeContext.request.headers.set('x-user-id', rc_uid);
|
||||
}
|
||||
|
||||
if (rc_token) {
|
||||
routeContext.request.headers.set('x-auth-token', rc_token);
|
||||
}
|
||||
|
||||
return super.authenticatedRoute(routeContext);
|
||||
}
|
||||
}
|
||||
|
||||
const adhocApi = new ExportHandlerAPI({
|
||||
useDefaultAuth: false,
|
||||
prettyJson: process.env.NODE_ENV !== 'development',
|
||||
});
|
||||
|
||||
export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => {
|
||||
adhocApi.get(
|
||||
':id/export-logs',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-apps'],
|
||||
query: isAppLogsExportProps,
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
body: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: 'The content of the exported logs file, either in JSON or CSV format.',
|
||||
},
|
||||
},
|
||||
}),
|
||||
400: isErrorResponse,
|
||||
401: isErrorResponse,
|
||||
404: isErrorResponse,
|
||||
},
|
||||
},
|
||||
|
||||
async function () {
|
||||
const proxiedApp = _manager.getOneById(this.urlParams.id);
|
||||
|
||||
if (!proxiedApp) {
|
||||
return api.notFound(`No App found by the id of: ${this.urlParams.id}`);
|
||||
}
|
||||
|
||||
let count = 100;
|
||||
|
||||
if (this.queryParams.count !== undefined && this.queryParams.count !== null) {
|
||||
count = parseInt(String(this.queryParams.count || 100));
|
||||
}
|
||||
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const options = {
|
||||
sort: sort || { _updatedAt: -1 },
|
||||
skip: 0,
|
||||
limit: Math.min(count, 2000),
|
||||
};
|
||||
|
||||
let query: ReturnType<typeof makeAppLogsQuery>;
|
||||
|
||||
try {
|
||||
query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams });
|
||||
} catch (error) {
|
||||
return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
|
||||
const result = await _orch.getLogStorage().find(query, options);
|
||||
|
||||
if (!result.length) {
|
||||
return api.failure({ error: 'No logs found for the specified criteria' });
|
||||
}
|
||||
|
||||
let fileContent: Buffer;
|
||||
let filename: string;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
if (this.queryParams.type === 'json') {
|
||||
fileContent = Buffer.from(JSON.stringify(result, null, 2), 'utf8');
|
||||
filename = `app-logs-${this.urlParams.id}-${timestamp}.json`;
|
||||
} else {
|
||||
fileContent = Buffer.from(json2csv(result, { expandArrayObjects: true }), 'utf8');
|
||||
filename = `app-logs-${this.urlParams.id}-${timestamp}.csv`;
|
||||
}
|
||||
|
||||
return {
|
||||
body: fileContent,
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
// 'application/json' here creates problems down the line with the router
|
||||
'Content-Type': 'text/plain',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': fileContent.length.toString(),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
api.router.use(adhocApi.router);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isAppLogsProps, ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { getPaginationItems } from '../../../../../server/api/lib/getPaginationItems';
|
||||
import type { AppsRestApi } from '../rest';
|
||||
import { makeAppLogsQuery } from './lib/makeAppLogsQuery';
|
||||
|
||||
const errorResponse = ajv.compile<{
|
||||
success: false;
|
||||
error: string;
|
||||
}>({
|
||||
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'],
|
||||
});
|
||||
|
||||
export const registerAppLogsHandler = ({ api, _manager, _orch }: AppsRestApi) =>
|
||||
void api.get(
|
||||
':id/logs',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-apps'],
|
||||
query: isAppLogsProps,
|
||||
response: {
|
||||
200: ajv.compile({
|
||||
type: 'object',
|
||||
properties: {
|
||||
offset: { type: 'number' },
|
||||
logs: { type: 'array' },
|
||||
count: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
success: { type: 'boolean' },
|
||||
},
|
||||
required: ['offset', 'logs', 'count', 'total', 'success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: errorResponse,
|
||||
401: errorResponse,
|
||||
403: errorResponse,
|
||||
404: errorResponse,
|
||||
},
|
||||
},
|
||||
async function action() {
|
||||
const proxiedApp = _manager.getOneById(this.urlParams.id);
|
||||
|
||||
if (!proxiedApp) {
|
||||
return api.notFound(`No App found by the id of: ${this.urlParams.id}`);
|
||||
}
|
||||
|
||||
if (this.queryParams.appId && this.queryParams.appId !== this.urlParams.id) {
|
||||
return api.notFound(`Invalid query parameter "appId": ${this.queryParams.appId}`);
|
||||
}
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const options = {
|
||||
sort: sort || { _updatedAt: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
};
|
||||
|
||||
let query: Record<string, any>;
|
||||
|
||||
try {
|
||||
query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams });
|
||||
} catch (error) {
|
||||
return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
|
||||
const result = await _orch.getLogStorage().findPaginated(query, options);
|
||||
|
||||
return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AppManager } from '@rocket.chat/apps/dist/server/AppManager';
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
import { getInstallationSourceFromAppStorageItem } from '../../../../../lib/apps/getInstallationSourceFromAppStorageItem';
|
||||
import { API } from '../../../../../server/api';
|
||||
import type { AppsRestApi } from '../rest';
|
||||
|
||||
export const registerAppsCountHandler = ({ api, _manager }: AppsRestApi) =>
|
||||
void api.addRoute(
|
||||
'count',
|
||||
{ authRequired: false },
|
||||
{
|
||||
async get() {
|
||||
const manager = _manager as AppManager;
|
||||
|
||||
const apps = await manager.get({ enabled: true });
|
||||
const { maxMarketplaceApps, maxPrivateApps } = License.getAppsConfig();
|
||||
|
||||
return API.v1.success({
|
||||
totalMarketplaceEnabled: apps.filter((app) => getInstallationSourceFromAppStorageItem(app.getStorageItem()) === 'marketplace')
|
||||
.length,
|
||||
totalPrivateEnabled: apps.filter((app) => getInstallationSourceFromAppStorageItem(app.getStorageItem()) === 'private').length,
|
||||
maxMarketplaceApps,
|
||||
maxPrivateApps,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AppLogsProps } from '@rocket.chat/rest-typings';
|
||||
|
||||
/**
|
||||
* Creates a query object for fetching app logs based on provided parameters.
|
||||
*
|
||||
* NOTE: This function expects that all values are in the correct format, as it is
|
||||
* used by an endpoint handler which has query parameter validation.
|
||||
*
|
||||
* @param queryParams - The query parameters.
|
||||
* @returns A query object for fetching app logs.
|
||||
* @throws {Error} If the date range is invalid.
|
||||
*/
|
||||
export function makeAppLogsQuery(queryParams: AppLogsProps) {
|
||||
const query: Record<string, any> = {};
|
||||
|
||||
if (queryParams.appId) {
|
||||
query.appId = queryParams.appId;
|
||||
}
|
||||
|
||||
if (queryParams.logLevel) {
|
||||
const queryLogLevel = Number(queryParams.logLevel);
|
||||
const logLevel = ['error'];
|
||||
|
||||
if (queryLogLevel >= 1) {
|
||||
logLevel.push('warn', 'info', 'log');
|
||||
}
|
||||
|
||||
if (queryLogLevel >= 2) {
|
||||
logLevel.push('debug', 'success');
|
||||
}
|
||||
|
||||
query['entries.severity'] = { $in: logLevel };
|
||||
}
|
||||
|
||||
if (queryParams.method) {
|
||||
query.method = queryParams.method;
|
||||
}
|
||||
|
||||
if (queryParams.instanceId) {
|
||||
query.instanceId = queryParams.instanceId;
|
||||
}
|
||||
|
||||
if (queryParams.startDate) {
|
||||
query._updatedAt = {
|
||||
$gte: new Date(queryParams.startDate),
|
||||
};
|
||||
}
|
||||
|
||||
if (queryParams.endDate) {
|
||||
const endDate = new Date(queryParams.endDate);
|
||||
|
||||
if (query._updatedAt?.$gte && query._updatedAt.$gte >= endDate) {
|
||||
throw new Error('Invalid date range');
|
||||
}
|
||||
|
||||
query._updatedAt = {
|
||||
...(query._updatedAt || {}),
|
||||
$lte: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
12
apps/meteor/ee/server/apps/communication/events.ts
Normal file
12
apps/meteor/ee/server/apps/communication/events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum AppEvents {
|
||||
APP_ADDED = 'app/added',
|
||||
APP_REMOVED = 'app/removed',
|
||||
APP_UPDATED = 'app/updated',
|
||||
APP_STATUS_CHANGE = 'app/statusUpdate',
|
||||
APP_SETTING_UPDATED = 'app/settingUpdated',
|
||||
COMMAND_ADDED = 'command/added',
|
||||
COMMAND_DISABLED = 'command/disabled',
|
||||
COMMAND_UPDATED = 'command/updated',
|
||||
COMMAND_REMOVED = 'command/removed',
|
||||
ACTIONS_CHANGED = 'actions/changed',
|
||||
}
|
||||
5
apps/meteor/ee/server/apps/communication/index.ts
Normal file
5
apps/meteor/ee/server/apps/communication/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AppsRestApi } from './rest';
|
||||
import { AppUIKitInteractionApi } from './uikit';
|
||||
import { AppServerNotifier } from './websockets';
|
||||
|
||||
export { AppUIKitInteractionApi, AppsRestApi, AppServerNotifier };
|
||||
1314
apps/meteor/ee/server/apps/communication/rest.ts
Normal file
1314
apps/meteor/ee/server/apps/communication/rest.ts
Normal file
File diff suppressed because it is too large
Load Diff
359
apps/meteor/ee/server/apps/communication/uikit.ts
Normal file
359
apps/meteor/ee/server/apps/communication/uikit.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { AppEvents, type IAppServerOrchestrator } from '@rocket.chat/apps';
|
||||
import { UiKitCoreApp } from '@rocket.chat/core-services';
|
||||
import type { OperationParams, OperationResult, UrlParams } from '@rocket.chat/rest-typings';
|
||||
import bodyParser from 'body-parser';
|
||||
import cors from 'cors';
|
||||
import type { Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { WebApp } from 'meteor/webapp';
|
||||
|
||||
import { authenticationMiddleware } from '../../../../server/api/v1/middlewares/authentication';
|
||||
import { settings } from '../../../../server/settings';
|
||||
import { Apps } from '../orchestrator';
|
||||
|
||||
const apiServer = express();
|
||||
|
||||
apiServer.disable('x-powered-by');
|
||||
|
||||
let corsEnabled = false;
|
||||
let allowListOrigins: string[] = [];
|
||||
|
||||
settings.watch('API_Enable_CORS', (value: boolean) => {
|
||||
corsEnabled = value;
|
||||
});
|
||||
|
||||
settings.watch('API_CORS_Origin', (value: string) => {
|
||||
allowListOrigins = value
|
||||
? value
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((origin) => String(origin).trim().toLocaleLowerCase())
|
||||
: [];
|
||||
});
|
||||
|
||||
WebApp.rawConnectHandlers.use(apiServer);
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const router = express.Router();
|
||||
|
||||
const unauthorized = (res: Response): unknown =>
|
||||
res.status(401).send({
|
||||
status: 'error',
|
||||
message: 'You must be logged in to do this.',
|
||||
});
|
||||
|
||||
Meteor.startup(() => {
|
||||
// use specific rate limit of 600 (which is 60 times the default limits) requests per minute (around 10/second)
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'),
|
||||
max: (settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default') as number) * 60,
|
||||
skip: () =>
|
||||
settings.get('API_Enable_Rate_Limiter') !== true ||
|
||||
(process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true),
|
||||
});
|
||||
|
||||
router.use(apiLimiter);
|
||||
});
|
||||
|
||||
router.use(authenticationMiddleware({ rejectUnauthorized: false }));
|
||||
|
||||
router.use(async (req: Request, res, next) => {
|
||||
const { 'x-visitor-token': visitorToken } = req.headers;
|
||||
|
||||
if (visitorToken && typeof visitorToken === 'string') {
|
||||
req.body.visitor = await Apps.getConverters()?.get('visitors').convertByToken(visitorToken);
|
||||
}
|
||||
|
||||
if (!req.user && !req.body.visitor) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const corsOptions: cors.CorsOptions = {
|
||||
origin: (origin, callback) => {
|
||||
if (
|
||||
!origin ||
|
||||
!corsEnabled ||
|
||||
allowListOrigins.includes('*') ||
|
||||
allowListOrigins.includes(origin) ||
|
||||
origin === settings.get('Site_Url')
|
||||
) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'), false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
apiServer.use('/api/apps/ui.interaction/', bodyParser.json(), cors(corsOptions), router); // didn't have the rateLimiter option
|
||||
|
||||
type UiKitUserInteractionRequest = Request<
|
||||
UrlParams<'/apps/ui.interaction/:id'>,
|
||||
OperationResult<'POST', '/apps/ui.interaction/:id'> | { error: unknown },
|
||||
OperationParams<'POST', '/apps/ui.interaction/:id'> & {
|
||||
visitor?: {
|
||||
id: string;
|
||||
username: string;
|
||||
name?: string;
|
||||
department?: string;
|
||||
updatedAt?: Date;
|
||||
token: string;
|
||||
phone?: { phoneNumber: string }[] | null;
|
||||
visitorEmails?: { address: string }[];
|
||||
livechatData?: Record<string, unknown>;
|
||||
status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled';
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
router.post('/:id', async (req: UiKitUserInteractionRequest, res, next) => {
|
||||
const { id: appId } = req.params;
|
||||
|
||||
const isCoreApp = await UiKitCoreApp.isRegistered(appId);
|
||||
if (!isCoreApp) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = req;
|
||||
|
||||
switch (req.body.type) {
|
||||
case 'blockAction': {
|
||||
const { type, actionId, triggerId, payload, container, visitor } = req.body;
|
||||
const message = 'mid' in req.body ? req.body.mid : undefined;
|
||||
const room = 'rid' in req.body ? req.body.rid : undefined;
|
||||
|
||||
const result = await UiKitCoreApp.blockAction({
|
||||
appId,
|
||||
type,
|
||||
actionId,
|
||||
triggerId,
|
||||
container,
|
||||
message,
|
||||
payload,
|
||||
user,
|
||||
visitor,
|
||||
room,
|
||||
});
|
||||
|
||||
// Using ?? to always send something in the response, even if the app had no result.
|
||||
res.send(result ?? {});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
case 'viewSubmit': {
|
||||
const { type, actionId, triggerId, payload } = req.body;
|
||||
|
||||
const result = await UiKitCoreApp.viewSubmit({
|
||||
appId,
|
||||
type,
|
||||
actionId,
|
||||
triggerId,
|
||||
payload,
|
||||
user,
|
||||
});
|
||||
|
||||
// Using ?? to always send something in the response, even if the app had no result.
|
||||
res.send(result ?? {});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
case 'viewClosed': {
|
||||
const {
|
||||
type,
|
||||
payload: { view, isCleared },
|
||||
triggerId,
|
||||
} = req.body;
|
||||
|
||||
const result = await UiKitCoreApp.viewClosed({
|
||||
appId,
|
||||
triggerId,
|
||||
type,
|
||||
user,
|
||||
payload: {
|
||||
view,
|
||||
isCleared,
|
||||
},
|
||||
});
|
||||
|
||||
// Using ?? to always send something in the response, even if the app had no result.
|
||||
res.send(result ?? {});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('Type not supported');
|
||||
}
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
res.status(500).send({ error });
|
||||
}
|
||||
});
|
||||
|
||||
export class AppUIKitInteractionApi {
|
||||
orch: IAppServerOrchestrator;
|
||||
|
||||
constructor(orch: IAppServerOrchestrator) {
|
||||
this.orch = orch;
|
||||
|
||||
router.post('/:id', this.routeHandler.bind(this));
|
||||
}
|
||||
|
||||
private async routeHandler(
|
||||
req: UiKitUserInteractionRequest,
|
||||
res: Response<OperationResult<'POST', '/apps/ui.interaction/:id'> | { error: unknown }>,
|
||||
): Promise<void> {
|
||||
const { orch } = this;
|
||||
const { id: appId } = req.params;
|
||||
|
||||
switch (req.body.type) {
|
||||
case 'blockAction': {
|
||||
const { type, actionId, triggerId, payload, container, rid } = req.body;
|
||||
const mid = 'mid' in req.body ? req.body.mid : undefined;
|
||||
|
||||
const { visitor } = req.body;
|
||||
const user = orch.getConverters()?.get('users').convertToApp(req.user);
|
||||
const message = mid ? await orch.getConverters()?.get('messages').convertById(mid) : undefined;
|
||||
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
|
||||
|
||||
const action = {
|
||||
type,
|
||||
container,
|
||||
appId,
|
||||
actionId,
|
||||
message,
|
||||
triggerId,
|
||||
payload,
|
||||
user,
|
||||
visitor,
|
||||
room,
|
||||
};
|
||||
|
||||
try {
|
||||
const eventInterface = !visitor ? AppEvents.IUIKitInteractionHandler : AppEvents.IUIKitLivechatInteractionHandler;
|
||||
|
||||
const result = await orch.triggerEvent(eventInterface, action);
|
||||
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
res.status(500).send({ error });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'viewClosed': {
|
||||
const {
|
||||
type,
|
||||
rid,
|
||||
payload: { view, isCleared },
|
||||
} = req.body;
|
||||
|
||||
const user = orch.getConverters()?.get('users').convertToApp(req.user);
|
||||
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
|
||||
|
||||
const action = {
|
||||
type,
|
||||
appId,
|
||||
user,
|
||||
room,
|
||||
payload: {
|
||||
view,
|
||||
isCleared,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await orch.triggerEvent(AppEvents.IUIKitInteractionHandler, action);
|
||||
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
res.status(500).send({ error });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'viewSubmit': {
|
||||
const { type, actionId, triggerId, payload, rid } = req.body;
|
||||
|
||||
const user = orch.getConverters()?.get('users').convertToApp(req.user);
|
||||
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
|
||||
|
||||
const action = {
|
||||
type,
|
||||
appId,
|
||||
actionId,
|
||||
triggerId,
|
||||
payload,
|
||||
user,
|
||||
room,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await orch.triggerEvent(AppEvents.IUIKitInteractionHandler, action);
|
||||
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
res.status(500).send({ error });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'actionButton': {
|
||||
const {
|
||||
type,
|
||||
actionId,
|
||||
triggerId,
|
||||
rid,
|
||||
mid,
|
||||
tmid,
|
||||
payload: { context, message: msgText },
|
||||
} = req.body;
|
||||
|
||||
const user = orch.getConverters()?.get('users').convertToApp(req.user);
|
||||
const room = rid ? await orch.getConverters()?.get('rooms').convertById(rid) : undefined;
|
||||
const message = mid ? await orch.getConverters()?.get('messages').convertById(mid) : undefined;
|
||||
|
||||
const action = {
|
||||
type,
|
||||
appId,
|
||||
actionId,
|
||||
triggerId,
|
||||
user,
|
||||
room,
|
||||
message,
|
||||
tmid,
|
||||
payload: {
|
||||
context,
|
||||
...(msgText ? { message: msgText } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await orch.triggerEvent(AppEvents.IUIKitInteractionHandler, action);
|
||||
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
res.status(500).send({ error });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
res.status(400).send({ error: 'Unknown action' });
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: validate payloads per type
|
||||
}
|
||||
}
|
||||
214
apps/meteor/ee/server/apps/communication/websockets.ts
Normal file
214
apps/meteor/ee/server/apps/communication/websockets.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings';
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import { InstanceStatus } from '@rocket.chat/instance-status';
|
||||
|
||||
import { AppEvents } from './events';
|
||||
import { SystemLogger } from '../../../../server/lib/logger/system';
|
||||
import notifications from '../../../../server/lib/notifications/core/lib/Notifications';
|
||||
import type { IStreamer } from '../../../../server/modules/streamer/types';
|
||||
import type { AppServerOrchestrator } from '../orchestrator';
|
||||
|
||||
export { AppEvents };
|
||||
export class AppServerListener {
|
||||
private orch: AppServerOrchestrator;
|
||||
|
||||
engineStreamer: IStreamer<'apps-engine'>;
|
||||
|
||||
clientStreamer: IStreamer<'apps'>;
|
||||
|
||||
received;
|
||||
|
||||
constructor(
|
||||
orch: AppServerOrchestrator,
|
||||
engineStreamer: IStreamer<'apps-engine'>,
|
||||
clientStreamer: IStreamer<'apps'>,
|
||||
received: Map<any, any>,
|
||||
) {
|
||||
this.orch = orch;
|
||||
this.engineStreamer = engineStreamer;
|
||||
this.clientStreamer = clientStreamer;
|
||||
this.received = received;
|
||||
|
||||
this.engineStreamer.on(AppEvents.APP_STATUS_CHANGE, this.onAppStatusUpdated.bind(this));
|
||||
this.engineStreamer.on(AppEvents.APP_REMOVED, this.onAppRemoved.bind(this));
|
||||
this.engineStreamer.on(AppEvents.APP_UPDATED, this.onAppUpdated.bind(this));
|
||||
this.engineStreamer.on(AppEvents.APP_ADDED, this.onAppAdded.bind(this));
|
||||
this.engineStreamer.on(AppEvents.ACTIONS_CHANGED, this.onActionsChanged.bind(this));
|
||||
|
||||
this.engineStreamer.on(AppEvents.APP_SETTING_UPDATED, this.onAppSettingUpdated.bind(this));
|
||||
this.engineStreamer.on(AppEvents.COMMAND_ADDED, this.onCommandAdded.bind(this));
|
||||
this.engineStreamer.on(AppEvents.COMMAND_DISABLED, this.onCommandDisabled.bind(this));
|
||||
this.engineStreamer.on(AppEvents.COMMAND_UPDATED, this.onCommandUpdated.bind(this));
|
||||
this.engineStreamer.on(AppEvents.COMMAND_REMOVED, this.onCommandRemoved.bind(this));
|
||||
}
|
||||
|
||||
async onAppAdded(appId: string): Promise<void> {
|
||||
await (this.orch.getManager()! as any).addLocal(appId); // TO-DO: fix type
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_ADDED, appId);
|
||||
}
|
||||
|
||||
async onAppStatusUpdated({ appId, status }: { appId: string; status: AppStatus }): Promise<void> {
|
||||
const app = this.orch.getManager()?.getOneById(appId);
|
||||
|
||||
if (!app || (await app.getStatus()) === status) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.received.set(`${AppEvents.APP_STATUS_CHANGE}_${appId}`, {
|
||||
appId,
|
||||
status,
|
||||
when: new Date(),
|
||||
});
|
||||
|
||||
if (AppStatusUtils.isEnabled(status)) {
|
||||
await this.orch.getManager()?.enable(appId).catch(SystemLogger.error);
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_STATUS_CHANGE, { appId, status });
|
||||
} else if (AppStatusUtils.isDisabled(status)) {
|
||||
await this.orch.getManager()?.disable(appId, status, true).catch(SystemLogger.error);
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_STATUS_CHANGE, { appId, status });
|
||||
}
|
||||
}
|
||||
|
||||
async onAppSettingUpdated({ appId, setting }: { appId: string; setting: AppsSetting }): Promise<void> {
|
||||
this.received.set(`${AppEvents.APP_SETTING_UPDATED}_${appId}_${setting.id}`, {
|
||||
appId,
|
||||
setting,
|
||||
when: new Date(),
|
||||
});
|
||||
|
||||
await this.orch
|
||||
.getManager()!
|
||||
.getSettingsManager()
|
||||
.updateAppSetting(appId, setting as any); // TO-DO: fix type of `setting`
|
||||
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_SETTING_UPDATED, { appId, setting });
|
||||
}
|
||||
|
||||
async onAppUpdated(appId: string): Promise<void> {
|
||||
this.received.set(`${AppEvents.APP_UPDATED}_${appId}`, { appId, when: new Date() });
|
||||
|
||||
const storageItem = await this.orch.getStorage()!.retrieveOne(appId);
|
||||
|
||||
const appPackage = await this.orch.getAppSourceStorage()!.fetch(storageItem);
|
||||
|
||||
const isEnabled = AppStatusUtils.isEnabled(storageItem.status);
|
||||
if (isEnabled) {
|
||||
await this.orch.getManager()!.updateAndStartupLocal(storageItem, appPackage);
|
||||
} else {
|
||||
await this.orch.getManager()!.updateAndInitializeLocal(storageItem, appPackage);
|
||||
}
|
||||
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_UPDATED, appId);
|
||||
}
|
||||
|
||||
async onAppRemoved(appId: string): Promise<void> {
|
||||
const app = this.orch.getManager()!.getOneById(appId);
|
||||
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.orch.getManager()!.removeLocal(appId);
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.APP_REMOVED, appId);
|
||||
}
|
||||
|
||||
async onCommandAdded(command: string): Promise<void> {
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_ADDED, command);
|
||||
}
|
||||
|
||||
async onCommandDisabled(command: string): Promise<void> {
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_DISABLED, command);
|
||||
}
|
||||
|
||||
async onCommandUpdated(command: string): Promise<void> {
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_UPDATED, command);
|
||||
}
|
||||
|
||||
async onCommandRemoved(command: string): Promise<void> {
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.COMMAND_REMOVED, command);
|
||||
}
|
||||
|
||||
async onActionsChanged(): Promise<void> {
|
||||
this.clientStreamer.emitWithoutBroadcast(AppEvents.ACTIONS_CHANGED);
|
||||
}
|
||||
}
|
||||
|
||||
export class AppServerNotifier {
|
||||
engineStreamer: IStreamer<'apps-engine'>;
|
||||
|
||||
clientStreamer: IStreamer<'apps'>;
|
||||
|
||||
received: Map<any, any>;
|
||||
|
||||
listener: AppServerListener;
|
||||
|
||||
constructor(orch: AppServerOrchestrator) {
|
||||
this.engineStreamer = notifications.streamAppsEngine;
|
||||
|
||||
// This is used to broadcast to the web clients
|
||||
this.clientStreamer = notifications.streamApps;
|
||||
|
||||
this.received = new Map();
|
||||
this.listener = new AppServerListener(orch, this.engineStreamer, this.clientStreamer, this.received);
|
||||
}
|
||||
|
||||
async appAdded(appId: string): Promise<void> {
|
||||
void api.broadcast('apps.added', appId);
|
||||
}
|
||||
|
||||
async appRemoved(appId: string): Promise<void> {
|
||||
void api.broadcast('apps.removed', appId);
|
||||
}
|
||||
|
||||
async appUpdated(appId: string): Promise<void> {
|
||||
if (this.received.has(`${AppEvents.APP_UPDATED}_${appId}`)) {
|
||||
this.received.delete(`${AppEvents.APP_UPDATED}_${appId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
void api.broadcast('apps.updated', appId, InstanceStatus.id());
|
||||
}
|
||||
|
||||
async appStatusUpdated(appId: string, status: AppStatus): Promise<void> {
|
||||
if (this.received.has(`${AppEvents.APP_STATUS_CHANGE}_${appId}`)) {
|
||||
const details = this.received.get(`${AppEvents.APP_STATUS_CHANGE}_${appId}`);
|
||||
if (details.status === status) {
|
||||
this.received.delete(`${AppEvents.APP_STATUS_CHANGE}_${appId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void api.broadcast('apps.statusUpdate', appId, status);
|
||||
}
|
||||
|
||||
async appSettingsChange(appId: string, setting: AppsSetting): Promise<void> {
|
||||
if (this.received.has(`${AppEvents.APP_SETTING_UPDATED}_${appId}_${setting.id}`)) {
|
||||
this.received.delete(`${AppEvents.APP_SETTING_UPDATED}_${appId}_${setting.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
void api.broadcast('apps.settingUpdated', appId, setting);
|
||||
}
|
||||
|
||||
async commandAdded(command: string): Promise<void> {
|
||||
void api.broadcast('command.added', command);
|
||||
}
|
||||
|
||||
async commandDisabled(command: string): Promise<void> {
|
||||
void api.broadcast('command.disabled', command);
|
||||
}
|
||||
|
||||
async commandUpdated(command: string): Promise<void> {
|
||||
void api.broadcast('command.updated', command);
|
||||
}
|
||||
|
||||
async commandRemoved(command: string): Promise<void> {
|
||||
void api.broadcast('command.removed', command);
|
||||
}
|
||||
|
||||
async actionsChanged(): Promise<void> {
|
||||
void api.broadcast('actions.changed');
|
||||
}
|
||||
}
|
||||
116
apps/meteor/ee/server/apps/cron.ts
Normal file
116
apps/meteor/ee/server/apps/cron.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
|
||||
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Settings, Users } from '@rocket.chat/models';
|
||||
|
||||
import { Apps } from './orchestrator';
|
||||
import { getWorkspaceAccessToken } from '../../../server/lib/cloud';
|
||||
import { i18n } from '../../../server/lib/i18n';
|
||||
import { sendMessagesToAdmins } from '../../../server/lib/sendMessagesToAdmins';
|
||||
|
||||
const notifyAdminsAboutInvalidApps = async function _notifyAdminsAboutInvalidApps(apps?: ProxiedApp[]) {
|
||||
if (!apps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasInvalidApps = !!apps.find((app) => app.getLatestLicenseValidationResult().hasErrors);
|
||||
|
||||
if (!hasInvalidApps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = 'someAppInInvalidState';
|
||||
const title = 'Warning';
|
||||
const link = '/marketplace/installed';
|
||||
|
||||
await sendMessagesToAdmins({
|
||||
msgs: async ({ adminUser }) => ({
|
||||
msg: `*${i18n.t(title, { lng: adminUser.language || 'en' })}*\n${i18n.t('Invalid_apps_admin_message', {
|
||||
lng: adminUser.language || 'en',
|
||||
marketplace: i18n.t('Marketplace', { lng: adminUser.language || 'en' }),
|
||||
installed: i18n.t('Installed', { lng: adminUser.language || 'en' }),
|
||||
})}`,
|
||||
}),
|
||||
banners: async ({ adminUser }) => {
|
||||
await Users.removeBannerById(adminUser._id, id);
|
||||
|
||||
return [
|
||||
{
|
||||
id,
|
||||
priority: 10,
|
||||
title,
|
||||
text: i18n.t('Invalid_apps_banner_text', { lng: adminUser.language || 'en' }),
|
||||
modifiers: ['danger'],
|
||||
link,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
const notifyAdminsAboutRenewedApps = async function _notifyAdminsAboutRenewedApps(apps?: ProxiedApp[]) {
|
||||
if (!apps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renewedApps = apps.filter(
|
||||
async (app) => (await app.getStatus()) === AppStatus.DISABLED && app.getPreviousStatus() === AppStatus.INVALID_LICENSE_DISABLED,
|
||||
);
|
||||
|
||||
if (renewedApps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessagesToAdmins({
|
||||
msgs: async ({ adminUser }) => ({
|
||||
msg: i18n.t('Disabled_apps_admin_message', {
|
||||
lng: adminUser.language || 'en',
|
||||
marketplace: i18n.t('Marketplace', { lng: adminUser.language || 'en' }),
|
||||
installed: i18n.t('Installed', { lng: adminUser.language || 'en' }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const appsUpdateMarketplaceInfo = async function _appsUpdateMarketplaceInfo() {
|
||||
if (License.hasOfflineLicense()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getWorkspaceAccessToken();
|
||||
const workspaceIdSetting = await Settings.getValueById('Cloud_Workspace_Id');
|
||||
|
||||
const currentSeats = await Users.getActiveLocalUserCount();
|
||||
|
||||
const fullUrl = `v1/workspaces/${workspaceIdSetting}/apps`;
|
||||
|
||||
let data = [];
|
||||
|
||||
try {
|
||||
const response = await Apps.getMarketplaceClient().fetch(fullUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
params: {
|
||||
seats: currentSeats,
|
||||
},
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
ignoreSsrfValidation: true,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
data = result;
|
||||
}
|
||||
} catch (err) {
|
||||
Apps.debugLog(err);
|
||||
}
|
||||
|
||||
await Apps.updateAppsMarketplaceInfo(data).then(notifyAdminsAboutInvalidApps).then(notifyAdminsAboutRenewedApps);
|
||||
};
|
||||
|
||||
await cronJobs.add('Apps-Engine:check', '0 4 * * *', async () => appsUpdateMarketplaceInfo());
|
||||
4
apps/meteor/ee/server/apps/index.ts
Normal file
4
apps/meteor/ee/server/apps/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import './cron';
|
||||
import './appRequestsCron';
|
||||
|
||||
export { Apps } from './orchestrator';
|
||||
42
apps/meteor/ee/server/apps/lib/redactor.ts
Normal file
42
apps/meteor/ee/server/apps/lib/redactor.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import fastRedact from 'fast-redact';
|
||||
|
||||
const requestFields = [
|
||||
'headers.Cookie',
|
||||
'headers.cookie',
|
||||
'headers["x-auth-token"]',
|
||||
'headers["X-Auth-Token"]',
|
||||
'headers.auth',
|
||||
'headers.Auth',
|
||||
'headers.authorization',
|
||||
'headers.Authorization',
|
||||
'headers.access_token',
|
||||
'content.password',
|
||||
'content.pass',
|
||||
'data.password',
|
||||
'data.pass',
|
||||
];
|
||||
|
||||
const entityFields = ['password', 'pass', 'customFields.*', '_unmappedProperties_'];
|
||||
|
||||
const roomFields = ['customFields.*', '_unmappedProperties_', ...entityFields.map((field) => `creator.${field}`)];
|
||||
|
||||
export const redactionFieldPaths = [
|
||||
// Incoming requests to the Apps API endpoints
|
||||
...requestFields,
|
||||
...entityFields.map((field) => `user.${field}`),
|
||||
'query.access_token',
|
||||
'query.query', // The deprecated `query` search param
|
||||
// Outgoing requests from the Apps to the outter webs
|
||||
...requestFields.map((field) => `request.${field}`),
|
||||
`request.query`, // `query` here is a string, so we have to redact it all
|
||||
// Slashcommands
|
||||
...roomFields.map((field) => `params[0].room.${field}`),
|
||||
...entityFields.map((field) => `params[0].sender.${field}`),
|
||||
];
|
||||
|
||||
export const redact = fastRedact({
|
||||
paths: redactionFieldPaths,
|
||||
censor: '[Redacted]',
|
||||
serialize: false,
|
||||
strict: false,
|
||||
});
|
||||
111
apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts
Normal file
111
apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { type ExtendedFetchOptions, Response, serverFetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
import { isTesting } from './isTesting';
|
||||
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
|
||||
|
||||
export class MarketplaceAPIClient {
|
||||
#fetchStrategy: (input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean) => Promise<Response>;
|
||||
|
||||
#marketplaceUrl: string;
|
||||
|
||||
constructor() {
|
||||
if (typeof process.env.OVERWRITE_INTERNAL_MARKETPLACE_URL === 'string' && process.env.OVERWRITE_INTERNAL_MARKETPLACE_URL !== '') {
|
||||
this.#marketplaceUrl = process.env.OVERWRITE_INTERNAL_MARKETPLACE_URL;
|
||||
} else {
|
||||
this.#marketplaceUrl = 'https://marketplace.rocket.chat';
|
||||
}
|
||||
|
||||
if (isTesting()) {
|
||||
this.#fetchStrategy = mockMarketplaceFetch;
|
||||
} else {
|
||||
this.#fetchStrategy = serverFetch;
|
||||
}
|
||||
}
|
||||
|
||||
public getMarketplaceUrl(): string {
|
||||
return this.#marketplaceUrl;
|
||||
}
|
||||
|
||||
public setStrategy(strategyName: 'default' | 'mock'): void {
|
||||
switch (strategyName) {
|
||||
case 'default':
|
||||
this.#fetchStrategy = serverFetch;
|
||||
break;
|
||||
|
||||
case 'mock':
|
||||
this.#fetchStrategy = mockMarketplaceFetch;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error('Unknown strategy');
|
||||
}
|
||||
}
|
||||
|
||||
public fetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): ReturnType<typeof serverFetch> {
|
||||
if (License.hasOfflineLicense()) {
|
||||
return Promise.reject(
|
||||
new CloudOfflineLicenseError('Marketplace connectivity is disabled by the offline license applied to this workspace'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!input.startsWith('http://') && !input.startsWith('https://')) {
|
||||
input = this.getMarketplaceUrl().concat(!input.startsWith('/') ? '/' : '', input);
|
||||
}
|
||||
|
||||
return this.#fetchStrategy(input, options, allowSelfSignedCerts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide mocked HTTP responses for supported Marketplace API endpoints.
|
||||
*
|
||||
* This allows us to prevent actual calls to Marketplace service
|
||||
* during TEST_MODE (CI, local tests, etc.), i.e., remove our dependency
|
||||
* an external unrelated service
|
||||
*
|
||||
* The response content provided has minimal structure to allow for the program
|
||||
* to not crash by receiving something different from the expected structure
|
||||
*
|
||||
* @param input - The request URL or path used to determine which mock response to return
|
||||
* @returns A `Response` with status 200 and a JSON body corresponding to the requested marketplace endpoint
|
||||
* @throws Error when `input` does not match any supported mock endpoint
|
||||
*/
|
||||
function mockMarketplaceFetch(input: string, _options?: ExtendedFetchOptions, _allowSelfSignedCerts?: boolean): Promise<Response> {
|
||||
let content: string;
|
||||
|
||||
switch (true) {
|
||||
// This is not an exhaustive list of endpoints
|
||||
case input.indexOf('v1/apps') !== -1:
|
||||
case input.indexOf('v1/categories') !== -1:
|
||||
case input.indexOf('v1/bundles') !== -1:
|
||||
content = '[]';
|
||||
break;
|
||||
case input.indexOf('v1/featured-apps') !== -1:
|
||||
content = '{"sections":[]}';
|
||||
break;
|
||||
case input.indexOf('v1/app-request/stats') !== -1:
|
||||
content = '{"data":{"totalSeen":0,"totalUnseen":0}}';
|
||||
break;
|
||||
case input.indexOf('v1/app-request/markAsSeen') !== -1:
|
||||
content = '{"success":false}';
|
||||
break;
|
||||
case input.indexOf('v1/app-request') !== -1:
|
||||
content = '{"data":[],"meta":{"limit":25,"offset":0,"sort":"","filter":"","total":0}}';
|
||||
break;
|
||||
case input.indexOf('v1/workspaces') !== -1:
|
||||
content = '{}';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid marketplace mock request ${input}`);
|
||||
}
|
||||
|
||||
const response = new Response(Buffer.from(content), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
58
apps/meteor/ee/server/apps/marketplace/appInstall.ts
Normal file
58
apps/meteor/ee/server/apps/marketplace/appInstall.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
|
||||
|
||||
import { Info } from '../../../../app/utils/rocketchat.info';
|
||||
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
|
||||
import { settings } from '../../../../server/settings';
|
||||
import { Apps } from '../orchestrator';
|
||||
|
||||
type MarketplaceNotificationType = 'install' | 'update' | 'uninstall';
|
||||
|
||||
/**
|
||||
* Notify the marketplace about an app install, update, or uninstall event.
|
||||
*
|
||||
* Attempts to POST a notification to the marketplace client at `v1/apps/{id}/install` containing the action, app metadata, Rocket.Chat and engine versions, and site URL. If a workspace access token is available it is included in the Authorization header. Any errors encountered while obtaining the token, reading settings, or sending the request are ignored.
|
||||
*
|
||||
* @param action - The marketplace event type: 'install', 'update', or 'uninstall'
|
||||
* @param appInfo - App metadata (including `id`, `name`, `nameSlug`, and `version`) to include in the notification
|
||||
*/
|
||||
export async function notifyMarketplace(action: MarketplaceNotificationType, appInfo: IAppInfo): Promise<void> {
|
||||
const headers: { Authorization?: string } = {};
|
||||
|
||||
try {
|
||||
const token = await getWorkspaceAccessToken();
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
let siteUrl = '';
|
||||
try {
|
||||
siteUrl = settings.get<string>('Site_Url');
|
||||
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
|
||||
const data = {
|
||||
action,
|
||||
appName: appInfo.name,
|
||||
appSlug: appInfo.nameSlug,
|
||||
appVersion: appInfo.version,
|
||||
rocketChatVersion: Info.version,
|
||||
engineVersion: Info.marketplaceApiVersion,
|
||||
siteUrl,
|
||||
};
|
||||
|
||||
const pendingSentUrl = `v1/apps/${appInfo.id}/install`;
|
||||
|
||||
try {
|
||||
await Apps.getMarketplaceClient().fetch(pendingSentUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: data,
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
ignoreSsrfValidation: true,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
}
|
||||
109
apps/meteor/ee/server/apps/marketplace/appRequestNotifyUsers.ts
Normal file
109
apps/meteor/ee/server/apps/marketplace/appRequestNotifyUsers.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { AppRequest, IUser } from '@rocket.chat/core-typings';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
|
||||
import { i18n } from '../../../../server/lib/i18n';
|
||||
import { sendDirectMessageToUsers } from '../../../../server/lib/sendDirectMessageToUsers';
|
||||
|
||||
const ROCKET_CAT_USERID = 'rocket.cat';
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
const notifyBatchOfUsersError = (error: Error) => {
|
||||
return new Error(`could not notify the batch of users. Error ${error}`);
|
||||
};
|
||||
|
||||
const notifyBatchOfUsers = async (appName: string, learnMoreUrl: string, appRequests: AppRequest[]): Promise<string[]> => {
|
||||
const batchRequesters = appRequests.reduce((acc: string[], appRequest: AppRequest) => {
|
||||
// Prevent duplicate requesters
|
||||
if (!acc.includes(appRequest.requester.id)) {
|
||||
acc.push(appRequest.requester.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const msgFn = (user: Pick<IUser, 'language'>): string => {
|
||||
const defaultLang = user.language || 'en';
|
||||
const msg = `${i18n.t('App_request_enduser_message', { appName, learnmore: learnMoreUrl, lng: defaultLang })}`;
|
||||
|
||||
return msg;
|
||||
};
|
||||
|
||||
try {
|
||||
return await sendDirectMessageToUsers(ROCKET_CAT_USERID, batchRequesters, msgFn);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const appRequestNotififyForUsers = async (
|
||||
marketplaceBaseUrl: string,
|
||||
workspaceUrl: string,
|
||||
appId: string,
|
||||
appName: string,
|
||||
): Promise<(string | Error)[]> => {
|
||||
try {
|
||||
const token = await getWorkspaceAccessToken();
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
// First request
|
||||
const pagination = { limit: DEFAULT_LIMIT, offset: 0 };
|
||||
|
||||
// First request to get the total and the first batch
|
||||
const response = await fetch(`${marketplaceBaseUrl}/v1/app-request`, {
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
ignoreSsrfValidation: true,
|
||||
headers,
|
||||
params: {
|
||||
appId,
|
||||
q: 'notification-not-sent',
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
},
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { meta: { total: number }; data: any };
|
||||
|
||||
const { total } = data.meta;
|
||||
|
||||
if (total === undefined || total === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate the number of loops - 1 because the first request was already made
|
||||
const loops = Math.ceil(total / pagination.limit) - 1;
|
||||
const requestsCollection = [];
|
||||
const learnMore = `${workspaceUrl}marketplace/explore/info/${appId}`;
|
||||
|
||||
// Notify first batch
|
||||
requestsCollection.push(notifyBatchOfUsers(appName, learnMore, data.data).catch(notifyBatchOfUsersError));
|
||||
|
||||
// Batch requests
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (const _i of Array.from({ length: loops })) {
|
||||
pagination.offset += pagination.limit;
|
||||
|
||||
// SECURITY: the URL is a default hardcoded value or an envvar/setting set by an admin. It's safe to disable this check.
|
||||
const request = await fetch(
|
||||
`${marketplaceBaseUrl}/v1/app-request?appId=${appId}&q=notification-not-sent&limit=${pagination.limit}&offset=${pagination.offset}`,
|
||||
{
|
||||
ignoreSsrfValidation: true,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
const { data } = await request.json();
|
||||
|
||||
requestsCollection.push(notifyBatchOfUsers(appName, learnMore, data));
|
||||
}
|
||||
|
||||
const finalResult = await Promise.all(requestsCollection);
|
||||
|
||||
// Return the list of users that were notified
|
||||
return finalResult.flat();
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
196
apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts
Normal file
196
apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { App } from '@rocket.chat/core-typings';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getMarketplaceHeaders } from './getMarketplaceHeaders';
|
||||
import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors';
|
||||
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
|
||||
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
|
||||
import { settings } from '../../../../server/settings';
|
||||
import { Apps } from '../orchestrator';
|
||||
|
||||
type FetchMarketplaceAppsParams = {
|
||||
endUserID?: string;
|
||||
};
|
||||
|
||||
const markdownObject = z.object({
|
||||
raw: z.string().optional(),
|
||||
rendered: z.string().optional(),
|
||||
});
|
||||
|
||||
const fetchMarketplaceAppsSchema = z.array(
|
||||
z.object({
|
||||
appId: z.string(),
|
||||
latest: z.object({
|
||||
internalId: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
nameSlug: z.string(),
|
||||
version: z.string(),
|
||||
categories: z.array(z.string()),
|
||||
languages: z.array(z.string()).optional(),
|
||||
shortDescription: z.string().optional(),
|
||||
description: z.string(),
|
||||
privacyPolicySummary: z.string().optional(),
|
||||
documentationUrl: z.string().optional(),
|
||||
detailedDescription: markdownObject,
|
||||
detailedChangelog: markdownObject,
|
||||
requiredApiVersion: z.string(),
|
||||
versionIncompatible: z.boolean().optional(),
|
||||
permissions: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
domains: z.array(z.string()).optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
addon: z.string().optional(),
|
||||
author: z.object({
|
||||
name: z.string(),
|
||||
support: z.string(),
|
||||
homepage: z.string(),
|
||||
}),
|
||||
classFile: z.string(),
|
||||
iconFile: z.string(),
|
||||
iconFileData: z.string(),
|
||||
status: z.enum(['submitted', 'author-rejected', 'author-approved', 'rejected', 'approved', 'published']),
|
||||
reviewedNote: z.string().optional(),
|
||||
rejectionNote: z.string().optional(),
|
||||
changesNote: z.string().optional(),
|
||||
internalChangesNote: z.string().optional(),
|
||||
isVisible: z.boolean(),
|
||||
createdDate: z.string(),
|
||||
modifiedDate: z.string(),
|
||||
}),
|
||||
isAddon: z.boolean(),
|
||||
addonId: z.string().optional(),
|
||||
isEnterpriseOnly: z.boolean(),
|
||||
isBundle: z.boolean(),
|
||||
bundledAppsIn: z.array(z.string()).optional(),
|
||||
bundledIn: z.array(
|
||||
z.object({
|
||||
bundleId: z.string(),
|
||||
bundleName: z.string(),
|
||||
addonTierId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
isPurchased: z.boolean(),
|
||||
isSubscribed: z.boolean(),
|
||||
subscriptionInfo: z
|
||||
.object({
|
||||
typeOf: z.enum(['app', 'service', '']),
|
||||
status: z.enum(['trialing', 'active', 'cancelled', 'cancelling', 'pastDue', '']),
|
||||
statusFromBilling: z.boolean(),
|
||||
isSeatBased: z.boolean(),
|
||||
seats: z.number(),
|
||||
maxSeats: z.number(),
|
||||
license: z.object({
|
||||
license: z.string(),
|
||||
version: z.number(),
|
||||
expireDate: z.string(),
|
||||
}),
|
||||
startDate: z.string(),
|
||||
periodEnd: z.string(),
|
||||
endDate: z.string().optional(),
|
||||
externallyManaged: z.boolean(),
|
||||
isSubscribedViaBundle: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
price: z.number(),
|
||||
purchaseType: z.enum(['', 'buy', 'subscription']),
|
||||
pricingPlans: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
enabled: z.boolean(),
|
||||
price: z.number(),
|
||||
trialDays: z.number(),
|
||||
strategy: z.enum(['once', 'monthly', 'yearly']),
|
||||
isPerSeat: z.boolean(),
|
||||
tiers: z
|
||||
.array(
|
||||
z.object({
|
||||
perUnit: z.boolean(),
|
||||
minimum: z.number(),
|
||||
maximum: z.number(),
|
||||
price: z.number(),
|
||||
refId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
isUsageBased: z.boolean().optional(),
|
||||
requestedEndUser: z.boolean().optional(),
|
||||
requested: z.boolean().optional(),
|
||||
appRequestStats: z.object({}).optional(),
|
||||
createdAt: z.string(),
|
||||
modifiedAt: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches marketplace apps available to the workspace.
|
||||
*
|
||||
* @param endUserID - Optional end-user identifier used to filter apps returned by the marketplace.
|
||||
* @returns An array of marketplace `App` objects describing available apps and their latest release metadata.
|
||||
* @throws MarketplaceConnectionError when the marketplace cannot be reached.
|
||||
* @throws MarketplaceUnsupportedVersionError when the marketplace reports an unsupported client version.
|
||||
* @throws MarketplaceAppsError for marketplace-side errors, including invalid apps engine version, internal marketplace errors, or a generic failure to fetch apps.
|
||||
*/
|
||||
export async function fetchMarketplaceApps({ endUserID }: FetchMarketplaceAppsParams = {}): Promise<App[]> {
|
||||
const headers = getMarketplaceHeaders();
|
||||
const token = await getWorkspaceAccessToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = await Apps.getMarketplaceClient().fetch(`v1/apps`, {
|
||||
headers,
|
||||
ignoreSsrfValidation: false,
|
||||
allowList: settings.get<string>('SSRF_Allowlist'),
|
||||
params: {
|
||||
...(endUserID && { endUserID }),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Offline (air-gapped) licenses reject before any request is made; keep the
|
||||
// typed error so the REST layer can report the real reason instead of a
|
||||
// generic connectivity failure.
|
||||
if (error instanceof CloudOfflineLicenseError) {
|
||||
throw error;
|
||||
}
|
||||
throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection');
|
||||
}
|
||||
|
||||
if (request.status === 200) {
|
||||
const response = await request.json();
|
||||
fetchMarketplaceAppsSchema.parse(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const response = await request.json();
|
||||
|
||||
Apps.getRocketChatLogger().error({ msg: 'Error fetching marketplace apps', status: request.status, response });
|
||||
|
||||
// TODO: Refactor cloud to return a proper error code on unsupported version
|
||||
if (request.status === 426 && 'errorMsg' in response && response.errorMsg === 'unsupported version') {
|
||||
throw new MarketplaceUnsupportedVersionError();
|
||||
}
|
||||
|
||||
if (request.status === 400 && response.code === 200) {
|
||||
throw new MarketplaceAppsError('Marketplace_Invalid_Apps_Engine_Version');
|
||||
}
|
||||
|
||||
const INTERNAL_MARKETPLACE_ERROR_CODES = [266, 256, 166, 221, 257, 320];
|
||||
|
||||
if (request.status === 500 && INTERNAL_MARKETPLACE_ERROR_CODES.includes(response.code)) {
|
||||
throw new MarketplaceAppsError('Marketplace_Internal_Error');
|
||||
}
|
||||
|
||||
throw new MarketplaceAppsError('Marketplace_Failed_To_Fetch_Apps');
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AppCategory } from '@rocket.chat/core-typings';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getMarketplaceHeaders } from './getMarketplaceHeaders';
|
||||
import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors';
|
||||
import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError';
|
||||
import { getWorkspaceAccessToken } from '../../../../server/lib/cloud';
|
||||
import { settings } from '../../../../server/settings';
|
||||
import { Apps } from '../orchestrator';
|
||||
|
||||
const fetchMarketplaceCategoriesSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
hidden: z.boolean(),
|
||||
createdDate: z.string(),
|
||||
modifiedDate: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches marketplace categories from the marketplace API.
|
||||
*
|
||||
* @returns An array of marketplace categories (`AppCategory[]`).
|
||||
* @throws MarketplaceConnectionError when the HTTP request cannot be made.
|
||||
* @throws MarketplaceUnsupportedVersionError when the marketplace responds with status 426 and `errorMsg` equals `"unsupported version"`.
|
||||
* @throws MarketplaceAppsError when the marketplace returns an internal error (specific internal codes) or any other non-successful response.
|
||||
*/
|
||||
export async function fetchMarketplaceCategories(): Promise<AppCategory[]> {
|
||||
const headers = getMarketplaceHeaders();
|
||||
const token = await getWorkspaceAccessToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = await Apps.getMarketplaceClient().fetch(`v1/categories`, {
|
||||
headers,
|
||||
ignoreSsrfValidation: false,
|
||||
allowList: settings.get<string>('SSRF_Allowlist'),
|
||||
});
|
||||
} catch (error) {
|
||||
// Offline (air-gapped) licenses reject before any request is made; keep the
|
||||
// typed error so the REST layer can report the real reason instead of a
|
||||
// generic connectivity failure.
|
||||
if (error instanceof CloudOfflineLicenseError) {
|
||||
throw error;
|
||||
}
|
||||
throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection');
|
||||
}
|
||||
|
||||
if (request.status === 200) {
|
||||
const response = await request.json();
|
||||
fetchMarketplaceCategoriesSchema.parse(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const response = await request.json();
|
||||
|
||||
Apps.getRocketChatLogger().error({ msg: 'Error fetching marketplace categories', status: request.status, response });
|
||||
|
||||
// TODO: Refactor cloud to return a proper error code on unsupported version
|
||||
if (request.status === 426 && 'errorMsg' in response && response.errorMsg === 'unsupported version') {
|
||||
throw new MarketplaceUnsupportedVersionError();
|
||||
}
|
||||
|
||||
const INTERNAL_MARKETPLACE_ERROR_CODES = [189, 266];
|
||||
|
||||
if (request.status === 500 && INTERNAL_MARKETPLACE_ERROR_CODES.includes(response.code)) {
|
||||
throw new MarketplaceAppsError('Marketplace_Internal_Error');
|
||||
}
|
||||
|
||||
throw new MarketplaceAppsError('Marketplace_Failed_To_Fetch_Categories');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Info } from '../../../../app/utils/rocketchat.info';
|
||||
|
||||
export function getMarketplaceHeaders(): Record<string, any> {
|
||||
return {
|
||||
'X-Apps-Engine-Version': Info.marketplaceApiVersion.replace(/-.*/g, ''),
|
||||
};
|
||||
}
|
||||
8
apps/meteor/ee/server/apps/marketplace/isTesting.ts
Normal file
8
apps/meteor/ee/server/apps/marketplace/isTesting.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Determine whether the application is running in testing mode.
|
||||
*
|
||||
* @returns `true` if the `TEST_MODE` environment variable equals `'true'`, `false` otherwise.
|
||||
*/
|
||||
export function isTesting() {
|
||||
return process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api';
|
||||
}
|
||||
17
apps/meteor/ee/server/apps/marketplace/marketplaceErrors.ts
Normal file
17
apps/meteor/ee/server/apps/marketplace/marketplaceErrors.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class MarketplaceAppsError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class MarketplaceConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class MarketplaceUnsupportedVersionError extends Error {
|
||||
constructor() {
|
||||
super('Marketplace_Unsupported_Version');
|
||||
}
|
||||
}
|
||||
448
apps/meteor/ee/server/apps/orchestrator.ts
Normal file
448
apps/meteor/ee/server/apps/orchestrator.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { AppBridges, AppEvents, IAppConvertersMap, IAppServerNotifier, IAppServerOrchestrator } from '@rocket.chat/apps';
|
||||
import { registerOrchestrator } from '@rocket.chat/apps';
|
||||
import { AppManager } from '@rocket.chat/apps/dist/server/AppManager';
|
||||
import type { IGetAppsFilter } from '@rocket.chat/apps/dist/server/IGetAppsFilter';
|
||||
import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp';
|
||||
import type { IMarketplaceInfo } from '@rocket.chat/apps/dist/server/marketplace/IMarketplaceInfo';
|
||||
import { AppInstallationSource } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
import { EssentialAppDisabledException } from '@rocket.chat/apps-engine/definition/exceptions';
|
||||
import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { AppLogs, Apps as AppsModel, AppsPersistence, Statistics } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { AppServerNotifier, AppsRestApi, AppUIKitInteractionApi } from './communication';
|
||||
import { redactionFieldPaths } from './lib/redactor';
|
||||
import { MarketplaceAPIClient } from './marketplace/MarketplaceAPIClient';
|
||||
import { isTesting } from './marketplace/isTesting';
|
||||
import { AppRealLogStorage, AppRealStorage, ConfigurableAppSourceStorage } from './storage';
|
||||
import { RealAppBridges } from '../../../app/apps/server/bridges';
|
||||
import type { HandleEvent } from '../../../app/apps/server/bridges/listeners';
|
||||
import {
|
||||
AppMessagesConverter,
|
||||
AppRoomsConverter,
|
||||
AppSettingsConverter,
|
||||
AppUsersConverter,
|
||||
AppVideoConferencesConverter,
|
||||
AppDepartmentsConverter,
|
||||
AppUploadsConverter,
|
||||
AppVisitorsConverter,
|
||||
AppRolesConverter,
|
||||
AppContactsConverter,
|
||||
} from '../../../app/apps/server/converters';
|
||||
import { AppThreadsConverter } from '../../../app/apps/server/converters/threads';
|
||||
import { settings } from '../../../server/settings';
|
||||
import { canEnableApp } from '../lib/license/canEnableApp';
|
||||
|
||||
const DISABLED_PRIVATE_APP_INSTALLATION = ['yes', 'true'].includes(String(process.env.DISABLE_PRIVATE_APP_INSTALLATION).toLowerCase());
|
||||
|
||||
type AppCommunicators = {
|
||||
notifier: AppServerNotifier;
|
||||
restapi: AppsRestApi;
|
||||
uikit: AppUIKitInteractionApi;
|
||||
};
|
||||
|
||||
interface IAppCommunicatorsMap extends Map<keyof AppCommunicators, AppCommunicators[keyof AppCommunicators]> {
|
||||
get<T extends keyof AppCommunicators>(key: T): AppCommunicators[T];
|
||||
}
|
||||
|
||||
export class AppServerOrchestrator implements IAppServerOrchestrator {
|
||||
private _isInitialized: boolean;
|
||||
|
||||
private readonly marketplaceClient: MarketplaceAPIClient;
|
||||
|
||||
private _rocketchatLogger: Logger;
|
||||
|
||||
private _model: typeof AppsModel;
|
||||
|
||||
private _logModel: typeof AppLogs;
|
||||
|
||||
private _persistModel: typeof AppsPersistence;
|
||||
|
||||
private _statisticsModel: typeof Statistics;
|
||||
|
||||
private _storage: AppRealStorage;
|
||||
|
||||
private _logStorage: AppRealLogStorage;
|
||||
|
||||
private _appSourceStorage: ConfigurableAppSourceStorage;
|
||||
|
||||
private _converters: IAppConvertersMap;
|
||||
|
||||
private _bridges: RealAppBridges;
|
||||
|
||||
private _manager: AppManager;
|
||||
|
||||
private _communicators: IAppCommunicatorsMap;
|
||||
|
||||
constructor() {
|
||||
this._isInitialized = false;
|
||||
|
||||
this.marketplaceClient = new MarketplaceAPIClient();
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
if (this._isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._rocketchatLogger = new Logger('Rocket.Chat Apps', { redact: redactionFieldPaths });
|
||||
|
||||
this._model = AppsModel;
|
||||
this._logModel = AppLogs;
|
||||
this._persistModel = AppsPersistence;
|
||||
this._statisticsModel = Statistics;
|
||||
this._storage = new AppRealStorage(this._model);
|
||||
this._logStorage = new AppRealLogStorage(this._logModel);
|
||||
this._appSourceStorage = new ConfigurableAppSourceStorage(
|
||||
settings.get<string>('Apps_Framework_Source_Package_Storage_Type'),
|
||||
settings.get<string>('Apps_Framework_Source_Package_Storage_FileSystem_Path'),
|
||||
);
|
||||
|
||||
this._converters = new Map() as IAppConvertersMap;
|
||||
this._converters.set('messages', new AppMessagesConverter(this));
|
||||
this._converters.set('rooms', new AppRoomsConverter(this));
|
||||
this._converters.set('settings', new AppSettingsConverter(this));
|
||||
this._converters.set('users', new AppUsersConverter(this));
|
||||
this._converters.set('visitors', new AppVisitorsConverter(this));
|
||||
this._converters.set('contacts', new AppContactsConverter());
|
||||
this._converters.set('departments', new AppDepartmentsConverter(this));
|
||||
this._converters.set('uploads', new AppUploadsConverter(this));
|
||||
this._converters.set('videoConferences', new AppVideoConferencesConverter());
|
||||
this._converters.set('threads', new AppThreadsConverter(this));
|
||||
this._converters.set('roles', new AppRolesConverter());
|
||||
|
||||
this._bridges = new RealAppBridges(this);
|
||||
|
||||
const tempFilePath = path.join(os.tmpdir(), 'apps-engine-temp');
|
||||
|
||||
try {
|
||||
// We call this only once at server startup, so using the synchronous version is fine
|
||||
fs.mkdirSync(tempFilePath);
|
||||
} catch (err) {
|
||||
// If the temp directory already exists, we can continue
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw new Error('Failed to initialize the Apps-Engine', { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
this._manager = new AppManager({
|
||||
metadataStorage: this._storage,
|
||||
logStorage: this._logStorage,
|
||||
bridges: this.getBridges(),
|
||||
sourceStorage: this._appSourceStorage,
|
||||
tempFilePath,
|
||||
});
|
||||
|
||||
this._communicators = new Map() as IAppCommunicatorsMap;
|
||||
this._communicators.set('notifier', new AppServerNotifier(this));
|
||||
this._communicators.set('restapi', new AppsRestApi(this, this._manager));
|
||||
this._communicators.set('uikit', new AppUIKitInteractionApi(this));
|
||||
|
||||
this._isInitialized = true;
|
||||
}
|
||||
|
||||
getMarketplaceClient(): MarketplaceAPIClient {
|
||||
return this.marketplaceClient;
|
||||
}
|
||||
|
||||
getModel(): typeof AppsModel {
|
||||
return this._model;
|
||||
}
|
||||
|
||||
getPersistenceModel(): typeof AppsPersistence {
|
||||
return this._persistModel;
|
||||
}
|
||||
|
||||
getStatisticsModel(): typeof Statistics {
|
||||
return this._statisticsModel;
|
||||
}
|
||||
|
||||
getStorage(): AppRealStorage {
|
||||
return this._storage;
|
||||
}
|
||||
|
||||
getLogStorage(): AppRealLogStorage {
|
||||
if (!this._logStorage) {
|
||||
throw new Error('Apps-Engine not yet fully initialized');
|
||||
}
|
||||
|
||||
return this._logStorage;
|
||||
}
|
||||
|
||||
getConverters(): IAppConvertersMap {
|
||||
return this._converters;
|
||||
}
|
||||
|
||||
getBridges(): AppBridges {
|
||||
// `RealAppBridges` does extend `AppBridges`, but its listener bridge takes a single event object
|
||||
// instead of the positional arguments `IListenerBridge` declares, so the structural check on the
|
||||
// bridge collection has to be bypassed. Internal callers use `this._bridges` to keep the real types.
|
||||
return this._bridges as unknown as AppBridges;
|
||||
}
|
||||
|
||||
getNotifier(): IAppServerNotifier {
|
||||
return this._communicators.get('notifier');
|
||||
}
|
||||
|
||||
getManager(): AppManager {
|
||||
return this._manager;
|
||||
}
|
||||
|
||||
getProvidedComponents(): IExternalComponent[] {
|
||||
return this._manager.getExternalComponentManager().getProvidedComponents();
|
||||
}
|
||||
|
||||
getAppSourceStorage(): ConfigurableAppSourceStorage {
|
||||
return this._appSourceStorage;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this._isInitialized;
|
||||
}
|
||||
|
||||
isLoaded(): boolean {
|
||||
return this.getManager().areAppsLoaded();
|
||||
}
|
||||
|
||||
isDebugging(): boolean {
|
||||
return !isTesting();
|
||||
}
|
||||
|
||||
shouldDisablePrivateAppInstallation(): boolean {
|
||||
return DISABLED_PRIVATE_APP_INSTALLATION;
|
||||
}
|
||||
|
||||
getRocketChatLogger(): Logger {
|
||||
return this._rocketchatLogger;
|
||||
}
|
||||
|
||||
debugLog(...args: any[]): void {
|
||||
if (this.isDebugging()) {
|
||||
// The logger only declares a single message parameter, but callers have always relied on
|
||||
// the extra arguments being forwarded to the underlying pino instance.
|
||||
this.getRocketChatLogger().debug(...(args as [object | string]));
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
// Don't try to load it again if it has
|
||||
// already been loaded
|
||||
if (this.isLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.getManager().load();
|
||||
|
||||
// Before enabling each app we verify if there is still room for it
|
||||
const apps = await this.getManager().get();
|
||||
|
||||
// This needs to happen sequentially to keep track of app limits
|
||||
for (const app of apps) {
|
||||
try {
|
||||
await canEnableApp(app.getStorageItem());
|
||||
|
||||
await this.getManager().loadOne(app.getID(), true);
|
||||
} catch (error) {
|
||||
this._rocketchatLogger.warn({
|
||||
msg: 'App could not be enabled',
|
||||
appName: app.getInfo().name,
|
||||
err: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this._bridges.getSchedulerBridge().startScheduler();
|
||||
|
||||
const appCount = (await this.getManager().get({ enabled: true })).length;
|
||||
|
||||
this._rocketchatLogger.info({
|
||||
msg: 'Loaded the Apps Framework and apps',
|
||||
appCount,
|
||||
});
|
||||
}
|
||||
|
||||
async migratePrivateApps(): Promise<void> {
|
||||
const apps = await this.getManager().get({ installationSource: AppInstallationSource.PRIVATE });
|
||||
|
||||
await Promise.all(apps.map((app) => this.getManager().migrate(app.getID())));
|
||||
await Promise.all(apps.map((app) => this.getNotifier().appUpdated(app.getID())));
|
||||
}
|
||||
|
||||
async findMajorVersionUpgradeDate(targetVersion = 7): Promise<Date | null> {
|
||||
let upgradeToV7Date: Date | null = null;
|
||||
let hadPreTargetVersion = false;
|
||||
|
||||
try {
|
||||
const statistics = await this.getStatisticsModel().findInstallationDates();
|
||||
if (!statistics || statistics.length === 0) {
|
||||
this._rocketchatLogger.info('No statistics found');
|
||||
return upgradeToV7Date;
|
||||
}
|
||||
|
||||
const statsAscendingByInstallDate = statistics.sort(
|
||||
(a, b) => new Date(a.installedAt ?? NaN).getTime() - new Date(b.installedAt ?? NaN).getTime(),
|
||||
);
|
||||
for (const stat of statsAscendingByInstallDate) {
|
||||
const version = stat.version || '';
|
||||
|
||||
if (!version) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const majorVersion = parseInt(version.split('.')[0], 10);
|
||||
if (isNaN(majorVersion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (majorVersion < targetVersion) {
|
||||
hadPreTargetVersion = true;
|
||||
}
|
||||
|
||||
if (hadPreTargetVersion && majorVersion >= targetVersion) {
|
||||
upgradeToV7Date = new Date(stat.installedAt ?? NaN);
|
||||
this._rocketchatLogger.info({
|
||||
msg: 'Found upgrade to target version date',
|
||||
targetVersion,
|
||||
upgradeToDate: upgradeToV7Date.toISOString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this._rocketchatLogger.error({
|
||||
msg: 'Error checking statistics for version history',
|
||||
err,
|
||||
});
|
||||
}
|
||||
|
||||
return upgradeToV7Date;
|
||||
}
|
||||
|
||||
async disableMarketplaceApps(): Promise<void> {
|
||||
return this.disableApps(AppInstallationSource.MARKETPLACE, false, 5);
|
||||
}
|
||||
|
||||
async disablePrivateApps(): Promise<void> {
|
||||
return this.disableApps(AppInstallationSource.PRIVATE, true, 0);
|
||||
}
|
||||
|
||||
async disableApps(installationSource: AppInstallationSource, grandfatherApps: boolean, maxApps: number): Promise<void> {
|
||||
const upgradeToV7Date = await this.findMajorVersionUpgradeDate();
|
||||
const apps = await this.getManager().get({ installationSource });
|
||||
|
||||
const grandfathered: ProxiedApp[] = [];
|
||||
const toKeep: ProxiedApp[] = [];
|
||||
const toDisable: ProxiedApp[] = [];
|
||||
|
||||
for (const app of apps) {
|
||||
const storageItem = app.getStorageItem();
|
||||
const isEnabled = ['enabled', 'manually_enabled', 'auto_enabled'].includes(storageItem.status);
|
||||
const marketplaceInfo = storageItem.marketplaceInfo?.[0];
|
||||
|
||||
const wasInstalledBeforeV7 = upgradeToV7Date && storageItem.createdAt && new Date(storageItem.createdAt) < upgradeToV7Date;
|
||||
|
||||
if (wasInstalledBeforeV7 && isEnabled && grandfatherApps) {
|
||||
grandfathered.push(app);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (marketplaceInfo?.isEnterpriseOnly === true && installationSource === AppInstallationSource.MARKETPLACE) {
|
||||
toDisable.push(app);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEnabled) {
|
||||
toKeep.push(app);
|
||||
}
|
||||
}
|
||||
|
||||
toKeep.sort((a, b) => new Date(a.getStorageItem().createdAt || 0).getTime() - new Date(b.getStorageItem().createdAt || 0).getTime());
|
||||
|
||||
if (toKeep.length > maxApps) {
|
||||
toDisable.push(...toKeep.splice(maxApps));
|
||||
}
|
||||
|
||||
if (toDisable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const disablePromises = toDisable.map((app) => {
|
||||
const appId = app.getID();
|
||||
return this.getManager().disable(appId);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(disablePromises);
|
||||
this._rocketchatLogger.info({
|
||||
msg: 'Apps processing complete',
|
||||
installationSource,
|
||||
keptCount: grandfathered.length + toKeep.length,
|
||||
disabledCount: toDisable.length,
|
||||
});
|
||||
} catch (error) {
|
||||
this._rocketchatLogger.error({
|
||||
msg: 'Error disabling apps',
|
||||
err: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async unload(): Promise<void> {
|
||||
// Don't try to unload it if it's already been
|
||||
// unlaoded or wasn't unloaded to start with
|
||||
if (!this.isLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this._manager
|
||||
.unload(false)
|
||||
.then(() => this._rocketchatLogger.info('Unloaded the Apps Framework.'))
|
||||
.catch((err) =>
|
||||
this._rocketchatLogger.error({
|
||||
msg: 'Failed to unload the Apps Framework!',
|
||||
err,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateAppsMarketplaceInfo(apps: Array<{ latest: IMarketplaceInfo }> = []): Promise<ProxiedApp[] | undefined> {
|
||||
if (!this.isLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this._manager.updateAppsMarketplaceInfo(apps).then(() => this._manager.get());
|
||||
}
|
||||
|
||||
async installedApps(filter: IGetAppsFilter = {}): Promise<ProxiedApp[] | undefined> {
|
||||
if (!this.isLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this._manager.get(filter);
|
||||
}
|
||||
|
||||
async triggerEvent(event: AppEvents, ...payload: any[]): Promise<any> {
|
||||
if (!this.isLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this._bridges
|
||||
.getListenerBridge()
|
||||
.handleEvent({ event, payload } as HandleEvent)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof EssentialAppDisabledException) {
|
||||
throw new Meteor.Error('error-essential-app-disabled');
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const Apps = new AppServerOrchestrator();
|
||||
registerOrchestrator(Apps);
|
||||
83
apps/meteor/ee/server/apps/startup.ts
Normal file
83
apps/meteor/ee/server/apps/startup.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
import { Apps } from './orchestrator';
|
||||
import { settings, settingsRegistry } from '../../../server/settings';
|
||||
import { disableAppsWithAddonsCallback } from '../lib/apps/disableAppsWithAddonsCallback';
|
||||
|
||||
export const startupApp = async function startupApp() {
|
||||
await settingsRegistry.addGroup('General', async function () {
|
||||
await this.section('Apps', async function () {
|
||||
await this.add('Apps_Logs_TTL', '30_days', {
|
||||
type: 'select',
|
||||
values: [
|
||||
{
|
||||
key: '7_days',
|
||||
i18nLabel: 'Apps_Logs_TTL_7days',
|
||||
},
|
||||
{
|
||||
key: '14_days',
|
||||
i18nLabel: 'Apps_Logs_TTL_14days',
|
||||
},
|
||||
{
|
||||
key: '30_days',
|
||||
i18nLabel: 'Apps_Logs_TTL_30days',
|
||||
},
|
||||
],
|
||||
public: true,
|
||||
hidden: true,
|
||||
alert: 'Apps_Logs_TTL_Alert',
|
||||
});
|
||||
|
||||
await this.add('Apps_Framework_Source_Package_Storage_Type', 'gridfs', {
|
||||
type: 'select',
|
||||
values: [
|
||||
{
|
||||
key: 'gridfs',
|
||||
i18nLabel: 'GridFS',
|
||||
},
|
||||
{
|
||||
key: 'filesystem',
|
||||
i18nLabel: 'FileSystem',
|
||||
},
|
||||
],
|
||||
public: true,
|
||||
hidden: false,
|
||||
alert: 'Apps_Framework_Source_Package_Storage_Type_Alert',
|
||||
});
|
||||
|
||||
await this.add('Apps_Framework_Source_Package_Storage_FileSystem_Path', '', {
|
||||
type: 'string',
|
||||
public: true,
|
||||
enableQuery: {
|
||||
_id: 'Apps_Framework_Source_Package_Storage_Type',
|
||||
value: 'filesystem',
|
||||
},
|
||||
alert: 'Apps_Framework_Source_Package_Storage_FileSystem_Alert',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function migratePrivateAppsCallback() {
|
||||
void Apps.migratePrivateApps();
|
||||
void Apps.disablePrivateApps();
|
||||
void Apps.disableMarketplaceApps();
|
||||
}
|
||||
|
||||
License.onInvalidateLicense(migratePrivateAppsCallback);
|
||||
License.onRemoveLicense(migratePrivateAppsCallback);
|
||||
|
||||
// Disable apps that depend on add-ons (external modules) if they are invalidated
|
||||
License.onModule(disableAppsWithAddonsCallback);
|
||||
|
||||
Apps.initialize();
|
||||
|
||||
void Apps.load();
|
||||
|
||||
settings.change<'filesystem' | 'gridfs'>('Apps_Framework_Source_Package_Storage_Type', (value) =>
|
||||
Apps.getAppSourceStorage()?.setStorage(value),
|
||||
);
|
||||
|
||||
settings.change<string>('Apps_Framework_Source_Package_Storage_FileSystem_Path', (value) =>
|
||||
Apps.getAppSourceStorage()?.setFileSystemStoragePath(value),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
|
||||
import { AppSourceStorage } from '@rocket.chat/apps/dist/server/storage/AppSourceStorage';
|
||||
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
|
||||
export class AppFileSystemSourceStorage extends AppSourceStorage {
|
||||
private pathPrefix = 'fs:/';
|
||||
|
||||
private path: string;
|
||||
|
||||
public setPath(path: string): void {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public checkPath(): void {
|
||||
if (!this.path) {
|
||||
throw new Error('Invalid path configured for file system App storage');
|
||||
}
|
||||
}
|
||||
|
||||
public async store(item: IAppStorageItem, zip: Buffer): Promise<string> {
|
||||
this.checkPath();
|
||||
|
||||
const filePath = this.itemToFilename(item);
|
||||
|
||||
await fs.writeFile(filePath, zip);
|
||||
|
||||
return this.filenameToSourcePath(filePath);
|
||||
}
|
||||
|
||||
public async fetch(item: IAppStorageItem): Promise<Buffer> {
|
||||
if (!item.sourcePath) {
|
||||
throw new Error('Invalid source path');
|
||||
}
|
||||
|
||||
return fs.readFile(this.sourcePathToFilename(item.sourcePath));
|
||||
}
|
||||
|
||||
public async update(item: IAppStorageItem, zip: Buffer): Promise<string> {
|
||||
this.checkPath();
|
||||
|
||||
const filePath = this.itemToFilename(item);
|
||||
|
||||
await fs.writeFile(filePath, zip);
|
||||
|
||||
return this.filenameToSourcePath(filePath);
|
||||
}
|
||||
|
||||
public async remove(item: IAppStorageItem): Promise<void> {
|
||||
if (!item.sourcePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
return fs.unlink(this.sourcePathToFilename(item.sourcePath));
|
||||
}
|
||||
|
||||
private itemToFilename(item: IAppStorageItem): string {
|
||||
return `${normalize(join(this.path, item.id))}.zip`;
|
||||
}
|
||||
|
||||
private filenameToSourcePath(filename: string): string {
|
||||
return this.pathPrefix + filename;
|
||||
}
|
||||
|
||||
private sourcePathToFilename(sourcePath: string): string {
|
||||
return sourcePath.substring(this.pathPrefix.length);
|
||||
}
|
||||
}
|
||||
85
apps/meteor/ee/server/apps/storage/AppGridFSSourceStorage.ts
Normal file
85
apps/meteor/ee/server/apps/storage/AppGridFSSourceStorage.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AppSourceStorage } from '@rocket.chat/apps/dist/server/storage/AppSourceStorage';
|
||||
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
import { streamToBuffer } from '@rocket.chat/tools';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
import { NpmModuleMongodb } from 'meteor/npm-mongo';
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
export class AppGridFSSourceStorage extends AppSourceStorage {
|
||||
private pathPrefix = 'GridFS:/';
|
||||
|
||||
private bucket: NpmModuleMongodb.GridFSBucket;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
|
||||
|
||||
this.bucket = new NpmModuleMongodb.GridFSBucket(db as any, {
|
||||
bucketName: 'rocketchat_apps_packages',
|
||||
chunkSizeBytes: 1024 * 255,
|
||||
});
|
||||
}
|
||||
|
||||
public async store(item: IAppStorageItem, zip: Buffer): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const filename = this.itemToFilename(item);
|
||||
const writeStream: NpmModuleMongodb.GridFSBucketWriteStream = this.bucket
|
||||
.openUploadStream(filename)
|
||||
.on('finish', () => resolve(this.idToPath(writeStream.id)))
|
||||
.on('error', (error: any) => reject(error));
|
||||
|
||||
writeStream.write(zip);
|
||||
writeStream.end();
|
||||
});
|
||||
}
|
||||
|
||||
public async fetch(item: IAppStorageItem): Promise<Buffer> {
|
||||
return streamToBuffer(this.bucket.openDownloadStream(this.itemToObjectId(item)));
|
||||
}
|
||||
|
||||
public async update(item: IAppStorageItem, zip: Buffer): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileId = this.itemToFilename(item);
|
||||
const writeStream: NpmModuleMongodb.GridFSBucketWriteStream = this.bucket
|
||||
.openUploadStream(fileId)
|
||||
.on('finish', () => {
|
||||
resolve(this.idToPath(writeStream.id));
|
||||
// An error in the following line would not cause the update process to fail
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
this.remove(item).catch(() => {});
|
||||
})
|
||||
|
||||
.on('error', (error: any) => reject(error));
|
||||
|
||||
writeStream.write(zip);
|
||||
writeStream.end();
|
||||
});
|
||||
}
|
||||
|
||||
public async remove(item: IAppStorageItem): Promise<void> {
|
||||
try {
|
||||
await this.bucket.delete(this.itemToObjectId(item));
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('File not found for id')) {
|
||||
console.warn(
|
||||
`This instance could not remove the ${item.info.name} app package. If you are running Rocket.Chat in a cluster with multiple instances, possibly other instance removed the package. If this is not the case, it is possible that the file in the database got renamed or removed manually.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private itemToFilename(item: IAppStorageItem): string {
|
||||
return `${item.info.nameSlug}-${item.info.version}.package`;
|
||||
}
|
||||
|
||||
private idToPath(id: NpmModuleMongodb.GridFSBucketWriteStream['id']): string {
|
||||
return this.pathPrefix + id;
|
||||
}
|
||||
|
||||
private itemToObjectId(item: IAppStorageItem): ObjectId {
|
||||
return new ObjectId(item.sourcePath?.substring(this.pathPrefix.length));
|
||||
}
|
||||
}
|
||||
62
apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts
Normal file
62
apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ILoggerStorageEntry } from '@rocket.chat/apps/dist/server/logging/ILoggerStorageEntry';
|
||||
import type { IAppLogStorageFindOptions } from '@rocket.chat/apps/dist/server/storage/AppLogStorage';
|
||||
import { AppLogStorage } from '@rocket.chat/apps/dist/server/storage/AppLogStorage';
|
||||
import { InstanceStatus } from '@rocket.chat/instance-status';
|
||||
import type { AppLogs } from '@rocket.chat/models';
|
||||
|
||||
import { redact } from '../lib/redactor';
|
||||
|
||||
export class AppRealLogStorage extends AppLogStorage {
|
||||
constructor(private db: typeof AppLogs) {
|
||||
super('mongodb');
|
||||
}
|
||||
|
||||
async find(
|
||||
query: {
|
||||
[field: string]: any;
|
||||
},
|
||||
options: IAppLogStorageFindOptions,
|
||||
) {
|
||||
return this.db.find<ILoggerStorageEntry>(query, options).toArray();
|
||||
}
|
||||
|
||||
async findPaginated(
|
||||
query: {
|
||||
[field: string]: any;
|
||||
},
|
||||
options: IAppLogStorageFindOptions,
|
||||
) {
|
||||
const { cursor, totalCount } = this.db.findPaginated<ILoggerStorageEntry>(query, options);
|
||||
|
||||
const [logs, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async distinctValues(appId: string) {
|
||||
return this.db.getDistinctFieldsForFilters(appId);
|
||||
}
|
||||
|
||||
async storeEntries(logEntry: ILoggerStorageEntry): Promise<ILoggerStorageEntry> {
|
||||
logEntry.instanceId = InstanceStatus.id();
|
||||
|
||||
logEntry.entries.forEach((entry) => {
|
||||
entry.args.forEach(redact);
|
||||
});
|
||||
|
||||
const id = (await this.db.insertOne(logEntry)).insertedId;
|
||||
|
||||
return this.db.findOneById(id);
|
||||
}
|
||||
|
||||
async getEntriesFor(appId: string): Promise<ILoggerStorageEntry[]> {
|
||||
return this.db.find({ appId }).toArray();
|
||||
}
|
||||
|
||||
async removeEntriesFor(appId: string): Promise<void> {
|
||||
await this.db.deleteOne({ appId });
|
||||
}
|
||||
}
|
||||
102
apps/meteor/ee/server/apps/storage/AppRealStorage.ts
Normal file
102
apps/meteor/ee/server/apps/storage/AppRealStorage.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { IMarketplaceInfo } from '@rocket.chat/apps/dist/server/marketplace/IMarketplaceInfo';
|
||||
import { AppMetadataStorage } from '@rocket.chat/apps/dist/server/storage/AppMetadataStorage';
|
||||
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
|
||||
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
|
||||
import type { Apps } from '@rocket.chat/models';
|
||||
import { removeEmpty } from '@rocket.chat/tools';
|
||||
import type { UpdateFilter } from 'mongodb';
|
||||
|
||||
export class AppRealStorage extends AppMetadataStorage {
|
||||
constructor(private db: typeof Apps) {
|
||||
super('mongodb');
|
||||
}
|
||||
|
||||
public async create(item: IAppStorageItem): Promise<IAppStorageItem> {
|
||||
item.createdAt = new Date();
|
||||
item.updatedAt = new Date();
|
||||
|
||||
const doc = await this.db.findOne({ $or: [{ id: item.id }, { 'info.nameSlug': item.info.nameSlug }] });
|
||||
|
||||
if (doc) {
|
||||
throw new Error('App already exists.');
|
||||
}
|
||||
|
||||
const nonEmptyItem = removeEmpty(item);
|
||||
const id = (await this.db.insertOne(nonEmptyItem)).insertedId as unknown as string;
|
||||
nonEmptyItem._id = id;
|
||||
|
||||
return nonEmptyItem;
|
||||
}
|
||||
|
||||
public async retrieveOne(id: string): Promise<IAppStorageItem> {
|
||||
return this.db.findOne({ $or: [{ _id: id }, { id }] });
|
||||
}
|
||||
|
||||
public async retrieveAll(): Promise<Map<string, IAppStorageItem>> {
|
||||
const docs = await this.db.find({}).toArray();
|
||||
const items = new Map();
|
||||
|
||||
docs.forEach((i) => items.set(i.id, i));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async retrieveAllPrivate(): Promise<Map<string, IAppStorageItem>> {
|
||||
const docs = await this.db.find({ installationSource: 'private' }).toArray();
|
||||
const items = new Map();
|
||||
|
||||
docs.forEach((i) => items.set(i.id, i));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<{ success: boolean }> {
|
||||
await this.db.deleteOne({ id });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async updatePartialAndReturnDocument(
|
||||
{ _id, ...item }: IAppStorageItem,
|
||||
{ unsetPermissionsGranted = false } = {},
|
||||
): Promise<IAppStorageItem> {
|
||||
if (!_id) {
|
||||
throw new Error('Property _id is required to update an app storage item');
|
||||
}
|
||||
|
||||
const updateQuery: UpdateFilter<IAppStorageItem> = {
|
||||
$set: item,
|
||||
};
|
||||
|
||||
if (unsetPermissionsGranted) {
|
||||
delete item.permissionsGranted;
|
||||
updateQuery.$unset = { permissionsGranted: 1 };
|
||||
}
|
||||
|
||||
// TODO need to change method return type to IAppStorageItem | null, because findOneAndUpdate can return null if the document is not found.
|
||||
// But for now, we are asserting that it will always return a document.
|
||||
return this.db.findOneAndUpdate({ _id }, updateQuery, { returnDocument: 'after' }) as Promise<IAppStorageItem>;
|
||||
}
|
||||
|
||||
public async updateStatus(_id: string, status: AppStatus): Promise<boolean> {
|
||||
const result = await this.db.updateOne({ _id }, { $set: { status } });
|
||||
return result.modifiedCount > 0;
|
||||
}
|
||||
|
||||
public async updateSetting(_id: string, setting: ISetting): Promise<boolean> {
|
||||
const result = await this.db.updateOne({ _id }, { $set: { [`settings.${setting.id}`]: setting } });
|
||||
|
||||
return result.modifiedCount > 0;
|
||||
}
|
||||
|
||||
public async updateAppInfo(_id: string, info: IAppInfo): Promise<boolean> {
|
||||
const result = await this.db.updateOne({ _id }, { $set: { info } });
|
||||
return result.modifiedCount > 0;
|
||||
}
|
||||
|
||||
public async updateMarketplaceInfo(_id: string, marketplaceInfo: IMarketplaceInfo[]): Promise<boolean> {
|
||||
const result = await this.db.updateOne({ _id }, { $set: { marketplaceInfo } });
|
||||
return result.modifiedCount > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AppSourceStorage } from '@rocket.chat/apps/dist/server/storage/AppSourceStorage';
|
||||
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
|
||||
import { AppFileSystemSourceStorage } from './AppFileSystemSourceStorage';
|
||||
import { AppGridFSSourceStorage } from './AppGridFSSourceStorage';
|
||||
|
||||
export class ConfigurableAppSourceStorage extends AppSourceStorage {
|
||||
private filesystem: AppFileSystemSourceStorage;
|
||||
|
||||
private gridfs: AppGridFSSourceStorage;
|
||||
|
||||
private storage: AppSourceStorage;
|
||||
|
||||
constructor(
|
||||
readonly storageType: string,
|
||||
filesystemStoragePath: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.filesystem = new AppFileSystemSourceStorage();
|
||||
this.gridfs = new AppGridFSSourceStorage();
|
||||
|
||||
this.setStorage(storageType);
|
||||
this.setFileSystemStoragePath(filesystemStoragePath);
|
||||
}
|
||||
|
||||
public setStorage(type: string): void {
|
||||
switch (type) {
|
||||
case 'filesystem':
|
||||
this.storage = this.filesystem;
|
||||
break;
|
||||
case 'gridfs':
|
||||
this.storage = this.gridfs;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public setFileSystemStoragePath(path: string): void {
|
||||
this.filesystem.setPath(path);
|
||||
}
|
||||
|
||||
public async store(item: IAppStorageItem, zip: Buffer): Promise<string> {
|
||||
return this.storage.store(item, zip);
|
||||
}
|
||||
|
||||
public async fetch(item: IAppStorageItem): Promise<Buffer> {
|
||||
return this.storage.fetch(item);
|
||||
}
|
||||
|
||||
public async update(item: IAppStorageItem, zip: Buffer): Promise<string> {
|
||||
return this.storage.update(item, zip);
|
||||
}
|
||||
|
||||
public async remove(item: IAppStorageItem): Promise<void> {
|
||||
return this.storage.remove(item);
|
||||
}
|
||||
}
|
||||
6
apps/meteor/ee/server/apps/storage/index.ts
Normal file
6
apps/meteor/ee/server/apps/storage/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import './AppFileSystemSourceStorage';
|
||||
import './AppGridFSSourceStorage';
|
||||
|
||||
export { AppRealLogStorage } from './AppRealLogStorage';
|
||||
export { AppRealStorage } from './AppRealStorage';
|
||||
export { ConfigurableAppSourceStorage } from './ConfigurableAppSourceStorage';
|
||||
67
apps/meteor/ee/server/configuration/abac.ts
Normal file
67
apps/meteor/ee/server/configuration/abac.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Abac, LDAPEnterprise } from '@rocket.chat/core-services';
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { isValidCron } from 'cron-validator';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { settings } from '../../../server/settings';
|
||||
|
||||
const VIRTRU_PDP_SYNC_JOB = 'ABAC_Virtru_PDP_Sync';
|
||||
|
||||
Meteor.startup(async () => {
|
||||
let stopWatcher: () => void;
|
||||
let stopCronWatcher: () => void;
|
||||
|
||||
License.onToggledFeature('abac', {
|
||||
up: async () => {
|
||||
const { addSettings } = await import('../settings/abac');
|
||||
const { createPermissions } = await import('../lib/abac');
|
||||
|
||||
await addSettings();
|
||||
await createPermissions();
|
||||
|
||||
await import('../hooks/abac');
|
||||
|
||||
stopWatcher = settings.watch('ABAC_Enabled', async (value) => {
|
||||
if (value && settings.get<string>('ABAC_PDP_Type') !== 'virtru') {
|
||||
await LDAPEnterprise.syncUsersAbacAttributes(Users.findLDAPUsers());
|
||||
}
|
||||
});
|
||||
|
||||
async function configureVirtruPdpSync(): Promise<void> {
|
||||
if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) {
|
||||
await cronJobs.remove(VIRTRU_PDP_SYNC_JOB);
|
||||
}
|
||||
|
||||
const abacEnabled = settings.get('ABAC_Enabled');
|
||||
const pdpType = settings.get<string>('ABAC_PDP_Type');
|
||||
|
||||
if (!abacEnabled || pdpType !== 'virtru') {
|
||||
return;
|
||||
}
|
||||
|
||||
const cronValue = settings.get<string>('ABAC_Virtru_Sync_Interval');
|
||||
|
||||
if (!cronValue || !isValidCron(cronValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cronJobs.add(VIRTRU_PDP_SYNC_JOB, cronValue, () => Abac.evaluateRoomMembership());
|
||||
}
|
||||
|
||||
stopCronWatcher = settings.watchMultiple(
|
||||
['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_Virtru_Sync_Interval'],
|
||||
() => void configureVirtruPdpSync(),
|
||||
);
|
||||
},
|
||||
down: async () => {
|
||||
stopWatcher?.();
|
||||
stopCronWatcher?.();
|
||||
|
||||
if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) {
|
||||
await cronJobs.remove(VIRTRU_PDP_SYNC_JOB);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { addSettings } from '../settings/contact-verification';
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await addSettings();
|
||||
});
|
||||
8
apps/meteor/ee/server/configuration/index.ts
Normal file
8
apps/meteor/ee/server/configuration/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import './contact-verification';
|
||||
import './ldap';
|
||||
import './oauth';
|
||||
import './outlookCalendar';
|
||||
import './saml';
|
||||
import './videoConference';
|
||||
import './voip';
|
||||
import './abac';
|
||||
127
apps/meteor/ee/server/configuration/ldap.ts
Normal file
127
apps/meteor/ee/server/configuration/ldap.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { LDAPEnterprise } from '@rocket.chat/core-services';
|
||||
import type { IImportUser, ILDAPEntry, IUser } from '@rocket.chat/core-typings';
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import { isValidCron } from 'cron-validator';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { callbacks } from '../../../server/lib/callbacks';
|
||||
import type { LDAPConnection } from '../../../server/lib/ldap/Connection';
|
||||
import { logger } from '../../../server/lib/ldap/Logger';
|
||||
import { settings } from '../../../server/settings';
|
||||
import { LDAPEEManager } from '../lib/ldap/Manager';
|
||||
import { addSettings, ldapIntervalValuesToCronMap } from '../settings/ldap';
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await License.onLicense('ldap-enterprise', async () => {
|
||||
await addSettings();
|
||||
|
||||
// Configure background sync cronjob
|
||||
function configureBackgroundSync(jobName: string, enableSetting: string, intervalSetting: string, cb: () => void): () => Promise<void> {
|
||||
let lastSchedule: string;
|
||||
return async function addCronJobDebounced(): Promise<void> {
|
||||
if (settings.get('LDAP_Enable') !== true || settings.get(enableSetting) !== true) {
|
||||
if (await cronJobs.has(jobName)) {
|
||||
logger.info({ msg: 'Disabling LDAP Background Sync', jobName });
|
||||
await cronJobs.remove(jobName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const settingValue = settings.get<string>(intervalSetting);
|
||||
const schedule =
|
||||
ldapIntervalValuesToCronMap[settingValue] ??
|
||||
(isValidCron(settingValue) ? settingValue : ((await Settings.findOneById(intervalSetting))?.packageValue as string));
|
||||
if (schedule) {
|
||||
if (schedule !== lastSchedule && (await cronJobs.has(jobName))) {
|
||||
await cronJobs.remove(jobName);
|
||||
}
|
||||
|
||||
lastSchedule = schedule;
|
||||
logger.info({ msg: 'Enabling LDAP Background Sync', jobName });
|
||||
|
||||
await cronJobs.add(jobName, schedule, cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const addCronJob = configureBackgroundSync('LDAP_Sync', 'LDAP_Background_Sync', 'LDAP_Background_Sync_Interval', () =>
|
||||
LDAPEnterprise.sync(),
|
||||
);
|
||||
const addAvatarCronJob = configureBackgroundSync(
|
||||
'LDAP_AvatarSync',
|
||||
'LDAP_Background_Sync_Avatars',
|
||||
'LDAP_Background_Sync_Avatars_Interval',
|
||||
() => LDAPEnterprise.syncAvatars(),
|
||||
);
|
||||
const addLogoutCronJob = configureBackgroundSync(
|
||||
'LDAP_AutoLogout',
|
||||
'LDAP_Sync_AutoLogout_Enabled',
|
||||
'LDAP_Sync_AutoLogout_Interval',
|
||||
() => LDAPEnterprise.syncLogout(),
|
||||
);
|
||||
|
||||
const addAbacCronJob = configureBackgroundSync(
|
||||
'LDAP_AbacSync',
|
||||
'LDAP_Background_Sync_ABAC_Attributes',
|
||||
'LDAP_Background_Sync_ABAC_Attributes_Interval',
|
||||
() => LDAPEnterprise.syncAbacAttributes(),
|
||||
);
|
||||
|
||||
settings.watchMultiple(['LDAP_Background_Sync', 'LDAP_Background_Sync_Interval'], addCronJob);
|
||||
settings.watchMultiple(['LDAP_Background_Sync_Avatars', 'LDAP_Background_Sync_Avatars_Interval'], addAvatarCronJob);
|
||||
settings.watchMultiple(['LDAP_Sync_AutoLogout_Enabled', 'LDAP_Sync_AutoLogout_Interval'], addLogoutCronJob);
|
||||
settings.watchMultiple(['LDAP_Background_Sync_ABAC_Attributes', 'LDAP_Background_Sync_ABAC_Attributes_Interval'], addAbacCronJob);
|
||||
|
||||
settings.watch('LDAP_Enable', async () => {
|
||||
await addCronJob();
|
||||
await addAvatarCronJob();
|
||||
await addLogoutCronJob();
|
||||
await addAbacCronJob();
|
||||
});
|
||||
|
||||
settings.watch<string>('LDAP_Groups_To_Rocket_Chat_Teams', (value) => {
|
||||
try {
|
||||
LDAPEEManager.validateLDAPTeamsMappingChanges(value);
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
});
|
||||
|
||||
settings.watch<string>('LDAP_ABAC_AttributeMap', (value) => {
|
||||
try {
|
||||
LDAPEEManager.validateLDAPABACAttributeMap(value);
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
});
|
||||
|
||||
callbacks.add(
|
||||
'mapLDAPUserData',
|
||||
(userData: IImportUser, ldapUser?: ILDAPEntry) => {
|
||||
if (!ldapUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
LDAPEEManager.copyCustomFields(ldapUser, userData);
|
||||
LDAPEEManager.copyActiveState(ldapUser, userData);
|
||||
},
|
||||
callbacks.priority.MEDIUM,
|
||||
'mapLDAPCustomFields',
|
||||
);
|
||||
|
||||
callbacks.add(
|
||||
'onLDAPLogin',
|
||||
async ({ user, ldapUser, isNewUser }: { user: IUser; ldapUser: ILDAPEntry; isNewUser: boolean }, ldap?: LDAPConnection) => {
|
||||
if (!ldap) {
|
||||
return;
|
||||
}
|
||||
|
||||
await LDAPEEManager.advancedSyncForUser(ldap, user, isNewUser, ldapUser.dn);
|
||||
},
|
||||
callbacks.priority.MEDIUM,
|
||||
'advancedLDAPSync',
|
||||
);
|
||||
});
|
||||
});
|
||||
99
apps/meteor/ee/server/configuration/oauth.ts
Normal file
99
apps/meteor/ee/server/configuration/oauth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Roles } from '@rocket.chat/models';
|
||||
import { capitalize } from '@rocket.chat/tools';
|
||||
|
||||
import { callbacks } from '../../../server/lib/callbacks';
|
||||
import { settings } from '../../../server/settings';
|
||||
import { OAuthEEManager } from '../lib/oauth/Manager';
|
||||
import { syncUserRoles } from '../lib/syncUserRoles';
|
||||
|
||||
interface IOAuthUserService {
|
||||
serviceName: string;
|
||||
serviceData: Record<string, any>;
|
||||
user: IUser;
|
||||
}
|
||||
|
||||
interface IOAuthUserIdentity {
|
||||
serviceName: string;
|
||||
identity: Record<string, any>;
|
||||
user: IUser;
|
||||
}
|
||||
|
||||
interface IOAuthSettings {
|
||||
mapChannels: boolean;
|
||||
mergeRoles: boolean;
|
||||
rolesToSync: string;
|
||||
rolesClaim: string;
|
||||
groupsClaim: string;
|
||||
channelsAdmin: string;
|
||||
channelsMap: string;
|
||||
}
|
||||
|
||||
const logger = new Logger('EECustomOAuth');
|
||||
|
||||
function getOAuthSettings(serviceName: string): IOAuthSettings {
|
||||
return {
|
||||
mapChannels: settings.get<boolean>(`Accounts_OAuth_Custom-${serviceName}-map_channels`),
|
||||
mergeRoles: settings.get<boolean>(`Accounts_OAuth_Custom-${serviceName}-merge_roles`),
|
||||
rolesToSync: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-roles_to_sync`),
|
||||
rolesClaim: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-roles_claim`),
|
||||
groupsClaim: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-groups_claim`),
|
||||
channelsAdmin: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-channels_admin`),
|
||||
channelsMap: settings.get<string>(`Accounts_OAuth_Custom-${serviceName}-groups_channel_map`),
|
||||
};
|
||||
}
|
||||
|
||||
function getChannelsMap(channelsMap: string): Record<string, any> | undefined {
|
||||
channelsMap = (channelsMap || '{}').trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(channelsMap);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Unexpected error', err });
|
||||
}
|
||||
}
|
||||
|
||||
await License.onLicense('oauth-enterprise', () => {
|
||||
callbacks.add('afterProcessOAuthUser', async (auth: IOAuthUserService) => {
|
||||
auth.serviceName = capitalize(auth.serviceName);
|
||||
const settings = getOAuthSettings(auth.serviceName);
|
||||
|
||||
if (settings.mapChannels) {
|
||||
const channelsMap = getChannelsMap(settings.channelsMap);
|
||||
await OAuthEEManager.mapSSOGroupsToChannels(auth.user, auth.serviceData, settings.groupsClaim, channelsMap, settings.channelsAdmin);
|
||||
}
|
||||
|
||||
if (settings.mergeRoles) {
|
||||
await OAuthEEManager.updateRolesFromSSO(
|
||||
auth.user,
|
||||
auth.serviceData,
|
||||
settings.rolesClaim,
|
||||
settings.rolesToSync.split(',').map((role) => role.trim()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
callbacks.add('afterValidateNewOAuthUser', async (auth: IOAuthUserIdentity) => {
|
||||
auth.serviceName = capitalize(auth.serviceName);
|
||||
const settings = getOAuthSettings(auth.serviceName);
|
||||
|
||||
if (settings.mapChannels) {
|
||||
const channelsMap = getChannelsMap(settings.channelsMap);
|
||||
await OAuthEEManager.mapSSOGroupsToChannels(auth.user, auth.identity, settings.groupsClaim, channelsMap, settings.channelsAdmin);
|
||||
}
|
||||
|
||||
if (settings.mergeRoles) {
|
||||
const rolesFromSSO = await OAuthEEManager.mapRolesFromSSO(auth.identity, settings.rolesClaim);
|
||||
const mappedRoles = (await Roles.findInIdsOrNames(rolesFromSSO).toArray()).map((role) => role._id);
|
||||
const rolesToSync = settings.rolesToSync.split(',').map((role) => role.trim());
|
||||
|
||||
const allowedRoles = (await Roles.findInIdsOrNames(rolesToSync).toArray()).map((role) => role._id);
|
||||
|
||||
await syncUserRoles(auth.user._id, mappedRoles, {
|
||||
allowedRoles,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
14
apps/meteor/ee/server/configuration/outlookCalendar.ts
Normal file
14
apps/meteor/ee/server/configuration/outlookCalendar.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Calendar } from '@rocket.chat/core-services';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { addSettings } from '../settings/outlookCalendar';
|
||||
|
||||
Meteor.startup(() =>
|
||||
License.onLicense('outlook-calendar', async () => {
|
||||
addSettings();
|
||||
|
||||
await Calendar.setupNextNotification();
|
||||
await Calendar.setupNextStatusChange();
|
||||
}),
|
||||
);
|
||||
73
apps/meteor/ee/server/configuration/saml.ts
Normal file
73
apps/meteor/ee/server/configuration/saml.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Roles, Users } from '@rocket.chat/models';
|
||||
|
||||
import { ensureArray } from '../../../lib/utils/arrayUtils';
|
||||
import type { ISAMLUser } from '../../../server/lib/saml/definition/ISAMLUser';
|
||||
import { SAMLUtils } from '../../../server/lib/saml/lib/Utils';
|
||||
import { settings } from '../../../server/settings';
|
||||
import { addSettings } from '../settings/saml';
|
||||
|
||||
await License.onLicense('saml-enterprise', () => {
|
||||
SAMLUtils.events.on('mapUser', async ({ profile, userObject }: { profile: Record<string, any>; userObject: ISAMLUser }) => {
|
||||
const roleAttributeName = settings.get('SAML_Custom_Default_role_attribute_name') as string;
|
||||
const roleAttributeSync = settings.get('SAML_Custom_Default_role_attribute_sync');
|
||||
|
||||
if (!roleAttributeSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (roleAttributeName && profile[roleAttributeName]) {
|
||||
let value = profile[roleAttributeName] || '';
|
||||
if (typeof value === 'string') {
|
||||
value = value.split(',');
|
||||
}
|
||||
|
||||
const savedRoles = await Roles.findInIdsOrNames(ensureArray<string>(value)).toArray();
|
||||
|
||||
userObject.roles = savedRoles.map((role) => role._id);
|
||||
}
|
||||
});
|
||||
|
||||
SAMLUtils.events.on('loadConfigs', (service: string, configs: Record<string, any>): void => {
|
||||
// Include ee settings on the configs object so that they can be copied to the login service too
|
||||
const rawCustomAuthnContext = settings.get(`${service}_custom_authn_context`);
|
||||
const customAuthnContext = typeof rawCustomAuthnContext === 'string' ? rawCustomAuthnContext.trim() : undefined;
|
||||
|
||||
Object.assign(configs, {
|
||||
...(customAuthnContext !== undefined && { customAuthnContext }),
|
||||
authnContextComparison: settings.get(`${service}_authn_context_comparison`),
|
||||
identifierFormat: settings.get(`${service}_identifier_format`),
|
||||
nameIDPolicyTemplate: settings.get(`${service}_NameId_template`),
|
||||
authnContextTemplate: settings.get(`${service}_AuthnContext_template`),
|
||||
authRequestTemplate: settings.get(`${service}_AuthRequest_template`),
|
||||
logoutResponseTemplate: settings.get(`${service}_LogoutResponse_template`),
|
||||
logoutRequestTemplate: settings.get(`${service}_LogoutRequest_template`),
|
||||
metadataCertificateTemplate: settings.get(`${service}_MetadataCertificate_template`),
|
||||
metadataTemplate: settings.get(`${service}_Metadata_template`),
|
||||
});
|
||||
});
|
||||
|
||||
SAMLUtils.events.on('updateCustomFields', async (loginResult: Record<string, any>, updatedUser: { userId: string; token: string }) => {
|
||||
const userDataCustomFieldMap = settings.get('SAML_Custom_Default_user_data_custom_fieldmap') as string;
|
||||
const customMap: Record<string, any> = JSON.parse(userDataCustomFieldMap);
|
||||
|
||||
const customFieldsList: Record<string, any> = {};
|
||||
|
||||
for (const spCustomFieldName in customMap) {
|
||||
if (!customMap.hasOwnProperty(spCustomFieldName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const customAttribute = customMap[spCustomFieldName];
|
||||
const value = SAMLUtils.getProfileValue(loginResult.profile, {
|
||||
fieldName: spCustomFieldName,
|
||||
});
|
||||
customFieldsList[customAttribute] = value;
|
||||
}
|
||||
|
||||
await Users.updateCustomFieldsById(updatedUser.userId, customFieldsList);
|
||||
});
|
||||
});
|
||||
|
||||
// For setting creation we add the listener first because the event is emmited during startup
|
||||
SAMLUtils.events.on('addSettings', (name: string): void | Promise<void> => License.onLicense('saml-enterprise', () => addSettings(name)));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user